From 6931cfc86f6430853caab7aa6b364d8b8809f913 Mon Sep 17 00:00:00 2001 From: Thomas Hackl Date: Mon, 11 Nov 2024 07:33:55 +0000 Subject: Resolve "ContentBar 2.0" Closes #4244 Merge request studip/studip!3128 --- app/controllers/course/wiki.php | 60 +++--- app/controllers/oer/market.php | 9 +- app/views/course/wiki/edit.php | 4 +- app/views/course/wiki/page.php | 3 +- app/views/course/wiki/version.php | 8 +- app/views/oer/market/details.php | 7 +- lib/classes/Debug/VueCollector.php | 17 ++ lib/classes/Icon.php | 7 +- lib/classes/VueApp.php | 23 ++- lib/models/WikiPage.php | 26 --- lib/modules/CoreWiki.php | 53 ++++-- resources/assets/javascripts/lib/actionmenu.js | 6 +- resources/assets/stylesheets/scss/contentbar.scss | 4 +- .../scss/courseware/layouts/ribbon.scss | 118 ++++++++---- .../stylesheets/scss/courseware/layouts/tabs.scss | 2 +- resources/assets/stylesheets/scss/responsive.scss | 112 ++++------- .../assets/stylesheets/scss/table_of_contents.scss | 73 +++++--- resources/studip.d.ts | 21 +++ resources/vue/components/ContentBar.vue | 163 ++++++++++++++++ resources/vue/components/ContentBarBreadcrumbs.vue | 78 ++++++++ .../vue/components/ContentBarTableOfContents.vue | 63 +++++++ resources/vue/components/ContentBarTocItemList.vue | 33 ++++ resources/vue/components/WikiEditor.vue | 22 ++- .../structural-element/CoursewareRibbon.vue | 207 +++++++-------------- .../structural-element/CoursewareRibbonToolbar.vue | 50 ++--- .../structural-element/CoursewareSearchResults.vue | 16 +- .../CoursewareStructuralElement.vue | 128 +++++++------ .../structural-element/CoursewareWelcomeScreen.vue | 6 +- .../PublicCoursewareStructuralElement.vue | 17 +- .../structural-element-components.js | 6 +- .../tasks/CoursewareDashboardStudents.vue | 18 +- .../courseware/tasks/PagesTaskGroupsShow.vue | 18 +- .../courseware/toolbar/CoursewareToolbar.vue | 2 +- .../components/responsive/ResponsiveContentBar.vue | 63 +++---- resources/vue/components/table-of-contents.ts | 19 ++ resources/vue/mixins/courseware/export.js | 5 +- resources/vue/store/StudipStore.js | 21 ++- .../store/courseware/courseware-public.module.js | 13 -- .../vue/store/courseware/courseware.module.js | 8 - templates/vue-app.php | 7 +- 40 files changed, 949 insertions(+), 567 deletions(-) create mode 100644 resources/vue/components/ContentBar.vue create mode 100644 resources/vue/components/ContentBarBreadcrumbs.vue create mode 100644 resources/vue/components/ContentBarTableOfContents.vue create mode 100644 resources/vue/components/ContentBarTocItemList.vue create mode 100644 resources/vue/components/table-of-contents.ts diff --git a/app/controllers/course/wiki.php b/app/controllers/course/wiki.php index 8e72f8a..926f641 100644 --- a/app/controllers/course/wiki.php +++ b/app/controllers/course/wiki.php @@ -30,7 +30,7 @@ class Course_WikiController extends AuthenticatedController } Navigation::activateItem('/course/wiki/start'); - $this->page = new WikiPage($page_id); + $this->page = WikiPage::find($page_id) ?: new WikiPage(); $this->validateWikiPage($this->page, $this->range); $sidebar = Sidebar::Get(); @@ -97,6 +97,13 @@ class Course_WikiController extends AuthenticatedController $sidebar->addWidget($actions); } + $contentbarProps = [ + 'icon' => 'wiki', + 'isContentBar' => true + ]; + + $toc = CoreWiki::getTOC($this->page); + if (!$this->page->isNew()) { //then the wiki is not empty $search = new SearchWidget($this->searchURL()); @@ -116,12 +123,18 @@ class Course_WikiController extends AuthenticatedController Icon::create('file-pdf') ); $sidebar->addWidget($exports); + + $contentbarProps['toc'] = $toc; } - $startPage = WikiPage::find($this->range->getConfiguration()->WIKI_STARTPAGE_ID); - $this->contentbar = ContentBar::get() - ->setTOC(CoreWiki::getTOC($this->page)) - ->setIcon(Icon::create('wiki')); + // Content bar + $this->contentBarVueApp = \Studip\VueApp::create('ContentBar') + ->withProps($contentbarProps) + ->withComponent('ContentBarBreadcrumbs') + ->withSlot('breadcrumb-list', + sprintf("", json_encode($toc)) + ); + if (!$this->page->isNew()) { $author = _('unbekannt'); if ($this->page->user) { @@ -132,8 +145,8 @@ class Course_WikiController extends AuthenticatedController ); } - $this->contentbar->setInfoHTML(sprintf( - _('Version %1$s, geändert von %2$s
am %3$s'), + $this->contentBarVueApp = $this->contentBarVueApp->withSlot('info-text', sprintf( + _('Version %1$s, geändert von %2$s am %3$s'), $this->page->versionnumber, $author, date('d.m.Y H:i:s', $this->page['chdate']) @@ -167,13 +180,7 @@ class Course_WikiController extends AuthenticatedController ); } } - $action_menu->addLink( - '#', - _('Als Vollbild anzeigen'), - Icon::create('screen-full'), - ['class' => 'fullscreen-trigger hidden-medium-down'] - ); - $this->contentbar->setActionMenu($action_menu); + $this->contentBarVueApp = $this->contentBarVueApp->withSlot('menu', $action_menu->render()); } } @@ -478,10 +485,6 @@ class Course_WikiController extends AuthenticatedController ORDER BY Nachname, Vorname", [$page->id] ); - $this->contentbar = ContentBar::get() - ->setTOC(CoreWiki::getTOC($page)) - ->setIcon(Icon::create('wiki')) - ->setInfoHTML(_('Zuletzt gespeichert') .': '. ''); } public function apply_editing_action(WikiPage $page) @@ -715,15 +718,24 @@ class Course_WikiController extends AuthenticatedController Navigation::activateItem('/course/wiki/start'); Sidebar::Get()->addWidget($this->getViewsWidget($version->page, 'history')); - $startPage = WikiPage::find($this->range->getConfiguration()->WIKI_STARTPAGE_ID); - $this->contentbar = ContentBar::get() - ->setTOC(CoreWiki::getTOC($version->page)) - ->setIcon(Icon::create('wiki')) - ->setInfoHTML(sprintf( + + $toc = CoreWiki::getTOC($version->page); + $this->contentBarVueApp = \Studip\VueApp::create('ContentBar') + ->withProps([ + 'icon' => 'wiki', + 'isContentBar' => true, + 'toc' => $toc, + ]) + ->withSlot('info-text', sprintf( _('Version %1$s vom %2$s'), $version->versionnumber, date('d.m.Y H:i:s', $version['mkdate']) - )); + )) + ->withComponent('ContentBarBreadcrumbs') + ->withSlot('breadcrumb-list', sprintf( + "", + json_encode($toc)) + ); } public function blame_action(WikiPage $page) diff --git a/app/controllers/oer/market.php b/app/controllers/oer/market.php index 72133d7..69030c2 100644 --- a/app/controllers/oer/market.php +++ b/app/controllers/oer/market.php @@ -328,10 +328,11 @@ class Oer_MarketController extends StudipController } } - $this->contentbar = ContentBar::get() - ->setTOC(new TOCItem($this->material['name'])) - ->setInfoHTML(htmlReady($infotext)) - ->setIcon(Icon::create('oer-campus')); + $this->contentBarVueApp = \Studip\VueApp::create('ContentBar')->withProps([ + 'title' => $this->material['name'], + 'icon' => 'oer-campus', + 'isContentBar' => true, + ])->withSlot('info-text', htmlReady($infotext)); } public function embed_action($material_id) diff --git a/app/views/course/wiki/edit.php b/app/views/course/wiki/edit.php index 983a02c..40e0111 100644 --- a/app/views/course/wiki/edit.php +++ b/app/views/course/wiki/edit.php @@ -3,12 +3,9 @@ * @var WikiPage $page * @var Course_WikiController $controller * @var WikiOnlineEditingUser $me_online - * @var ContentBar $contentbar */ ?> - - withProps([ 'cancel-url' => $controller->leave_editingURL($page), @@ -18,5 +15,6 @@ 'page-id' => (int) $page->id, 'save-url' => $controller->saveURL($page), 'users' => $page->getOnlineUsers(), + 'toc' => CoreWiki::getTOC($page), ]) ?> diff --git a/app/views/course/wiki/page.php b/app/views/course/wiki/page.php index e3c67cf..21ae031 100644 --- a/app/views/course/wiki/page.php +++ b/app/views/course/wiki/page.php @@ -4,10 +4,11 @@ * @var string $edit_perms * @var Context $range * @var Course_WikiController $controller + * @var \Studip\VueApp $contentBarVueApp */ -echo $contentbar; ?> +render() ?> isEditable()) : ?>
diff --git a/app/views/course/wiki/version.php b/app/views/course/wiki/version.php index 26094ae..177d7d0 100644 --- a/app/views/course/wiki/version.php +++ b/app/views/course/wiki/version.php @@ -1,4 +1,10 @@ - + + +render() ?>
diff --git a/app/views/oer/market/details.php b/app/views/oer/market/details.php index 9e1c60b..c05bbdf 100644 --- a/app/views/oer/market/details.php +++ b/app/views/oer/market/details.php @@ -1,4 +1,9 @@ - + +render() ?> getDownloadUrl() ?> diff --git a/lib/classes/Debug/VueCollector.php b/lib/classes/Debug/VueCollector.php index a2d90f3..0dd479b 100644 --- a/lib/classes/Debug/VueCollector.php +++ b/lib/classes/Debug/VueCollector.php @@ -45,6 +45,23 @@ final class VueCollector extends DataCollector implements Renderable } } + $slots = $this->app->getSlots(); + if (count($slots) > 0) { + ksort($slots); + + $data['== SLOTS =='] = count($slots) . ' items'; + foreach ($slots as $key => $value) { + $data[$key] = $this->dumpVar($value); + } + } + + $components = $this->app->getComponents(); + ksort($components); + $data['== COMPONENTS =='] = count($components) . ' items'; + foreach ($components as $value) { + $data[$value] = ''; + } + return $data; } diff --git a/lib/classes/Icon.php b/lib/classes/Icon.php index 6c586a0..fd2a25b 100644 --- a/lib/classes/Icon.php +++ b/lib/classes/Icon.php @@ -9,7 +9,7 @@ * @license GPL2 or any later version * @since 3.2 */ -class Icon +class Icon implements JsonSerializable { const SVG = 1; const CSS_BACKGROUND = 4; @@ -196,6 +196,11 @@ class Icon return $this->asImg(); } + public function jsonSerialize(): mixed + { + return get_object_vars($this); + } + /** * Renders the icon inside an img html tag. * diff --git a/lib/classes/VueApp.php b/lib/classes/VueApp.php index 76c9a52..c26b011 100644 --- a/lib/classes/VueApp.php +++ b/lib/classes/VueApp.php @@ -43,6 +43,7 @@ final class VueApp implements Stringable private array $slots = []; private array $stores = []; private array $storeData = []; + private array $components = []; /** * Private constructor since we want to enforce the use of VueApp::create(). @@ -50,6 +51,7 @@ final class VueApp implements Stringable private function __construct( private readonly string $base_component ) { + $this->components[] = $base_component; } /** @@ -153,12 +155,30 @@ final class VueApp implements Stringable } /** + * Registers a component for use e.g. in slots. + */ + public function withComponent(string $component): VueApp + { + $clone = clone $this; + $clone->components[] = $component; + return $clone; + } + + /** + * Returns all components + */ + public function getComponents(): array + { + return $this->components; + } + + /** * Returns the template to render the vue app */ public function getTemplate(): Template { $data = [ - 'components' => [$this->base_component], + 'components' => $this->components, ]; if (count($this->stores) > 0) { @@ -174,6 +194,7 @@ final class VueApp implements Stringable $template->attributes = ['data-vue-app' => json_encode($data)]; $template->props = $this->getPreparedProps(); $template->storeData = $this->storeData; + $template->slots = $this->getSlots(); return $template; } diff --git a/lib/models/WikiPage.php b/lib/models/WikiPage.php index 817dfae..bb41d1b 100644 --- a/lib/models/WikiPage.php +++ b/lib/models/WikiPage.php @@ -202,32 +202,6 @@ class WikiPage extends SimpleORMap implements PrivacyObject /** - * Returns the start page of a wiki for a given course. The start page has - * the keyword 'WikiWikiWeb'. - * - * @param string $range_id Course id - * @return WikiPage - */ - public static function getStartPage($range_id): WikiPage - { - $page_id = CourseConfig::get($range_id)->WIKI_STARTPAGE_ID; - - if ($page_id) { - return self::find($page_id); - } - - $page = new WikiPage(); - $page->setValue('content', _('Dieses Wiki ist noch leer.')); - if ($page->isEditable()) { - $page->setValue( - 'content', - $page->getValue('content') . ' ' . _("Bearbeiten Sie es!\nNeue Seiten oder Links werden einfach durch Eingeben von [nop][[Wikinamen]][/nop] in doppelten eckigen Klammern angelegt.") - ); - } - return $page; - } - - /** * Export available data of a given user into a storage object * (an instance of the StoredUserData class) for that user. * diff --git a/lib/modules/CoreWiki.php b/lib/modules/CoreWiki.php index a447451..b544af3 100644 --- a/lib/modules/CoreWiki.php +++ b/lib/modules/CoreWiki.php @@ -178,28 +178,45 @@ class CoreWiki extends CorePlugin implements StudipModule /** - * Generates a page hierarchy for table of contents/breadcrumbs. - * @return TOCItem + * Generates a TOCItem tree containing all pages in the currently opened wiki + * for use in table of contents/breadcrumbs. + * To prevent cyclic data references, the TOCItems in the tree do not contain + * references to their parent pages. + * This allows the resultant TOCItem to be serialized via json_decode for + * use in Vue. + * + * @param $activePage WikiPage The page that the user has currently navigated to. + * @return TOCItem A TOCItem for the root of the wiki and all of its descendants. */ - public static function getTOC($page, $first = true): TOCItem + public static function getTOC(WikiPage $activePage): TOCItem { - $root = new TOCItem( - ($page && ($page->isNew() || $page->name === 'WikiWikiWeb')) - ? _('Wiki-Startseite') - : $page->name - ); - $root->setURL(URLHelper::getURL('dispatch.php/course/wiki/page/'.$page->id)); - if ($page->name == 'WikiWikiWeb' || $page->id == CourseConfig::get($page->range_id)->WIKI_STARTPAGE_ID) { - $root->setIcon(Icon::create('wiki')); - } - $root->setActive($first); + $rootId = CourseConfig::get(Context::getId())->WIKI_STARTPAGE_ID; + $rootPage = WikiPage::find($rootId) ?: $activePage; - if ($page->parent) { - $parent = self::getTOC($page->parent, false); - $root->setParent($parent); - } + $rootToc = self::getTOCRecursive($rootPage, $activePage->page_id); + $rootToc->setTitle(_('Wiki-Startseite')); + $rootToc->setIcon(Icon::create('wiki')); + return $rootToc; + } - return $root; + /** + * Using a recursive depth-first traversal of the wiki page hierarchy, + * create a TOCItem tree starting at the given $page. + * + * @param WikiPage $page The currently visited page in the traversal. + * @param int|null $active_page_id The id of the page that the user has navigated to. + * @return TOCItem A TOCItem for the given $page and all of its descendants + */ + private static function getTOCRecursive(WikiPage $page, int|null $active_page_id): TOCItem + { + $toc = new TOCItem($page->isNew() ? _('Wiki-Startseite') : $page->name); + $toc->setURL($page->isNew() ? URLHelper::getURL('dispatch.php/course/wiki/page') : URLHelper::getURL('dispatch.php/course/wiki/page/' . $page->id)); + $toc->setActive($page->page_id === $active_page_id); + foreach ($page->children as $child) { + $childToc = self::getTOCRecursive($child, $active_page_id); + $toc->children[] = $childToc; + } + return $toc; } } diff --git a/resources/assets/javascripts/lib/actionmenu.js b/resources/assets/javascripts/lib/actionmenu.js index a81f78a..da85e33 100644 --- a/resources/assets/javascripts/lib/actionmenu.js +++ b/resources/assets/javascripts/lib/actionmenu.js @@ -103,7 +103,11 @@ class ActionMenu // Reposition the menu? if (position) { - let parents = getScrollableParents(this.element, menu_width, menu_height); + let parents = getScrollableParents(this.element, menu_width, menu_height) + // Prevent us from appending the actionmenu outside of the . + // (If it's appended outside of , some CSS rules will not + // be applied, and the Z-ordering will be incorrect.) + .filter(parent => parent !== document.documentElement); if (parents.length > 0) { const form = this.element.closest('form'); if (form) { diff --git a/resources/assets/stylesheets/scss/contentbar.scss b/resources/assets/stylesheets/scss/contentbar.scss index 3b0aff3..338737b 100644 --- a/resources/assets/stylesheets/scss/contentbar.scss +++ b/resources/assets/stylesheets/scss/contentbar.scss @@ -2,7 +2,6 @@ background-color: var(--dark-gray-color-10); display: flex; flex-wrap: nowrap; - height: auto; align-items: center; justify-content: flex-start; margin-bottom: 15px; @@ -85,6 +84,9 @@ margin: 0 7px; @-moz-document url-prefix() { + &.contentbar-toc-wrapper { + margin-top: -4px; + } &.contentbar-action-menu-wrapper { margin-top: 2px; } diff --git a/resources/assets/stylesheets/scss/courseware/layouts/ribbon.scss b/resources/assets/stylesheets/scss/courseware/layouts/ribbon.scss index 457cd4d..28ecfbe 100644 --- a/resources/assets/stylesheets/scss/courseware/layouts/ribbon.scss +++ b/resources/assets/stylesheets/scss/courseware/layouts/ribbon.scss @@ -14,17 +14,13 @@ $consum_ribbon_width: calc(100% - 58px); } .cw-ribbon-wrapper-consume { - position: fixed; - padding-top: 15px; + padding-top: 7px; padding-bottom: 15px; background-color: var(--white); - width: $consum_ribbon_width; - height: 46px; z-index: 42; } .cw-ribbon-consume-bottom { - position: fixed; top: 75px; height: 15px; left: 0; @@ -56,38 +52,46 @@ $consum_ribbon_width: calc(100% - 58px); } .cw-ribbon { + position: relative; display: flex; flex-wrap: wrap; - height: auto; - min-height: 30px; + align-items: center; + height: 60px; + min-height: 60px; margin-bottom: 15px; - padding: 1em; + padding: 0 2em; justify-content: space-between; background-color: var(--dark-gray-color-10); &.cw-ribbon-sticky { position: fixed; top: 50px; - width: calc(100% - 346px); + width: calc(100% - 375px); z-index: 40; } &.cw-ribbon-consume { width: $consum_ribbon_width; - position: fixed; margin-bottom: 0; } .cw-ribbon-wrapper-left { display: flex; - max-width: calc(100% - 106px); + gap: 1em; + min-width: 0; + width: 100%; + flex: 1; .cw-ribbon-nav { display: flex; - min-width: 75px; + align-items: center; + + .contentbar-icon { + margin: 0 15px 0 10px; - &.single-icon { - min-width: 45px; + img { + vertical-align: middle; + } } } @@ -138,11 +142,18 @@ $consum_ribbon_width: calc(100% - 58px); } } } + + .cw-ribbon-info-text { + font-size: small; + line-height: 1em; + } } } .cw-ribbon-wrapper-right { display: flex; + gap: 0.5em; + align-items: center; button { border: none; @@ -174,7 +185,7 @@ $consum_ribbon_width: calc(100% - 58px); &.cw-ribbon-button-next { @include background-icon(arr_1right, clickable, 24); - margin: 0 1em 0 0; + margin: 0 0.5em 0 0; } &.cw-ribbon-button-prev-disabled { @@ -185,15 +196,13 @@ $consum_ribbon_width: calc(100% - 58px); &.cw-ribbon-button-next-disabled { @include background-icon(arr_1right, inactive, 24); - margin: 0 1em 0 0; + margin: 0 0.5em 0 0; cursor: default; } } } .cw-ribbon-action-menu { - vertical-align: text-top; - margin: 2px 0 0 2px; &.is-open { z-index: 32; } @@ -204,26 +213,25 @@ $consum_ribbon_width: calc(100% - 58px); border: solid thin var(--content-color-40); box-shadow: 2px 2px var(--dark-gray-color-30); position: absolute; - right: -570px; - top: 15px; + right: 0; + top: 0; height: 100%; max-width: calc(100% - 28px); display: flex; flex-flow: row; - transition: right 0.8s; z-index: 42; - &.unfold { - right: 0px; - margin-right: 15px; - } - &.cw-ribbon-tools-consume { - position: fixed; + right: 15px; + } - &.unfold { - right: 15px; - } + &.cw-ribbon-slide-enter-active, + &.cw-ribbon-slide-leave-active { + transition: transform var(--transition-duration-superslow); + } + &.cw-ribbon-slide-enter, + &.cw-ribbon-slide-leave-to { + transform: translateX(calc(100% + 30px)); } &.cw-ribbon-tools-sticky { @@ -274,6 +282,7 @@ $consum_ribbon_width: calc(100% - 58px); > .cw-tabs-nav { border: none; + height: 57px; width: calc(100% - 48px); > button { @@ -281,7 +290,7 @@ $consum_ribbon_width: calc(100% - 58px); max-width: unset; flex-grow: 0.5; &::after { - margin-top: 16px; + margin-top: 20px; } } } @@ -348,7 +357,7 @@ $consum_ribbon_width: calc(100% - 58px); .cw-structural-element-consumemode { .cw-ribbon-tools { - top: 25px; + top: 18px; } } @@ -370,3 +379,48 @@ $consum_ribbon_width: calc(100% - 58px); height: 75px; } } + +// Rules extracted from PHP contentbar for use in ContentBar.vue +.contentbar-button-wrapper { + height: 24px; + margin: 0 7px; + + @-moz-document url-prefix() { + &.contentbar-toc-wrapper { + margin-top: -4px; + } + &.contentbar-action-menu-wrapper { + margin-top: 2px; + } + } + + .contentbar-button, + .cw-ribbon-button { + background-color: transparent; + background-position: center; + background-repeat: no-repeat; + background-size: 24px; + border: none; + cursor: pointer; + display: inline-block; + height: 24px; + width: 24px; + + &.contentbar-button-menu, + &.cw-ribbon-button-menu { + @include background-icon(table-of-contents, clickable, 24); + } + + &.contentbar-button-zoom::before { + left: -5px; + position: relative; + top: -2px; + } + + @-moz-document url-prefix() { + &.contentbar-button-zoom::before { + top: -3px; + } + } + } +} diff --git a/resources/assets/stylesheets/scss/courseware/layouts/tabs.scss b/resources/assets/stylesheets/scss/courseware/layouts/tabs.scss index cf28364..023b31d 100644 --- a/resources/assets/stylesheets/scss/courseware/layouts/tabs.scss +++ b/resources/assets/stylesheets/scss/courseware/layouts/tabs.scss @@ -24,7 +24,7 @@ &::after { display: block; margin-top: 4px; - margin-bottom: -5px; + margin-bottom: -12px; margin-left: -14px; width: calc(100% + 28px); content: ''; diff --git a/resources/assets/stylesheets/scss/responsive.scss b/resources/assets/stylesheets/scss/responsive.scss index c2ca5c3..0a84d3e 100644 --- a/resources/assets/stylesheets/scss/responsive.scss +++ b/resources/assets/stylesheets/scss/responsive.scss @@ -329,7 +329,7 @@ $sidebarOut: -330px; &.responsive-show { animation: slide-in var(--transition-duration) forwards; position: sticky; - top: 100px; + top: 110px; visibility: visible; } @@ -383,10 +383,6 @@ $sidebarOut: -330px; } #responsive-contentbar { - justify-content: stretch; - margin-bottom: 15px; - padding-bottom: 0.5em; - .contentbar-nav, .cw-ribbon-nav { .contentbar-button { @@ -407,71 +403,20 @@ $sidebarOut: -330px; } } - } - - .contentbar-wrapper-left { - flex: 1; - max-width: calc(100% - 70px); - min-width: 0; - width:100%; - - & > .contentbar-icon { - margin-right: 15px; - } - - .contentbar-breadcrumb { - font-size: $font-size-large; - - > img { - margin-left: 15px; - width: 24px; - } - - > span { - display: inline; - flex-shrink: 10000; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - } - } - - > .contentbar-wrapper-right { - flex: 0; - left: 5px; - position: relative; - - .contentbar-button, - nav { - position: relative; + img { + vertical-align: middle; } } - &.cw-ribbon { - .cw-ribbon-tools { - max-width: calc(100% - 2px); - top: 0; - margin-right: 0; - } + .cw-ribbon-tools { + right: 16px; } - &.cw-ribbon-sticky { - position: unset; - width: calc(100vw - 30px); - } } #toc { - max-width: 100vw; - position: absolute; - right: -8px; - top: -21px; - } - - #toc_header { - height: 47px; + margin-right: -2px; + margin-top: -7px; } #main-footer { @@ -509,7 +454,30 @@ $sidebarOut: -330px; #responsive-contentbar { &.cw-ribbon-sticky { position: unset; - width: calc(100vw - 30px); + width: unset; + } + + .cw-ribbon-breadcrumb { + max-width: 100%; + min-width: unset; + } + + .cw-ribbon-info-text, + .contentbar-icon { + display: none; + } + + &:not(.cw-ribbon) { + .contentbar-wrapper-left { + flex: 1; + max-width: 100%; + text-overflow: ellipsis; + } + } + + .cw-ribbon-tools { + max-width: 100vw; + right: 0; } } @@ -521,10 +489,9 @@ $sidebarOut: -330px; height: calc(100% - 100px); overflow-y: auto; position: fixed; - top: 75px; transform: translateX($sidebarOut); -webkit-transform: translateX($sidebarOut); - top: 80px; + top: 110px; z-index: 100; &.responsive-show { @@ -536,6 +503,12 @@ $sidebarOut: -330px; } } + #toc { + margin-right: -16px; + max-width: 100vw; + width: 100vw; + } + #system-notifications { top: 0; position: fixed; @@ -619,13 +592,13 @@ $sidebarOut: -330px; .contentbar-nav, .cw-ribbon-nav { - margin-left: -8px; + margin-left: -6px; } } #content-wrapper { flex: 1; - margin-top: 75px; + margin-top: 55px; min-height: calc(100vh - 150px); } } @@ -686,11 +659,8 @@ $sidebarOut: -330px; } #toc { - position: absolute; - right: -29px; - top: -25px; + margin-right: 12px; } - } html:not(.responsive-display):not(.fullscreen-mode) { diff --git a/resources/assets/stylesheets/scss/table_of_contents.scss b/resources/assets/stylesheets/scss/table_of_contents.scss index b361a90..e323c79 100644 --- a/resources/assets/stylesheets/scss/table_of_contents.scss +++ b/resources/assets/stylesheets/scss/table_of_contents.scss @@ -15,45 +15,57 @@ ul.numberedchapters { display: none; } -#cb-toc:checked + .check-box + #cb-toc-close + article.toc_overview, button#toc-button:hover article.toc_overview { - visibility: visible; - width: 540px; - overflow: hidden; -} - -#cb-toc-close:checked article.toc_overview { - visibility: hidden; - width: 0; -} - -.toc_overview { - visibility: hidden; - width: 0%; +#toc { + margin: 11px; + text-align: left; z-index: 100; position: absolute; - right: -22px; - top: -25px; + right: -10px; + top: -11px; background-color: var(--white); border: 1px solid var(--content-color-40); margin-bottom: 10px; box-shadow: 2px 2px var(--dark-gray-color-30); - + width: min(100%, 540px); > section { max-width: 100%; overflow-y: scroll; height: 580px; margin-top: 7px; + padding: 5px 15px; } -} -#toc { - margin: 10px; - text-align: left; + .toc-hide-button { + position: absolute; + border: none; + height: 36px; + width: 24px; + min-width: 24px; + margin-right: 1em; + padding: 0 4px; + right: 0; + top: 12px; + cursor: pointer; + @include background-icon(decline, clickable, 24); + background-repeat: no-repeat; + background-size: 24px; + background-position: center right; + background-color: var(--white); + } + + &.cw-ribbon-slide-enter-active, + &.cw-ribbon-slide-leave-active { + transition: transform var(--transition-duration-superslow); + } + &.cw-ribbon-slide-enter, + &.cw-ribbon-slide-leave-to { + transform: translateX(calc(100% + 30px)); + } } #toc_header { - height: 58px; + height: 57px; overflow: hidden; background-color: var(--white); color: var(--black); @@ -73,14 +85,10 @@ ul.numberedchapters { #toc_h1 { color: var(--black); font-weight: 500; - margin-left: 10px; + margin-left: 25px; margin-bottom: unset; } -.toc_transform { - transition: all var(--transition-duration) ease; -} - #main_content { opacity: 1; @@ -121,6 +129,13 @@ section > .toc { img, svg { vertical-align: bottom; } + + a, + span { + img { + margin-right: 10px; + } + } } li#chap1 { @@ -196,10 +211,6 @@ section > .toc { width:375px; } - #toc { - max-width: 94%; - } - ul.breadcrumb { list-style: none; font-size: 18px; diff --git a/resources/studip.d.ts b/resources/studip.d.ts index 5ec8fef..ec5c065 100644 --- a/resources/studip.d.ts +++ b/resources/studip.d.ts @@ -22,3 +22,24 @@ export interface InstalledLanguage { picture: string; selected: boolean; } + +enum iconTypes { + 'accept', + 'attention', + 'clickable', + 'inactive', + 'info', + 'info_alt', + 'navigation', + 'new', + 'sort', + 'status-green', + 'status-red', + 'status-yellow' +} + +export interface Icon { + role: iconTypes; + shape: string; + attributes: Record; +} diff --git a/resources/vue/components/ContentBar.vue b/resources/vue/components/ContentBar.vue new file mode 100644 index 0000000..539c582 --- /dev/null +++ b/resources/vue/components/ContentBar.vue @@ -0,0 +1,163 @@ + + + diff --git a/resources/vue/components/ContentBarBreadcrumbs.vue b/resources/vue/components/ContentBarBreadcrumbs.vue new file mode 100644 index 0000000..35606b4 --- /dev/null +++ b/resources/vue/components/ContentBarBreadcrumbs.vue @@ -0,0 +1,78 @@ + + + diff --git a/resources/vue/components/ContentBarTableOfContents.vue b/resources/vue/components/ContentBarTableOfContents.vue new file mode 100644 index 0000000..525c4d6 --- /dev/null +++ b/resources/vue/components/ContentBarTableOfContents.vue @@ -0,0 +1,63 @@ + + + diff --git a/resources/vue/components/ContentBarTocItemList.vue b/resources/vue/components/ContentBarTocItemList.vue new file mode 100644 index 0000000..15101c4 --- /dev/null +++ b/resources/vue/components/ContentBarTocItemList.vue @@ -0,0 +1,33 @@ + + + diff --git a/resources/vue/components/WikiEditor.vue b/resources/vue/components/WikiEditor.vue index 069b2ff..3b15bb0 100644 --- a/resources/vue/components/WikiEditor.vue +++ b/resources/vue/components/WikiEditor.vue @@ -1,5 +1,14 @@ diff --git a/resources/vue/components/courseware/structural-element/CoursewareRibbonToolbar.vue b/resources/vue/components/courseware/structural-element/CoursewareRibbonToolbar.vue index 2a92579..fe99808 100644 --- a/resources/vue/components/courseware/structural-element/CoursewareRibbonToolbar.vue +++ b/resources/vue/components/courseware/structural-element/CoursewareRibbonToolbar.vue @@ -2,7 +2,7 @@
@@ -49,6 +49,7 @@ import CoursewareToolsContents from './CoursewareToolsContents.vue'; import CoursewareToolsUnits from './CoursewareToolsUnits.vue'; import { FocusTrap } from 'focus-trap-vue'; import { mapActions, mapGetters } from 'vuex'; +import { store } from "../../../../assets/javascripts/chunks/vue"; export default { name: 'courseware-ribbon-toolbar', @@ -60,16 +61,6 @@ export default { FocusTrap, }, props: { - toolsActive: Boolean, - canEdit: Boolean, - disableSettings: { - type: Boolean, - default: false, - }, - disableAdder: { - type: Boolean, - default: false, - }, stickyRibbon: { type: Boolean, default: false, @@ -84,9 +75,11 @@ export default { }; }, computed: { + consumeMode() { + return store.state.studip.consumeMode; + }, ...mapGetters({ userIsTeacher: 'userIsTeacher', - consumeMode: 'consumeMode', containerAdder: 'containerAdder', adderStorage: 'blockAdder', viewMode: 'viewMode', @@ -108,28 +101,25 @@ export default { coursewareContainerAdder: 'coursewareContainerAdder', }), scrollToCurrent() { - setTimeout(() => { - let contents = this.$refs.contents.$el; - let current = contents.querySelector('.cw-tree-item-link-current'); - if (current) { - contents.scroll({ top: current.offsetTop - 4, behavior: 'smooth' }); - } - }, 360); + let contents = this.$refs.contents.$el; + let current = contents.querySelector('.cw-tree-item-link-current'); + if (current) { + contents.scroll({ top: current.offsetTop - 4, behavior: 'smooth' }); + } }, - }, - mounted () { - this.scrollToCurrent(); - }, - watch: { - toolsActive(newValue) { + activate() { const focusElement = this.$refs.tabs.getTabButtonByAlias(this.selectedToolbarItem); - if (newValue && focusElement) { - setTimeout(() => { - this.initialFocusElement = focusElement; - this.trap = true; - }, 300); + if (focusElement) { + this.initialFocusElement = focusElement; + this.trap = true; } }, }, + mounted() { + this.$nextTick(() => { + this.activate(); + this.$nextTick(() => this.scrollToCurrent()); + }); + }, }; diff --git a/resources/vue/components/courseware/structural-element/CoursewareSearchResults.vue b/resources/vue/components/courseware/structural-element/CoursewareSearchResults.vue index 6efbca2..5d7547d 100644 --- a/resources/vue/components/courseware/structural-element/CoursewareSearchResults.vue +++ b/resources/vue/components/courseware/structural-element/CoursewareSearchResults.vue @@ -1,14 +1,10 @@ diff --git a/resources/vue/components/tree/StudipTreeList.vue b/resources/vue/components/tree/StudipTreeList.vue index 155503b..1524d6f 100644 --- a/resources/vue/components/tree/StudipTreeList.vue +++ b/resources/vue/components/tree/StudipTreeList.vue @@ -15,10 +15,10 @@ - + :title="$gettextInterpolate($gettext('%{name} bearbeiten'), {name: currentNode.attributes.name}, true)" + > + -

diff --git a/resources/vue/components/tree/StudipTreeNode.vue b/resources/vue/components/tree/StudipTreeNode.vue index dbe3eff..8eb13fa 100644 --- a/resources/vue/components/tree/StudipTreeNode.vue +++ b/resources/vue/components/tree/StudipTreeNode.vue @@ -3,7 +3,7 @@

diff --git a/resources/vue/components/tree/TreeExportWidget.vue b/resources/vue/components/tree/TreeExportWidget.vue index 62b73b0..61500f5 100644 --- a/resources/vue/components/tree/TreeExportWidget.vue +++ b/resources/vue/components/tree/TreeExportWidget.vue @@ -2,7 +2,7 @@ diff --git a/resources/vue/components/tree/TreeSearchResult.vue b/resources/vue/components/tree/TreeSearchResult.vue index 9799dae..20a73ca 100644 --- a/resources/vue/components/tree/TreeSearchResult.vue +++ b/resources/vue/components/tree/TreeSearchResult.vue @@ -7,7 +7,7 @@
- \ No newline at end of file + diff --git a/templates/online/user.php b/templates/online/user.php index 931aaab..9517447 100644 --- a/templates/online/user.php +++ b/templates/online/user.php @@ -7,7 +7,11 @@ - _("zu den Buddies hinzufügen")])->asImg(16, ["style" => 'padding-right: 0.33em;', "class" => 'middle']) ?> + asImg([ + 'title' => _('zu den Buddies hinzufügen'), + 'style' => 'padding-right: 0.33em;', + 'class' => 'middle', + ]) ?>   @@ -25,7 +29,7 @@ $text) : ?> - $text, 'title' => $text, 'class' => 'text-bottom'])->asImg() ?> + asImg(['title' => $text, 'class' => 'text-bottom']) ?> @@ -36,14 +40,14 @@ @@ -52,7 +56,7 @@ "delete_user", "delete_uname" => $tmp_online_uname]) ?>"> - _("aus der Buddy-Liste entfernen"), 'class' => 'text-bottom'])->asImg() ?> + asImg(['title' => _('aus der Buddy-Liste entfernen'), 'class' => 'text-bottom']) ?>   diff --git a/templates/quicksearch/selectbox.php b/templates/quicksearch/selectbox.php index 1ca5e5b..66eeb7d 100644 --- a/templates/quicksearch/selectbox.php +++ b/templates/quicksearch/selectbox.php @@ -7,7 +7,7 @@ if ($withButton) : ?>
- asInput(["class" => 'text-bottom']) ?> + asInput(['class' => 'text-bottom']) ?> $attr_value) { @@ -27,7 +27,11 @@ if ($withButton) : ?> - _('Suche zurücksetzen')])->asInput(['name'=>$reset_button_name?:'','class'=>'text-bottom',]) ?> + asInput([ + 'name'=> $reset_button_name ?? '', + 'class'=>'text-bottom', + 'title' => _('Suche zurücksetzen') + ]) ?>
diff --git a/templates/sidebar/clipboard-widget.php b/templates/sidebar/clipboard-widget.php index ccb44b2..c1e38a2 100644 --- a/templates/sidebar/clipboard-widget.php +++ b/templates/sidebar/clipboard-widget.php @@ -35,13 +35,11 @@ - _('Hinzufügen')])->asInput([ - 'name' => 'save', - 'id' => 'add-clipboard-button', - 'class' => 'middle', - 'disabled' => 'disabled' - ]) ?> + asInput([ + 'name' => 'save', + 'id' => 'add-clipboard-button', + 'class' => 'middle', + 'disabled' => 'disabled', + 'title' => _('Hinzufügen'), + ]) ?> diff --git a/templates/sidebar/resource-tree-widget.php b/templates/sidebar/resource-tree-widget.php index 2346131..890971b 100644 --- a/templates/sidebar/resource-tree-widget.php +++ b/templates/sidebar/resource-tree-widget.php @@ -21,37 +21,30 @@ id, $resource_path)): ?> - ( - in_array($resource->id, $resource_path) || (!$resource_path && $resource->level < $max_open_depth) - ? 'rotated' - : '' - ), - 'style' => - (in_array($resource->id, $resource_path) || (!$resource_path && $resource->level < $max_open_depth) + asImg([ + 'class' => in_array($resource->id, $resource_path) || (!$resource_path && $resource->level < $max_open_depth) + ? 'rotated' + : '', + 'style' => in_array($resource->id, $resource_path) || (!$resource_path && $resource->level < $max_open_depth) ? 'transform: rotate(90deg)' - : '' - ), - 'onClick' => - (!$resource_path || in_array($resource->id, $resource_path) + : '', + 'onClick' => !$resource_path || in_array($resource->id, $resource_path) ? 'STUDIP.Resources.toggleTreeNode($(this).parent());' : '' - )]) ?> - + ]) ?> + id, $resource_path)): ?> - + > - getIcon($selected ? Icon::ROLE_INFO_ALT : Icon::ROLE_CLICKABLE)->asImg( - [ - 'class' => 'text-bottom' - ] - ) ?> + getIcon($selected ? Icon::ROLE_INFO_ALT : Icon::ROLE_CLICKABLE)->asImg([ + 'class' => 'text-bottom' + ]) ?> name) ?> children): ?> diff --git a/templates/sidebar/resources_individual_booking_plan_sidebar.php b/templates/sidebar/resources_individual_booking_plan_sidebar.php index 77068fa..8ba630d 100644 --- a/templates/sidebar/resources_individual_booking_plan_sidebar.php +++ b/templates/sidebar/resources_individual_booking_plan_sidebar.php @@ -11,12 +11,9 @@ - asImg( - '32px', - [ - 'class' => 'text-bottom print-action', - 'title' => _('Individuelle Druckansicht drucken'), - 'onclick' => 'javascript:window.print();' - ] - ) ?> + asImg(Icon::SIZE_LARGE, [ + 'class' => 'text-bottom print-action', + 'title' => _('Individuelle Druckansicht drucken'), + 'onclick' => 'javascript:window.print();' + ]) ?> diff --git a/templates/sidebar/room-clipboard-item.php b/templates/sidebar/room-clipboard-item.php index 86c2950..45633a0 100644 --- a/templates/sidebar/room-clipboard-item.php +++ b/templates/sidebar/room-clipboard-item.php @@ -33,29 +33,20 @@ if (!$item) { diff --git a/templates/sidebar/room-clipboard-widget.php b/templates/sidebar/room-clipboard-widget.php index 76678df..f55f73d 100644 --- a/templates/sidebar/room-clipboard-widget.php +++ b/templates/sidebar/room-clipboard-widget.php @@ -35,13 +35,13 @@ - _('Hinzufügen')])->asInput([ - 'name' => 'save', - 'id' => 'add-clipboard-button', - 'class' => 'middle', - 'disabled' => 'disabled' - ]) ?> + asInput([ + 'name' => 'save', + 'id' => 'add-clipboard-button', + 'class' => 'middle', + 'disabled' => 'disabled', + 'title' => _('Hinzufügen'), + ]) ?> diff --git a/templates/sidebar/search-widget.php b/templates/sidebar/search-widget.php index e475f00..332579c 100644 --- a/templates/sidebar/search-widget.php +++ b/templates/sidebar/search-widget.php @@ -24,9 +24,24 @@ > + + + asInput([ + 'title' => _('Suche zurücksetzen'), + 'class' => 'reset-search', + 'onclick' => "window.document.getElementById('needle-".$hash."').value = '';" + ]) ?> + + + title=""> + + + + @@ -38,7 +53,7 @@ - asImg(20) ?> + diff --git a/templates/start/_jstemplates.php b/templates/start/_jstemplates.php index 740b05a..16c26fc 100644 --- a/templates/start/_jstemplates.php +++ b/templates/start/_jstemplates.php @@ -35,23 +35,23 @@ <% var treffer = activity.provider.match(/.*\\(.*)Provider/) %> <% var provider = treffer[1].toLowerCase(); %> <% if (provider === 'blubber') { %> - asImg(32) ?> + <% } else if(provider === 'documents') { %> - asImg(32) ?> + <% } else if(provider === 'forum') { %> - asImg(32) ?> + <% } else if(provider === 'message') { %> - asImg(32) ?> + <% } else if(provider === 'news') { %> - asImg(32) ?> + <% } else if(provider === 'participants') { %> - asImg(32) ?> + <% } else if(provider === 'schedule') { %> - asImg(32) ?> + <% } else if(provider === 'wiki') { %> - asImg(32) ?> + <% } else { %> - asImg(32) ?> + <% } %>
diff --git a/tests/unit/lib/classes/IconClassTest.php b/tests/unit/lib/classes/IconClassTest.php index 0e79df9..01a3a1b 100644 --- a/tests/unit/lib/classes/IconClassTest.php +++ b/tests/unit/lib/classes/IconClassTest.php @@ -12,93 +12,93 @@ class IconClassTest extends \Codeception\Test\Unit { private $memo_assets_url; - function setUp(): void + public function setUp(): void { $this->memo_assets_url = Assets::url(); Assets::set_assets_url(''); } - function tearDown(): void + public function tearDown(): void { Assets::set_assets_url($this->memo_assets_url); } - function testIconCreateAsImg() + public function testIconCreateAsImg() { $this->assertEquals( - '', + '', Icon::create('vote')->asImg() ); } - function testIconCreateAsImgWithAddition() + public function testIconCreateAsImgWithAddition() { $this->assertEquals( - '', + '', Icon::create('vote')->asImg() ); } - function testIconCreateAsImgWithSize() + public function testIconCreateAsImgWithSize() { $this->assertEquals( - '', - Icon::create('vote')->asImg(20) + '', + Icon::create('vote')->asImg(100) ); } - function testIconCreateAsImgWithTitle() + public function testIconCreateAsImgWithTitle() { $this->assertEquals( - '', - Icon::create('vote')->asImg(20, ['title' => _('Mit Anhang')]) + '', + Icon::create('vote')->asImg(24, ['title' => 'Mit Anhang']) ); } - function testIconCreateAsImgWithHspace() + public function testIconCreateAsImgWithHspace() { $this->assertEquals( - '', + '', Icon::create('arr_2left')->asImg(['hspace' => 3]) ); } - function testIconCreateAsImgWithClass() + public function testIconCreateAsImgWithClass() { $this->assertEquals( - '', - Icon::create('staple', Icon::ROLE_INFO)->asImg(20, ['class' => 'text-bottom']) + '', + Icon::create('staple', Icon::ROLE_INFO)->asImg(24, ['class' => 'text-bottom']) ); } - function testIconCreateAsImgWithClassAndTitle() + public function testIconCreateAsImgWithClassAndTitle() { $this->assertEquals( - '', - Icon::create('upload', Icon::ROLE_NEW, ['title' => _("Datei hochladen")]) - ->asImg(20, ['class' => 'text-bottom']) + '', + Icon::create('upload', Icon::ROLE_NEW, ['title' => 'Datei hochladen']) + ->asImg(24, ['class' => 'text-bottom']) ); } - function testIconCreateAsInput() + public function testIconCreateAsInput() { $this->assertEquals( - '', - Icon::create('upload')->asInput(20, ['class' => 'text-bottom']) + '', + Icon::create('upload')->asInput(24, ['class' => 'text-bottom']) ); } - function testIconIsImmutable() + public function testIconIsImmutable() { - $icon = Icon::create('upload', Icon::ROLE_CLICKABLE, ['title' => _('a title')]); + $icon = Icon::create('upload', attributes: ['title' => 'a title']); $copy = $icon->copyWithRole(Icon::ROLE_CLICKABLE); $this->assertNotSame($icon, $copy); } - function testIconCopyWithRole() + public function testIconCopyWithRole() { - $icon = Icon::create('upload', Icon::ROLE_CLICKABLE, ['title' => _('a title')]); + $icon = Icon::create('upload', attributes: ['title' => 'a title']); $copy = $icon->copyWithRole(Icon::ROLE_INFO); $this->assertEquals($icon->getShape(), $copy->getShape()); @@ -106,9 +106,9 @@ class IconClassTest extends \Codeception\Test\Unit $this->assertEquals($icon->getAttributes(), $copy->getAttributes()); } - function testIconCopyWithShape() + public function testIconCopyWithShape() { - $icon = Icon::create('upload', Icon::ROLE_CLICKABLE, ['title' => _('a title')]); + $icon = Icon::create('upload', attributes: ['title' => 'a title']); $copy = $icon->copyWithShape('staple'); $this->assertNotEquals($icon->getShape(), $copy->getShape()); @@ -116,23 +116,23 @@ class IconClassTest extends \Codeception\Test\Unit $this->assertEquals($icon->getAttributes(), $copy->getAttributes()); } - function testIconCopyWithAttributes() + public function testIconCopyWithAttributes() { - $icon = Icon::create('upload', Icon::ROLE_CLICKABLE, ['title' => _('a title')]); - $copy = $icon->copyWithAttributes(['title' => _('another title')]); + $icon = Icon::create('upload', Icon::ROLE_CLICKABLE, ['title' => 'a title']); + $copy = $icon->copyWithAttributes(['title' => 'another title']); $this->assertEquals($icon->getShape(), $copy->getShape()); $this->assertEquals($icon->getRole(), $copy->getRole()); $this->assertNotEquals($icon->getAttributes(), $copy->getAttributes()); } - function testStaticIcon() + public function testStaticIcon() { $icon = Icon::create('https://i.imgur.com/kpTtTh.gif'); $this->assertEquals($icon->asImagePath(), 'https://i.imgur.com/kpTtTh.gif'); } - function testIconCreateAsCSSWithSize() + public function testIconCreateAsCSSWithSize() { $this->assertEquals( 'background-image:url(images/icons/blue/vote.svg);background-size:17px 17px;', @@ -140,7 +140,7 @@ class IconClassTest extends \Codeception\Test\Unit ); } - function testIconCreateAsImagePath() + public function testIconCreateAsImagePath() { $this->assertEquals( 'images/icons/blue/vote.svg', @@ -148,30 +148,30 @@ class IconClassTest extends \Codeception\Test\Unit ); } - function testIconCreateAsImgWithoutSize() + public function testIconCreateAsImgWithoutSize() { $this->assertEquals( - '', + '', Icon::create('vote')->asImg(false) ); } - function testIconCreateAsInputWithoutSize() + public function testIconCreateAsInputWithoutSize() { $this->assertEquals( - '', + '', Icon::create('upload')->asInput(false) ); } - function testIconCreateRemovedExtras() + public function testIconCreateRemovedExtras() { $this->assertEquals( - '', + '', Icon::create('add/vote')->asImg(false) ); $this->assertEquals( - '', + '', Icon::create('vote+add')->asImg(false) ); } -- cgit v1.0 From b710bd4dde510707545c5cf41d8658eb5cca7783 Mon Sep 17 00:00:00 2001 From: Jan-Hendrik Willms Date: Mon, 11 Nov 2024 15:20:38 +0100 Subject: fix typo, re #2801 --- app/views/questionnaire/widget.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/questionnaire/widget.php b/app/views/questionnaire/widget.php index 1ea5d85..a7bb562 100644 --- a/app/views/questionnaire/widget.php +++ b/app/views/questionnaire/widget.php @@ -2,7 +2,7 @@

- asimg(['class' => 'text-bottom']) ?> + asimg(['class' => 'text-bottom']) ?>

- + -
diff --git a/lib/classes/calendar/EventData.php b/lib/classes/calendar/EventData.php index 95e89b0..db1e472 100644 --- a/lib/classes/calendar/EventData.php +++ b/lib/classes/calendar/EventData.php @@ -25,6 +25,12 @@ class EventData public $border_colour; public $all_day; + /** + * @var string The ID in this field is used to group events when displayed + * in Fullcalendar so that they can be moved together. + */ + public string $group_id; + public function __construct( \DateTime $begin, \DateTime $end, @@ -43,7 +49,8 @@ class EventData Array $api_urls = [], string $icon = '', string $border_colour = '', - bool $all_day = false + bool $all_day = false, + string $group_id = '' ) { $this->begin = $begin; @@ -64,6 +71,7 @@ class EventData $this->icon = $icon; $this->border_colour = $border_colour ?: $background_colour; $this->all_day = $all_day; + $this->group_id = $group_id; } @@ -109,6 +117,6 @@ class EventData 'studip_view_urls' => $this->view_urls, 'studip_api_urls' => $this->api_urls, 'icon' => $this->icon - ]; + ] + ($this->group_id ? ['groupId' => $this->group_id] : []); } } diff --git a/lib/models/calendar/CalendarDateAssignment.php b/lib/models/calendar/CalendarDateAssignment.php index d61d124..43c0080 100644 --- a/lib/models/calendar/CalendarDateAssignment.php +++ b/lib/models/calendar/CalendarDateAssignment.php @@ -676,11 +676,12 @@ class CalendarDateAssignment extends SimpleORMap implements Event ], [ 'resize_dialog' => URLHelper::getURL('dispatch.php/calendar/date/move/' . $this->calendar_date_id), - 'move_dialog' => URLHelper::getURL('dispatch.php/calendar/date/move/' . $this->calendar_date_id) + 'move_dialog' => URLHelper::getURL('dispatch.php/calendar/date/move/' . $this->calendar_date_id, ['original_date' => $begin->format('Y-m-d')]) ], $this->participation === 'DECLINED' ? 'decline-circle-full' : '', $border_colour, - $all_day + $all_day, + $this->calendar_date_id ); } -- cgit v1.0 From 1066b5800b43b8ce9a012dccad85723ae4cdfb09 Mon Sep 17 00:00:00 2001 From: Jan-Hendrik Willms Date: Wed, 13 Nov 2024 11:41:36 +0000 Subject: use correct way to load plugin, fixes #4862 Closes #4862 Merge request studip/studip!3642 --- app/controllers/course/contentmodules.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/course/contentmodules.php b/app/controllers/course/contentmodules.php index 7fbb487..1259ef2 100644 --- a/app/controllers/course/contentmodules.php +++ b/app/controllers/course/contentmodules.php @@ -113,7 +113,7 @@ class Course_ContentmodulesController extends AuthenticatedController } $moduleclass = Request::get('moduleclass'); $active = Request::bool('active', false); - $module = new $moduleclass; + $module = PluginEngine::getPlugin($moduleclass); if ($module->isActivatableForContext($context)) { PluginManager::getInstance()->setPluginActivated($module->getPluginId(), $context->getId(), $active); } @@ -177,7 +177,7 @@ class Course_ContentmodulesController extends AuthenticatedController } $moduleclass = Request::get('moduleclass'); - $module = new $moduleclass; + $module = PluginEngine::getPlugin($moduleclass); $active_tool = ToolActivation::find([$context->id, $module->getPluginId()]); $metadata = $active_tool->metadata->getArrayCopy(); -- cgit v1.0 From 9fd14abeaf7a3d2996540ffa7144b81e019624a2 Mon Sep 17 00:00:00 2001 From: Moritz Strohm Date: Wed, 13 Nov 2024 11:52:39 +0000 Subject: neither show the dialog for confidential dates of other users nor be able to drag such dates, fixes #4706 Closes #4706 Merge request studip/studip!3636 --- lib/models/calendar/CalendarDate.php | 8 ++++--- lib/models/calendar/CalendarDateAssignment.php | 29 +++++++++++++++----------- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/lib/models/calendar/CalendarDate.php b/lib/models/calendar/CalendarDate.php index 1d49ff5..ebfb20e 100644 --- a/lib/models/calendar/CalendarDate.php +++ b/lib/models/calendar/CalendarDate.php @@ -177,13 +177,15 @@ class CalendarDate extends SimpleORMap implements PrivacyObject } } elseif ($assignment->user instanceof User) { if ($assignment->user->isCalendarReadable($range_id)) { - return true; + //The date is only readable if it isn't confidential: + return $this->access !== 'CONFIDENTIAL'; } } } - //In case the date is not in a calendar of the user or a course - //where the user has access to, it is only visible when it is public. + //In case the date is not in a calendar of a user or a course + //where the user has read access to, the date is only visible + //when it is public. return $this->access === 'PUBLIC'; } diff --git a/lib/models/calendar/CalendarDateAssignment.php b/lib/models/calendar/CalendarDateAssignment.php index 43c0080..05fafec 100644 --- a/lib/models/calendar/CalendarDateAssignment.php +++ b/lib/models/calendar/CalendarDateAssignment.php @@ -652,32 +652,37 @@ class CalendarDateAssignment extends SimpleORMap implements Event } } - $show_url_params = []; - if ($this->calendar_date->repetition_type) { - $show_url_params['selected_date'] = $begin->format('Y-m-d'); + $studip_urls = []; + $action_urls = []; + if (!$hide_confidential_data) { + $show_url_params = []; + if ($this->calendar_date->repetition_type !== CalendarDate::REPETITION_SINGLE) { + $show_url_params['selected_date'] = $begin->format('Y-m-d'); + } + $studip_urls['show'] = URLHelper::getURL('dispatch.php/calendar/date/index/' . $this->calendar_date_id, $show_url_params); + + if ($this->isWritable($user_id)) { + $action_urls['resize_dialog'] = URLHelper::getURL('dispatch.php/calendar/date/move/' . $this->calendar_date_id); + $action_urls['move_dialog'] = URLHelper::getURL('dispatch.php/calendar/date/move/' . $this->calendar_date_id, ['original_date' => $begin->format('Y-m-d')]); + } } return new \Studip\Calendar\EventData( $begin, $end, - !$hide_confidential_data ? $this->getTitle() : '', + !$hide_confidential_data ? $this->getTitle() : _('Vertraulich'), $event_classes, $text_colour, $background_colour, - $this->isWritable($user_id), + $this->isWritable($user_id) && $this->calendar_date->isVisible($user_id), CalendarDateAssignment::class, $this->id, CalendarDate::class, $this->calendar_date_id, 'user', $this->range_id ?? '', - [ - 'show' => URLHelper::getURL('dispatch.php/calendar/date/index/' . $this->calendar_date_id, $show_url_params) - ], - [ - 'resize_dialog' => URLHelper::getURL('dispatch.php/calendar/date/move/' . $this->calendar_date_id), - 'move_dialog' => URLHelper::getURL('dispatch.php/calendar/date/move/' . $this->calendar_date_id, ['original_date' => $begin->format('Y-m-d')]) - ], + $studip_urls, + $action_urls, $this->participation === 'DECLINED' ? 'decline-circle-full' : '', $border_colour, $all_day, -- cgit v1.0 From 59d2e4f33d291672093f49b78f3b155d49321c0d Mon Sep 17 00:00:00 2001 From: Moritz Strohm Date: Wed, 13 Nov 2024 13:17:23 +0000 Subject: fixed functionality issues in TIC 4387, re #4387 Merge request studip/studip!3629 --- app/controllers/course/timesrooms.php | 39 ++++++++++++++++++---- lib/exceptions/Exception.php | 8 +++++ .../resources/ResourceBookingException.php | 2 +- .../resources/ResourceBookingOverlapException.php | 2 +- lib/models/resources/Resource.php | 8 +++-- lib/models/resources/ResourceBooking.php | 8 +++-- 6 files changed, 55 insertions(+), 12 deletions(-) diff --git a/app/controllers/course/timesrooms.php b/app/controllers/course/timesrooms.php index 6249d47..9e189ca 100644 --- a/app/controllers/course/timesrooms.php +++ b/app/controllers/course/timesrooms.php @@ -584,7 +584,7 @@ class Course_TimesroomsController extends AuthenticatedController } else { PageLayout::postError( studip_interpolate( - _('Der Raum %{room_name} wird an dem Termin %{date} bereits durch eine andere Veranstaltung belegt.'), + _('Der Raum %{room_name} wird an dem Termin %{date} bereits anderweitig belegt.'), [ 'room_name' => $room->name, 'date' => $termin->getFullName() @@ -1123,6 +1123,7 @@ class Course_TimesroomsController extends AuthenticatedController } if (in_array(Request::get('action'), ['room', 'freetext', 'noroom']) || Request::get('course_type')) { + $success_cases = 0; $errors = []; foreach ($singledates as $singledate) { if ($singledate instanceof CourseExDate) { @@ -1147,12 +1148,32 @@ class Course_TimesroomsController extends AuthenticatedController $failure = false; try { $failure = !$singledate->bookRoom($room, intval($preparation_time)); - } catch (ResourceBookingException|ResourceBookingOverlapException $e) { + } catch (ResourceBookingException $e) { $errors[] = sprintf( _('Der angegebene Raum konnte für den Termin %1$s nicht gebucht werden: %2$s'), '' . htmlReady($singledate->getFullName()) . '', $e->getMessage() ); + } catch (ResourceBookingOverlapException $e) { + $course = $e->getRange(); + if ($course instanceof Course) { + $errors[] = studip_interpolate( + _('Der Raum %{room_name} wird an dem Termin %{date} bereits durch die Veranstaltung %{course_name} belegt.'), + [ + 'room_name' => $room->name, + 'date' => $singledate->getFullName(), + 'course_name' => $course->name + ] + ); + } else { + $errors[] = studip_interpolate( + _('Der Raum %{room_name} wird an dem Termin %{date} bereits anderweitig belegt.'), + [ + 'room_name' => $room->name, + 'date' => $singledate->getFullName() + ] + ); + } } if ($failure) { $errors[] = sprintf( @@ -1160,10 +1181,7 @@ class Course_TimesroomsController extends AuthenticatedController '' . htmlReady($singledate->getFullName()) . '' ); } else { - PageLayout::postSuccess(sprintf( - _('Die Änderungen am Termin %s wurden gespeichert.'), - $singledate->getFullName() - )); + $success_cases++; } } } else if (Request::get('room_id_parameter')) { @@ -1202,6 +1220,15 @@ class Course_TimesroomsController extends AuthenticatedController )); } } + if ($success_cases > 0) { + if (!$errors) { + //Everything went well. + PageLayout::postSuccess(_('Die Änderungen wurden gespeichert.')); + } else { + //Not everything went well. + PageLayout::postWarning(_('Es konnten nicht alle Termine geändert werden.')); + } + } if ($errors) { PageLayout::postError( _('Die folgenden Fehler traten auf:'), diff --git a/lib/exceptions/Exception.php b/lib/exceptions/Exception.php index 606c03c..714998e 100644 --- a/lib/exceptions/Exception.php +++ b/lib/exceptions/Exception.php @@ -55,4 +55,12 @@ class Exception extends \Exception $this->range ); } + + /** + * @return \Range|null The range of the exception. + */ + public function getRange() : ?\Range + { + return $this->range; + } } diff --git a/lib/exceptions/resources/ResourceBookingException.php b/lib/exceptions/resources/ResourceBookingException.php index 3dcf483..a39da50 100644 --- a/lib/exceptions/resources/ResourceBookingException.php +++ b/lib/exceptions/resources/ResourceBookingException.php @@ -18,7 +18,7 @@ * This exception is thrown when a general error occurs when dealing with * ResourceBooking objects. */ -class ResourceBookingException extends InvalidArgumentException +class ResourceBookingException extends \Studip\Exception { } diff --git a/lib/exceptions/resources/ResourceBookingOverlapException.php b/lib/exceptions/resources/ResourceBookingOverlapException.php index 697cfc5..39497e7 100644 --- a/lib/exceptions/resources/ResourceBookingOverlapException.php +++ b/lib/exceptions/resources/ResourceBookingOverlapException.php @@ -18,7 +18,7 @@ * This exception is thrown when a resource booking overlaps with * other resource bookings or with a resource lock. */ -class ResourceBookingOverlapException extends InvalidArgumentException +class ResourceBookingOverlapException extends \Studip\Exception { } diff --git a/lib/models/resources/Resource.php b/lib/models/resources/Resource.php index 4dae189..661e8f4 100644 --- a/lib/models/resources/Resource.php +++ b/lib/models/resources/Resource.php @@ -872,7 +872,9 @@ class Resource extends SimpleORMap implements StudipItem $begin->format('d.m.Y H:i'), $end->format('H:i'), $e->getMessage() - ) + ), + $e->getCode(), + $e->getRange() ); } else { throw new ResourceBookingOverlapException( @@ -882,7 +884,9 @@ class Resource extends SimpleORMap implements StudipItem $begin->format('d.m.Y H:i'), $end->format('d.m.Y H:i'), $e->getMessage() - ) + ), + $e->getCode(), + $e->getRange() ); } } catch (Exception $e) { diff --git a/lib/models/resources/ResourceBooking.php b/lib/models/resources/ResourceBooking.php index 3c84cb8..49de381 100644 --- a/lib/models/resources/ResourceBooking.php +++ b/lib/models/resources/ResourceBooking.php @@ -521,6 +521,9 @@ class ResourceBooking extends SimpleORMap implements PrivacyObject, Studip\Calen } ); } + + $course = null; + foreach ($time_intervals as $time_interval) { foreach ($existing_deleted_intervals as $deleted_interval) { if ( @@ -563,7 +566,6 @@ class ResourceBooking extends SimpleORMap implements PrivacyObject, Studip\Calen [self::TYPE_NORMAL, self::TYPE_LOCK], [$this->id] ); - $course = null; if ( count($other_booking) >= 1 && !empty($other_booking[0]->assigned_course_date->course) @@ -606,7 +608,9 @@ class ResourceBooking extends SimpleORMap implements PrivacyObject, Studip\Calen } if ($time_interval_overlaps) { throw new ResourceBookingOverlapException( - implode(', ', $time_interval_overlaps) + implode(', ', $time_interval_overlaps), + 0, + $course ); } -- cgit v1.0 From 60f7675f3d58fe1fe2cf79da4314a3407cea6055 Mon Sep 17 00:00:00 2001 From: Moritz Strohm Date: Wed, 13 Nov 2024 13:32:23 +0000 Subject: refactorised admission/restricted_courses/index action, fixes #4842 Closes #4842 Merge request studip/studip!3634 --- app/controllers/admission/restricted_courses.php | 180 +++++++++++++---------- app/views/admission/restricted_courses/index.php | 38 +++-- 2 files changed, 129 insertions(+), 89 deletions(-) diff --git a/app/controllers/admission/restricted_courses.php b/app/controllers/admission/restricted_courses.php index 371ea5d..1a4a191 100644 --- a/app/controllers/admission/restricted_courses.php +++ b/app/controllers/admission/restricted_courses.php @@ -56,10 +56,10 @@ class Admission_RestrictedCoursesController extends AuthenticatedController } $semester = Semester::find($this->current_semester_id); $sem_condition .= " - AND (semester_courses.semester_id IS NULL OR semester_courses.semester_id = " . DBManager::get()->quote($semester->getId()) . ") + AND (`semester_courses`.`semester_id` IS NULL OR `semester_courses`.`semester_id` = " . DBManager::get()->quote($semester->getId()) . ") "; if ($this->sem_name_prefix) { - $sem_condition .= sprintf('AND (seminare.Name LIKE %1$s OR seminare.VeranstaltungsNummer LIKE %1$s) ', DBManager::get()->quote($this->sem_name_prefix . '%')); + $sem_condition .= sprintf('AND (`seminare`.`Name` LIKE %1$s OR `seminare`.`VeranstaltungsNummer` LIKE %1$s) ', DBManager::get()->quote($this->sem_name_prefix . '%')); } if ($GLOBALS['perm']->have_perm('dozent')) { $this->my_inst = $this->get_institutes($sem_condition); @@ -72,6 +72,7 @@ class Admission_RestrictedCoursesController extends AuthenticatedController foreach (words('current_institut_id sem_name_prefix') as $param) { $_SESSION[get_class($this)][$param] = $this->$param; } + $this->additional_data = []; if (Request::get('csv')) { $captions = [_("Anmeldeset"), _("Nummer"), @@ -86,19 +87,34 @@ class Admission_RestrictedCoursesController extends AuthenticatedController _("Endzeitpunkt")]; $data = []; foreach ($this->courses as $course) { - $sorm_course = Course::find($course['seminare.seminar_id']); + $additional_data = $this->getAdditionalCourseData($course); + + $start_time = ''; + + $start_semester = $course->getStartSemester(); + if ($start_semester) { + $start_time = date('d.m.Y H:i', $start_semester->beginn); + } + + $end_time = ''; + + $end_semester = $course->getEndSemester(); + if ($end_semester) { + $end_time = date('d.m.Y H:i', $end_semester->ende); + } + $row = []; - $row[] = $course['cs_name']; - $row[] = $course['course_number']; - $row[] = $course['course_name']; - $row[] = (int)$course['admission_turnout']; - $row[] = $course['count_teilnehmer'] + $course['count_prelim']; - $row[] = (int)$course['count_claiming']; - $row[] = (int)$course['count_prelim']; - $row[] = (int)$course['count_waiting']; - $row[] = $course['distribution_time'] ? strftime('%x %R', $course['distribution_time']) : ''; - $row[] = $sorm_course?->getStartSemester()?->beginn ?? ''; - $row[] = $sorm_course?->getEndSemester()?->ende ?? ''; + $row[] = $additional_data['courseset_name']; + $row[] = $course->veranstaltungsnummer; + $row[] = $course->name; + $row[] = (int)$course->admission_turnout ?: ''; + $row[] = $additional_data['participant_count'] + $additional_data['accepted_count']; + $row[] = (int)$additional_data['claiming_count']; + $row[] = (int)$additional_data['accepted_count']; + $row[] = (int)$additional_data['awaiting_count']; + $row[] = $additional_data['distribution_time'] ? date('d.m.Y H:i', $additional_data['distribution_time']) : ''; + $row[] = $start_time; + $row[] = $end_time; $data[] = $row; } @@ -112,7 +128,14 @@ class Admission_RestrictedCoursesController extends AuthenticatedController ); return; } + } else { + //We need to loop over each course and fetch additional data to fill the + //not_distributed_coursesets attribute before showing the view. + foreach ($this->courses as $course) { + $this->additional_data[$course->id] = $this->getAdditionalCourseData($course); + } } + if (is_array($this->not_distributed_coursesets)) { PageLayout::postInfo( _("Es existieren Anmeldesets, die zum Zeitpunkt der Platzverteilung nicht gelost wurden. Stellen Sie sicher, dass der Cronjob \"Losverfahren überprüfen\" ausgeführt wird."), @@ -120,83 +143,92 @@ class Admission_RestrictedCoursesController extends AuthenticatedController } } - function get_courses($seminare_condition) + /** + * Fetches additional data for a course and sets the not_distributed_coursesets + * attribute in some cases. + * + * @param Course $course The course to fetch data for. + * + * @return array An associative array with additional data. + */ + protected function getAdditionalCourseData(Course $course) : array + { + $data = []; + + $courseset = $course->getCourseSet(); + if ($courseset) { + $data['courseset_id'] = $courseset->getId(); + $data['courseset_name'] = $courseset->getName(); + if ($courseset->hasAlgorithmRun()) { + $data['claiming_count'] = 0; + } else { + $data['claiming_count'] = count(AdmissionPriority::getPrioritiesByCourse($courseset->getId(), $course->id)); + } + $data['distribution_time'] = $courseset->getSeatDistributionTime(); + if ( + $data['distribution_time'] < time() - 1000 + && !$courseset->hasAlgorithmRun() + ) { + $this->not_distributed_coursesets[] = $courseset->getName(); + } + + $timed_admission = $courseset->getAdmissionRule(TimedAdmission::class); + if ($timed_admission) { + $data['admission_start_time'] = $timed_admission->getStartTime(); + $data['admission_end_time'] = $timed_admission->getEndTime(); + } + } else { + $data['courseset_id'] = ''; + $data['courseset_name'] = ''; + $data['claiming_count'] = 0; + $data['distribution_time'] = 0; + $data['admission_start_time'] = ''; + $data['admission_end_time'] = ''; + } + $data['participant_count'] = CourseMember::countByCourseAndStatus($course->id, ['user', 'autor']); + $data['accepted_count'] = AdmissionApplication::countBySql( + "`seminar_id` = :course_id AND `status` = 'accepted'", + ['course_id' => $course->id] + ); + $data['awaiting_count'] = AdmissionApplication::countBySql( + "`seminar_id` = :course_id AND `status` = 'awaiting'", + ['course_id' => $course->id] + ); + + return $data; + } + + protected function get_courses($seminare_condition) { $chunks = explode('_', $this->current_institut_id); $institut_id = $chunks[0]; $all = $chunks[1] ?? null; - // Prepare count statements - $query = "SELECT count(*) - FROM seminar_user - WHERE seminar_id = ? AND status IN ('user', 'autor')"; - $count0_statement = DBManager::get()->prepare($query); - - $query = "SELECT SUM(status = 'accepted') AS count2, - SUM(status = 'awaiting') AS count3 - FROM admission_seminar_user - WHERE seminar_id = ? - GROUP BY seminar_id"; - $count1_statement = DBManager::get()->prepare($query); $parameters = []; - $sql = "SELECT seminare.seminar_id,seminare.Name as course_name,seminare.VeranstaltungsNummer as course_number, - admission_prelim, admission_turnout,seminar_courseset.set_id - FROM seminar_courseset - INNER JOIN courseset_rule csr ON csr.set_id=seminar_courseset.set_id AND csr.type='ParticipantRestrictedAdmission' - INNER JOIN seminare ON seminar_courseset.seminar_id=seminare.seminar_id - LEFT JOIN semester_courses ON (seminare.Seminar_id = semester_courses.course_id) + $sql = "JOIN `seminar_courseset` + USING (`seminar_id`) + JOIN `courseset_rule` csr + ON csr.`set_id` = `seminar_courseset`.`set_id` + AND csr.`type` = 'ParticipantRestrictedAdmission' + LEFT JOIN `semester_courses` + ON `seminare`.`Seminar_id` = `semester_courses`.`course_id` "; if ($institut_id === 'all' && $GLOBALS['perm']->have_perm('root')) { $sql .= "WHERE 1 {$seminare_condition} "; } elseif ($all == 'all') { $sql .= "INNER JOIN Institute USING (Institut_id) - WHERE Institute.fakultaets_id = ? {$seminare_condition} + WHERE Institute.fakultaets_id = :faculty_id {$seminare_condition} "; - $parameters[] = $institut_id; + $parameters['faculty_id'] = $institut_id; } else { - $sql .= "WHERE seminare.Institut_id = ? {$seminare_condition} + $sql .= "WHERE seminare.Institut_id = :institute_id {$seminare_condition} "; - $parameters[] = $institut_id; + $parameters['institute_id'] = $institut_id; } - $sql .= "GROUP BY seminare.Seminar_id ORDER BY seminar_courseset.set_id, seminare.Name"; - - $statement = DBManager::get()->prepare($sql); - $statement->execute($parameters); - $csets = []; - $ret = []; - while ($row = $statement->fetch(PDO::FETCH_ASSOC)) { - $seminar_id = $row['seminar_id']; - $ret[$seminar_id] = $row; + $sql .= "GROUP BY `seminare`.`Seminar_id` ORDER BY `seminar_courseset`.`set_id`, `seminare`.`Name`"; - $count0_statement->execute([$seminar_id]); - $count = $count0_statement->fetchColumn(); - - $ret[$seminar_id]['count_teilnehmer'] = $count; - - $count1_statement->execute([$seminar_id]); - $counts = $count1_statement->fetch(PDO::FETCH_ASSOC); - - $ret[$seminar_id]['count_prelim'] = (int) ($counts['count2'] ?? 0); - $ret[$seminar_id]['count_waiting'] = (int) ($counts['count3'] ?? 0); - if (!isset($csets[$row['set_id']])) { - $csets[$row['set_id']] = new CourseSet($row['set_id']); - } - $cs = $csets[$row['set_id']]; - $ret[$seminar_id]['cs_name'] = $cs->getName(); - $ret[$seminar_id]['distribution_time'] = $cs->getSeatDistributionTime(); - if ($ret[$seminar_id]['distribution_time'] < (time() - 1000) && !$cs->hasAlgorithmRun()) { - $this->not_distributed_coursesets[] = $cs->getName(); - } - if ($ta = $cs->getAdmissionRule('TimedAdmission')) { - $ret[$seminar_id]['start_time'] = $ta->getStartTime(); - $ret[$seminar_id]['end_time'] = $ta->getEndTime(); - } - if (!$cs->hasAlgorithmRun()) { - $ret[$seminar_id]['count_claiming'] = count(AdmissionPriority::getPrioritiesByCourse($row['set_id'], $seminar_id)); - } - } - return $ret; + return Course::findBySql($sql, $parameters); } function get_institutes($seminare_condition) diff --git a/app/views/admission/restricted_courses/index.php b/app/views/admission/restricted_courses/index.php index a7503e4..fa78f62 100644 --- a/app/views/admission/restricted_courses/index.php +++ b/app/views/admission/restricted_courses/index.php @@ -1,6 +1,7 @@ render_partial('admission/restricted_courses/_institute_choose.php')?> @@ -22,30 +23,37 @@
+ id]; + ?> - - - + + + - - - -- cgit v1.0 From 51a0e793f0ca08a6e0ea0ad6b7181e662946eeef Mon Sep 17 00:00:00 2001 From: Jan-Hendrik Willms Date: Wed, 13 Nov 2024 14:56:58 +0000 Subject: fix 2fa token waiting (and more cleaning up than should be, sorry), fixes #4855 Closes #4855 Merge request studip/studip!3640 --- lib/classes/Request.php | 2 ++ lib/classes/TwoFactorAuth.php | 52 +++++++++++++++++++++++-------------------- templates/tfa-validate.php | 16 ++++++++++--- 3 files changed, 43 insertions(+), 27 deletions(-) diff --git a/lib/classes/Request.php b/lib/classes/Request.php index 1bb1079..ae294ae 100644 --- a/lib/classes/Request.php +++ b/lib/classes/Request.php @@ -77,6 +77,8 @@ class Request implements ArrayAccess, IteratorAggregate /** * IteratorAggregate: Create iterator for request parameters. + * + * @return ArrayIterator */ public function getIterator(): Traversable { diff --git a/lib/classes/TwoFactorAuth.php b/lib/classes/TwoFactorAuth.php index 6c04b8e..b67f4be 100644 --- a/lib/classes/TwoFactorAuth.php +++ b/lib/classes/TwoFactorAuth.php @@ -11,8 +11,6 @@ final class TwoFactorAuth { const SESSION_KEY = 'tfa/confirmed'; - const SESSION_REDIRECT = 'tfa/redirect'; - const SESSION_ENFORCE = 'tfa/enforce'; const SESSION_DATA = 'tfa/data'; const SESSION_CONFIRMATIONS = 'tfa/confirmations'; const SESSION_FAILED = 'tfa/failed'; @@ -26,7 +24,7 @@ final class TwoFactorAuth * Returns an instance of the authentication * @return TwoFactorAuth object */ - public static function get() + public static function get(): TwoFactorAuth { if (self::$instance === null) { self::$instance = new self(); @@ -39,16 +37,16 @@ final class TwoFactorAuth * user (defaults to current user). The user's permissions decide whether * the two factor authentication is enabled or not. * - * @param User $user User to check (optional, defaults to current user) + * @param User|null $user User to check (optional, defaults to current user) * @return boolean */ - public static function isEnabledForUser(User $user = null) + public static function isEnabledForUser(User $user = null): bool { if ($user === null) { $user = User::findCurrent(); } - $valid_perms = array_filter(array_map('trim', explode(',', Config::get()->TFA_PERMS))); + $valid_perms = array_filter(array_map('trim', explode(',', Config::get()->getValue('TFA_PERMS')))); return in_array($user->perms, $valid_perms); } @@ -67,11 +65,13 @@ final class TwoFactorAuth /** * Private constructor to enforce singleton + * + * @throws SessionRequiredException */ private function __construct() { if (session_status() !== PHP_SESSION_ACTIVE) { - throw new Exception('2FA requires a valid session'); + throw new SessionRequiredException('2FA requires a valid session'); } if (!isset($_SESSION[self::SESSION_FAILED])) { @@ -81,7 +81,7 @@ final class TwoFactorAuth $_SESSION[self::SESSION_FAILED] = array_filter( $_SESSION[self::SESSION_FAILED], function ($timestamp) { - return $timestamp > time() - Config::get()->TFA_MAX_TRIES_TIMESPAN; + return $timestamp > time() - Config::get()->getValue('TFA_MAX_TRIES_TIMESPAN'); } ); } @@ -145,7 +145,7 @@ final class TwoFactorAuth // User has already confirmed this session? if (isset($_SESSION[self::SESSION_KEY])) { - list($code, $timeslice) = array_values($_SESSION[self::SESSION_KEY]); + [$code, $timeslice] = array_values($_SESSION[self::SESSION_KEY]); if ($this->secret->validateToken($code, (int) $timeslice, true)) { return; } @@ -155,7 +155,7 @@ final class TwoFactorAuth // Trusted computer? $user_cookie_key = self::COOKIE_KEY . '/' . $GLOBALS['user']->id; if (isset($_COOKIE[$user_cookie_key])) { - list($code, $timeslice) = explode(':', $_COOKIE[$user_cookie_key]); + [$code, $timeslice] = explode(':', $_COOKIE[$user_cookie_key]); if ($this->secret->validateToken($code, (int) $timeslice, true)) { $this->registerSecretInSession(); return; @@ -177,7 +177,7 @@ final class TwoFactorAuth * @param array $data Optional additional data to pass to the * confirmation screen (for internal use) */ - public function confirm($action, $text, array $data = []): void + public function confirm(string $action, string $text, array $data = []): void { if ( isset($_SESSION[self::SESSION_CONFIRMATIONS]) @@ -202,7 +202,7 @@ final class TwoFactorAuth * @param string $text Text to display to the user * @param array $data Optional additional data (for internal use) */ - private function showConfirmationScreen($text = '', array $data = []) + private function showConfirmationScreen(string $text = '', array $data = []) { $data = array_merge(['global' => false], $data); @@ -235,8 +235,8 @@ final class TwoFactorAuth $_SESSION[self::SESSION_DATA] + [ 'secret' => $this->secret, 'text' => $text, - 'blocked' => $this->isBlocked(), - 'duration' => Config::get()->TFA_TRUST_DURATION, + 'waittime' => $this->getWaitingTime(), + 'duration' => Config::get()->getValue('TFA_TRUST_DURATION'), ], 'layouts/base.php' ); @@ -263,7 +263,7 @@ final class TwoFactorAuth */ private function registerSecretInCookie() { - $lifetime_in_days = Config::get()->TFA_TRUST_DURATION; + $lifetime_in_days = Config::get()->getValue('TFA_TRUST_DURATION'); $lifetime = $lifetime_in_days > 0 ? strtotime("+{$lifetime_in_days} days") : 2147483647; $timeslice = mt_rand(0, PHP_INT_MAX); @@ -288,7 +288,7 @@ final class TwoFactorAuth private function validateFromRequest() { if ( - $this->isBlocked() + $this->getWaitingTime() || !Request::isPost() || !Request::submitted('tfacode-input') || !Request::submitted('tfa-nonce') @@ -341,16 +341,20 @@ final class TwoFactorAuth } /** - * Returns whether the current session is blocked from any more token - * inputs. This happens if too many false inputs happen in a short amount - * of time and should prevent brute force attacks. + * Returns the time the user has to wait before entering a token. * - * @return boolean + * This happens if too many invalid tokens have been input. */ - private function isBlocked() + private function getWaitingTime(): int { - return count($_SESSION[self::SESSION_FAILED]) >= Config::get()->TFA_MAX_TRIES - ? min($_SESSION[self::SESSION_FAILED]) - : false; + if (count($_SESSION[self::SESSION_FAILED]) < Config::get()->getValue('TFA_MAX_TRIES')) { + return 0; + } + + $min_timestamp = min(array_slice($_SESSION[self::SESSION_FAILED], -Config::get()->getValue('TFA_MAX_TRIES'))); + + $wait_time = $min_timestamp + Config::get()->getValue('TFA_MAX_TRIES_TIMESPAN') - time(); + + return ceil($wait_time / 60); } } diff --git a/templates/tfa-validate.php b/templates/tfa-validate.php index f5221bc..f3a6821 100644 --- a/templates/tfa-validate.php +++ b/templates/tfa-validate.php @@ -1,17 +1,27 @@ +
- + hideClose() ?>

type === 'app' && !$secret->confirmed): ?> - TFA_TEXT_APP) ?> + getValue('TFA_TEXT_APP')) ?>

-- cgit v1.0 From cab490d973939eae047c59997809b7c4cfd00855 Mon Sep 17 00:00:00 2001 From: Jan-Hendrik Willms Date: Wed, 13 Nov 2024 15:08:59 +0000 Subject: fix layout glitch, fixes #4865 Closes #4865 Merge request studip/studip!3644 --- resources/assets/stylesheets/scss/responsive.scss | 1 - 1 file changed, 1 deletion(-) diff --git a/resources/assets/stylesheets/scss/responsive.scss b/resources/assets/stylesheets/scss/responsive.scss index 0a84d3e..7892d30 100644 --- a/resources/assets/stylesheets/scss/responsive.scss +++ b/resources/assets/stylesheets/scss/responsive.scss @@ -550,7 +550,6 @@ $sidebarOut: -330px; max-height: unset; opacity: 1; overflow: unset; - width: calc(100% - 20px); } #main-header { -- cgit v1.0 From 019f57e1eabbe8270310c30f86980c8ce5d41483 Mon Sep 17 00:00:00 2001 From: Jan-Hendrik Willms Date: Wed, 13 Nov 2024 15:11:51 +0000 Subject: fix pagination for users index jsonapi route, fixes #4844 Closes #4844 Merge request studip/studip!3630 --- lib/classes/JsonApi/Routes/Users/UsersIndex.php | 4 ++-- lib/classes/globalsearch/GlobalSearchUsers.php | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/classes/JsonApi/Routes/Users/UsersIndex.php b/lib/classes/JsonApi/Routes/Users/UsersIndex.php index c1a4fb7..e7f074a 100644 --- a/lib/classes/JsonApi/Routes/Users/UsersIndex.php +++ b/lib/classes/JsonApi/Routes/Users/UsersIndex.php @@ -35,8 +35,8 @@ class UsersIndex extends JsonApiController $this->validateFilters(); $filters = $this->getFilters(); - list($offset, $limit) = $this->getOffsetAndLimit(); - $partSQL = \GlobalSearchUsers::getSQL($filters['search'], [], $limit + $offset); + [$offset, $limit] = $this->getOffsetAndLimit(); + $partSQL = \GlobalSearchUsers::getSQL($filters['search'], [], "{$offset}, {$limit}"); $search_result = \DBManager::get()->fetchAll($partSQL); $total = (int) \DBManager::get()->fetchColumn('SELECT FOUND_ROWS() as found_rows'); diff --git a/lib/classes/globalsearch/GlobalSearchUsers.php b/lib/classes/globalsearch/GlobalSearchUsers.php index 458f098..24952ef 100644 --- a/lib/classes/globalsearch/GlobalSearchUsers.php +++ b/lib/classes/globalsearch/GlobalSearchUsers.php @@ -29,6 +29,11 @@ class GlobalSearchUsers extends GlobalSearchModule implements GlobalSearchFullte * @param string $search the input query string * @param array $filter an array with search limiting filter information (e.g. 'category', 'semester', etc.) * @return string SQL Query to discover elements for the search + * + * @todo If the signature of this method changes, the UsersIndex jsonapi + * route must also be taken into account. The route adds another + * parameter so that offset and limit can be used. + * @see \JsonApi\Routes\Users\UsersIndex::__invoke */ public static function getSQL($search, $filter, $limit) { -- cgit v1.0 From 999c050182f840d0b6e0c5bb13eb66ec59a62018 Mon Sep 17 00:00:00 2001 From: Jan-Hendrik Willms Date: Wed, 13 Nov 2024 15:15:00 +0000 Subject: prevent warning if no self assign is activated, fixes #2518 Closes #2518 Merge request studip/studip!3632 --- app/controllers/course/statusgroups.php | 8 +++++--- app/views/course/statusgroups/create_groups.php | 9 +++++---- app/views/course/statusgroups/edit.php | 13 ++++++++---- resources/assets/javascripts/lib/statusgroups.js | 25 ------------------------ 4 files changed, 19 insertions(+), 36 deletions(-) diff --git a/app/controllers/course/statusgroups.php b/app/controllers/course/statusgroups.php index 1658a37..df8c86e 100644 --- a/app/controllers/course/statusgroups.php +++ b/app/controllers/course/statusgroups.php @@ -580,9 +580,11 @@ class Course_StatusgroupsController extends AuthenticatedController if ($selfassign !== 0) { $selfassign += Request::int('exclusive', 0); // Selfassign is not set but exclusive selfassign or some timeframe -> show warning message - } else if (Request::int('exclusive', 0) !== 0 - || Request::get('selfassign_start', null) !== null - || Request::get('selfassign_end', null) !== null) { + } else if ( + Request::bool('exclusive') + || Request::get('selfassign_start') + || Request::get('selfassign_end') + ) { PageLayout::postWarning(_('Einstellungen zum Eintrag in eine Gruppe oder zum Eintragszeitraum können ' . 'nur gespeichert werden, wenn der Selbsteintrag aktiviert ist.')); } diff --git a/app/views/course/statusgroups/create_groups.php b/app/views/course/statusgroups/create_groups.php index c011b79..4616500 100644 --- a/app/views/course/statusgroups/create_groups.php +++ b/app/views/course/statusgroups/create_groups.php @@ -106,24 +106,25 @@

-
+
-
+
-
+
- + diff --git a/app/views/admin/cronjobs/tasks/index.php b/app/views/admin/cronjobs/tasks/index.php index 268c6e9..9f2602d 100644 --- a/app/views/admin/cronjobs/tasks/index.php +++ b/app/views/admin/cronjobs/tasks/index.php @@ -17,7 +17,7 @@ use Studip\Button; - + -- cgit v1.0 From 95d444ab4a5c7ae37418709c7dba909718a1351d Mon Sep 17 00:00:00 2001 From: Ron Lucke Date: Fri, 15 Nov 2024 08:36:32 +0000 Subject: Layout Bug: Buttons im Fullcalendar Closes #4872 Merge request studip/studip!3649 --- resources/assets/stylesheets/fullcalendar.scss | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/resources/assets/stylesheets/fullcalendar.scss b/resources/assets/stylesheets/fullcalendar.scss index 1fe0a54..d4295b4 100644 --- a/resources/assets/stylesheets/fullcalendar.scss +++ b/resources/assets/stylesheets/fullcalendar.scss @@ -21,11 +21,9 @@ a.fc-event, td.fc-event { .fc-button { @include button(); - border-radius: 0; - margin-top: 0; margin-bottom: 0; - padding: 5px 20px; + padding: 0; &:last-of-type { margin-right: 0; -- cgit v1.0 From b1436f3d68d8a6ce417391565135bdc5e9b05767 Mon Sep 17 00:00:00 2001 From: Ron Lucke Date: Fri, 15 Nov 2024 08:41:03 +0000 Subject: Layout Bug: select neben button zu hoch Closes #4873 Merge request studip/studip!3650 --- resources/assets/stylesheets/scss/selects.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/assets/stylesheets/scss/selects.scss b/resources/assets/stylesheets/scss/selects.scss index 16bb90c..80befb2 100644 --- a/resources/assets/stylesheets/scss/selects.scss +++ b/resources/assets/stylesheets/scss/selects.scss @@ -207,6 +207,6 @@ select { .groupactions { select:not([multiple]):not([size]) { vertical-align: middle; - height: 40px; + height: 34px; } } \ No newline at end of file -- cgit v1.0 From 2e4a7218429ab3b10c65004e3c940b76ceee3943 Mon Sep 17 00:00:00 2001 From: Ron Lucke Date: Fri, 15 Nov 2024 09:57:02 +0000 Subject: Layout Bug: Buttons neben Suchfeld Closes #4874 Merge request studip/studip!3651 --- app/views/search/globalsearch/index.php | 2 +- resources/assets/stylesheets/scss/search.scss | 4 ++++ .../vue/components/courseware/toolbar/CoursewareToolbarBlocks.vue | 3 +-- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/app/views/search/globalsearch/index.php b/app/views/search/globalsearch/index.php index d0846e9..ba83a3b 100644 --- a/app/views/search/globalsearch/index.php +++ b/app/views/search/globalsearch/index.php @@ -9,7 +9,7 @@ asImg(['title' => _('Suche zurücksetzen')]) ?> - diff --git a/resources/assets/stylesheets/scss/search.scss b/resources/assets/stylesheets/scss/search.scss index 535306f..d3231ba 100644 --- a/resources/assets/stylesheets/scss/search.scss +++ b/resources/assets/stylesheets/scss/search.scss @@ -186,6 +186,10 @@ div#div-search-input { } +.button.seach-button { + border-radius: 0; +} + #search-active-filters { display: flex; flex-direction: row; diff --git a/resources/vue/components/courseware/toolbar/CoursewareToolbarBlocks.vue b/resources/vue/components/courseware/toolbar/CoursewareToolbarBlocks.vue index c39f43e..4a95f69 100644 --- a/resources/vue/components/courseware/toolbar/CoursewareToolbarBlocks.vue +++ b/resources/vue/components/courseware/toolbar/CoursewareToolbarBlocks.vue @@ -23,8 +23,7 @@ $value): ?> diff --git a/app/views/privacy/print.php b/app/views/privacy/print.php index 46fa71c..a5a2995 100644 --- a/app/views/privacy/print.php +++ b/app/views/privacy/print.php @@ -18,7 +18,7 @@ - + -- cgit v1.0 From f73e2918504c4786fe0609b1593971b4a959fa98 Mon Sep 17 00:00:00 2001 From: Jan-Hendrik Willms Date: Tue, 19 Nov 2024 14:42:53 +0000 Subject: allow setting different default view (regular and responsive), re #4421 Merge request studip/studip!3657 --- app/controllers/calendar/schedule.php | 1 + lib/classes/Fullcalendar.php | 24 +++++++++++++++++------- resources/assets/javascripts/lib/fullcalendar.js | 13 +++++++++++-- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/app/controllers/calendar/schedule.php b/app/controllers/calendar/schedule.php index 0a99314..8f5e6e5 100644 --- a/app/controllers/calendar/schedule.php +++ b/app/controllers/calendar/schedule.php @@ -109,6 +109,7 @@ class Calendar_ScheduleController extends AuthenticatedController $semester->id ?? '', Request::bool('show_hidden', false) ); + $fullcalendar->setResponsiveDefaultView('timeGridDay'); $this->fullcalendar = $fullcalendar->render(); } diff --git a/lib/classes/Fullcalendar.php b/lib/classes/Fullcalendar.php index db8364a..8674601 100644 --- a/lib/classes/Fullcalendar.php +++ b/lib/classes/Fullcalendar.php @@ -1,9 +1,6 @@ render(); } - public function __construct( $title = '', $config = [], @@ -61,6 +55,23 @@ class Fullcalendar $this->data_name = $data_name; } + public function setDefaultView(?string $view): void + { + if ($view === null) { + unset($this->config['defaultView']); + } else { + $this->config['defaultView'] = $view; + } + } + + public function setResponsiveDefaultView(?string $view): void + { + if ($view === null) { + unset($this->config['responsiveDefaultView']); + } else { + $this->config['responsiveDefaultView'] = $view; + } + } public function render() { @@ -79,7 +90,6 @@ class Fullcalendar ); } - /** * Creates an array with data for a Fullcalendar instance * from Stud.IP objects that implement the EventSource interface. diff --git a/resources/assets/javascripts/lib/fullcalendar.js b/resources/assets/javascripts/lib/fullcalendar.js index af86d59..615cb2d 100644 --- a/resources/assets/javascripts/lib/fullcalendar.js +++ b/resources/assets/javascripts/lib/fullcalendar.js @@ -15,6 +15,7 @@ import resourceTimelinePlugin from '@fullcalendar/resource-timeline'; import { jsPDF } from 'jspdf'; import html2canvas from 'html2canvas'; +import Responsive from "./responsive"; Date.prototype.getWeekNumber = function () { var d = new Date(Date.UTC(this.getFullYear(), this.getMonth(), this.getDate())); @@ -395,14 +396,20 @@ class Fullcalendar return; } - var config = $(node).data('config'); + let config = $(node).data('config'); + + let defaultView = 'timeGridWeek'; + if (Responsive.isResponsive() && config.responsiveDefaultView !== undefined) { + defaultView = config.responsiveDefaultView; + } else if (config.defaultView !== undefined) { + defaultView = config.defaultView; + } //Make sure the default values are set, if they are not found //in the additional_config object: config = $.extend({ plugins: [ interactionPlugin, dayGridPlugin, timeGridPlugin, resourceCommonPlugin, resourceTimeGridPlugin, resourceTimelinePlugin ], schedulerLicenseKey: 'GPL-My-Project-Is-Open-Source', - defaultView: 'timeGridWeek', header: { left: 'dayGridMonth,timeGridWeek,timeGridDay' }, @@ -696,6 +703,8 @@ class Fullcalendar config = $.extend({}, config, additional_config); + config.defaultView = defaultView; + return this.init(node, config); } -- cgit v1.0 From 3919ab809eec39b69107b0eee92c48a667e6506a Mon Sep 17 00:00:00 2001 From: Moritz Strohm Date: Wed, 20 Nov 2024 11:48:14 +0000 Subject: changed output format of SeminarCycleDate::toString, CourseDate::getFullName and CourseExDate::getFullName and made the output of CourseDateList::toHtml a list with invisible list items, fixes #4889 Closes #4889 Merge request studip/studip!3662 --- lib/models/CourseDate.php | 2 +- lib/models/CourseExDate.php | 35 +++++++++++++++++++++++++++-------- lib/models/SeminarCycleDate.php | 2 +- templates/dates/course_date_list.php | 2 +- 4 files changed, 30 insertions(+), 11 deletions(-) diff --git a/lib/models/CourseDate.php b/lib/models/CourseDate.php index f33332e..82dd692 100644 --- a/lib/models/CourseDate.php +++ b/lib/models/CourseDate.php @@ -331,7 +331,7 @@ class CourseDate extends SimpleORMap implements PrivacyObject, Event $string = sprintf( '%1$s, %2$s - %3$s', $day_of_week, - date('d.m.Y H:i', $this->date), + date('d.m.y, H:i', $this->date), $formatted_end ); } diff --git a/lib/models/CourseExDate.php b/lib/models/CourseExDate.php index eb0f90a..ec6e312 100644 --- a/lib/models/CourseExDate.php +++ b/lib/models/CourseExDate.php @@ -135,17 +135,36 @@ class CourseExDate extends SimpleORMap implements PrivacyObject, Event return ''; } - $latter_template = $format === 'verbose' - ? _('%R Uhr') - : '%R'; - if (($this->end_time - $this->date) / 60 / 60 > 23) { - return strftime('%a., %x' . ' (' . _('ganztägig') . ')' , $this->date) . " (" . _("fällt aus") . ")"; + $date_string = studip_interpolate( + $format === 'verbose' + ? _('%{weekday}, %{date}, %{start} - %{end} Uhr (ganztägig, fällt aus)') + : _('%{weekday}, %{date}, %{start} - %{end} (ganztägig, fällt aus)') + , + [ + 'weekday' => getWeekday(date('N', $this->date)), + 'date' => date('d.m.y', $this->date), + 'start' => date('H:i', $this->date), + 'end' => date('H:i', $this->end_time), + ] + ); + return $date_string; } - return strftime('%a., %x, %R', $this->date) . ' - ' - . strftime($latter_template, $this->end_time) - . ' (' . _('fällt aus') . ')'; + $date_string = studip_interpolate( + $format === 'verbose' + ? _('%{weekday}, %{date}, %{start} - %{end} Uhr (fällt aus)') + : _('%{weekday}, %{date}, %{start} - %{end} (fällt aus)') + , + [ + 'weekday' => getWeekday(date('N', $this->date)), + 'date' => date('d.m.y', $this->date), + 'start' => date('H:i', $this->date), + 'end' => date('H:i', $this->end_time), + ] + ); + + return $date_string; } /** diff --git a/lib/models/SeminarCycleDate.php b/lib/models/SeminarCycleDate.php index 9a922e9..74b0c78 100644 --- a/lib/models/SeminarCycleDate.php +++ b/lib/models/SeminarCycleDate.php @@ -253,7 +253,7 @@ class SeminarCycleDate extends SimpleORMap } $first_date = $this->getFirstDate(); if ($first_date) { - $parameters['start_date'] = date('d.m.Y', $first_date->date); + $parameters['start_date'] = date('d.m.y', $first_date->date); } if ($room && $first_date) { $text = _('%{weekday}, %{beginning} - %{end}, %{interval} (ab dem %{start_date} im Raum %{room_name})'); diff --git a/templates/dates/course_date_list.php b/templates/dates/course_date_list.php index 4fbc34f..8fdcd69 100644 --- a/templates/dates/course_date_list.php +++ b/templates/dates/course_date_list.php @@ -10,7 +10,7 @@ */ ?> isEmpty()) : ?> -
    +
      getRegularDates() as $regular_date) : ?>
    • toString('long-start') ?>
    • -- cgit v1.0 From 6f33c1ee793d28ff46d80d6393dc47952241ae9c Mon Sep 17 00:00:00 2001 From: Jan-Hendrik Willms Date: Wed, 20 Nov 2024 16:23:59 +0000 Subject: allow sorm to use other variants of I18NString, fixes #4891 Closes #4891 Merge request studip/studip!3664 --- lib/classes/SimpleORMap.php | 12 ++++++++++-- lib/models/DatafieldEntryModelI18N.php | 2 ++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/lib/classes/SimpleORMap.php b/lib/classes/SimpleORMap.php index f3993c2..d8cdb8e 100644 --- a/lib/classes/SimpleORMap.php +++ b/lib/classes/SimpleORMap.php @@ -85,6 +85,11 @@ class SimpleORMap implements ArrayAccess, Countable, IteratorAggregate protected static $performs_batch_operation = false; /** + * Defines which variant of the I18NString class should be used + */ + protected string $i18n_class = I18NString::class; + + /** * name of db table * @return string */ @@ -2232,7 +2237,10 @@ class SimpleORMap implements ArrayAccess, Countable, IteratorAggregate $field = strtolower($field); if ($this->content[$field] === null || $this->content_db[$field] === null) { return $this->content[$field] !== $this->content_db[$field]; - } else if ($this->content[$field] instanceof I18NString || $this->content_db[$field] instanceof I18NString) { + } else if ( + $this->content[$field] instanceof I18NString + || $this->content_db[$field] instanceof I18NString + ) { // Trigger loading of translations if ($this->content[$field] instanceof I18NString) { $this->content[$field]->toArray(); @@ -2512,7 +2520,7 @@ class SimpleORMap implements ArrayAccess, Countable, IteratorAggregate $value->setMetadata($meta); $this->content[$field] = $value; } else { - $this->content[$field] = new I18NString($value, null, $meta); + $this->content[$field] = new $this->i18n_class($value, null, $meta); } return $this->content[$field]; } diff --git a/lib/models/DatafieldEntryModelI18N.php b/lib/models/DatafieldEntryModelI18N.php index aa93a63..1997411 100644 --- a/lib/models/DatafieldEntryModelI18N.php +++ b/lib/models/DatafieldEntryModelI18N.php @@ -28,6 +28,8 @@ class DatafieldEntryModelI18N extends DatafieldEntryModel { + protected string $i18n_class = I18NStringDatafield::class; + protected static function configure($config = []) { $config['i18n_fields']['content'] = true; -- cgit v1.0 From 85e59c4ee2653ac0e7c97411a7651d4965a1957a Mon Sep 17 00:00:00 2001 From: Jan-Hendrik Willms Date: Thu, 21 Nov 2024 07:25:29 +0000 Subject: restrict node version to >= 18 and < 23, re #4890 Merge request studip/studip!3663 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5d9a3ce..b716f57 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "url": "https://gitlab.studip.de/studip/studip.git" }, "engines": { - "node": ">=18" + "node": ">=18 <23" }, "devDependencies": { "@axe-core/playwright": "^4.6.1", -- cgit v1.0 From 9d0f6b82f35c9403a30db9ff5680f7bb8840bd63 Mon Sep 17 00:00:00 2001 From: Ron Lucke Date: Thu, 21 Nov 2024 11:33:05 +0000 Subject: New Login Layout (#4205) Closes #4205 Merge request studip/studip!3192 --- app/controllers/news.php | 4 + db/migrations/6.0.31_add_login_news_config.php | 39 ++ lib/classes/forms/NewsRangesInput.php | 5 + lib/functions.php | 4 + lib/models/NewsRange.php | 3 + lib/models/StudipNews.php | 17 +- lib/navigation/LoginNavigation.php | 16 - lib/navigation/StudipNavigation.php | 13 - lib/phplib/Seminar_Auth.php | 15 +- lib/visual.inc.php | 4 +- public/assets/images/icons/black/faq.svg | 1 + public/assets/images/icons/blue/faq.svg | 1 + public/assets/images/icons/green/faq.svg | 1 + public/assets/images/icons/grey/faq.svg | 1 + public/assets/images/icons/red/faq.svg | 1 + public/assets/images/icons/white/accessibility.svg | 1 + public/assets/images/icons/white/faq.svg | 1 + public/assets/images/icons/yellow/faq.svg | 1 + public/logout.php | 2 +- resources/assets/stylesheets/scss/index.scss | 418 ++++++++++++++++----- resources/assets/stylesheets/scss/layouts.scss | 1 + .../stylesheets/scss/system-notifications.scss | 5 +- resources/assets/stylesheets/scss/tooltip.scss | 6 + templates/_standard_loginform.php | 80 ---- templates/footer.php | 38 +- templates/header.php | 22 +- templates/login/_header_languages.php | 35 ++ templates/login/_login_faq.php | 18 + templates/login/_login_news.php | 59 +++ templates/login/_standard_loginform.php | 69 ++++ templates/loginform.php | 205 +++++----- templates/shared/tooltip.php | 13 +- 32 files changed, 756 insertions(+), 343 deletions(-) create mode 100644 db/migrations/6.0.31_add_login_news_config.php create mode 100644 public/assets/images/icons/black/faq.svg create mode 100644 public/assets/images/icons/blue/faq.svg create mode 100644 public/assets/images/icons/green/faq.svg create mode 100644 public/assets/images/icons/grey/faq.svg create mode 100644 public/assets/images/icons/red/faq.svg create mode 100644 public/assets/images/icons/white/accessibility.svg create mode 100644 public/assets/images/icons/white/faq.svg create mode 100644 public/assets/images/icons/yellow/faq.svg delete mode 100644 templates/_standard_loginform.php create mode 100644 templates/login/_header_languages.php create mode 100644 templates/login/_login_faq.php create mode 100644 templates/login/_login_news.php create mode 100644 templates/login/_standard_loginform.php diff --git a/app/controllers/news.php b/app/controllers/news.php index 745dafd..4b9eb60 100644 --- a/app/controllers/news.php +++ b/app/controllers/news.php @@ -40,6 +40,10 @@ class NewsController extends StudipController 'title' => _('Stud.IP (systemweit)'), 'icon' => 'home', ], + 'login' => [ + 'title' => _('Stud.IP (login)'), + 'icon' => 'door-enter', + ], 'inst' => [ 'title' => _('Einrichtungen'), 'icon' => 'institute', diff --git a/db/migrations/6.0.31_add_login_news_config.php b/db/migrations/6.0.31_add_login_news_config.php new file mode 100644 index 0000000..cd234eb --- /dev/null +++ b/db/migrations/6.0.31_add_login_news_config.php @@ -0,0 +1,39 @@ +prepare($query); + $statement->execute([ + 'name' => 'LOGIN_NEWS_VISIBILITY', + 'value' => '1', + 'type' => 'boolean', + 'section' => 'Loginseite', + 'range' => 'global', + 'description' => 'Soll Ankündigungs-Galerie auf der Loginseite sichtbar sein?' + ]); + } + + public function down() + { + $query = "DELETE `config`, `config_values`, `i18n` + FROM `config` + LEFT JOIN `config_values` USING (`field`) + LEFT JOIN `i18n` + ON `table` = 'config' + AND `field` = 'value' + AND `object_id` = MD5(`config`.`field`) + WHERE `field` IN ( + 'LOGIN_NEWS_VISIBILITY' + )"; + DBManager::get()->exec($query); + } +} \ No newline at end of file diff --git a/lib/classes/forms/NewsRangesInput.php b/lib/classes/forms/NewsRangesInput.php index 1a4a830..abf95c7 100644 --- a/lib/classes/forms/NewsRangesInput.php +++ b/lib/classes/forms/NewsRangesInput.php @@ -12,6 +12,7 @@ class NewsRangesInput extends Input $items = []; $icons = [ 'global' => 'home', + 'login' => 'door-enter', 'sem' => 'seminar', 'inst' => 'institute', 'user' => 'person' @@ -115,6 +116,10 @@ class NewsRangesInput extends Input 'value' => 'studip__home', 'name' => _('Stud.IP-Startseite'), ]; + $options[] = [ + 'value' => 'login', + 'name' => _('Stud.IP-Loginseite'), + ]; } $options[] = [ diff --git a/lib/functions.php b/lib/functions.php index 893c4a4..39fb966 100644 --- a/lib/functions.php +++ b/lib/functions.php @@ -200,6 +200,10 @@ function get_object_type($id, $check_only = []) return 'global'; } + if ($id === 'login') { + return 'login'; + } + // Initialize cache array if ($cache === null) { $cache = new StudipCachedArray('Studip/ObjectTypes'); diff --git a/lib/models/NewsRange.php b/lib/models/NewsRange.php index 675478f..94c76c2 100644 --- a/lib/models/NewsRange.php +++ b/lib/models/NewsRange.php @@ -56,6 +56,9 @@ class NewsRange extends SimpleORMap case 'global': return _('Stud.IP-Startseite'); break; + case 'login': + return _('Stud.IP-Loginseite'); + break; case 'sem': return $this->course->name; break; diff --git a/lib/models/StudipNews.php b/lib/models/StudipNews.php index 2311d66..c00e4a5 100644 --- a/lib/models/StudipNews.php +++ b/lib/models/StudipNews.php @@ -237,6 +237,7 @@ class StudipNews extends SimpleORMap implements PrivacyObject $where_querypart[] = "topic LIKE CONCAT('%', ?, '%')"; $query_vars[] = $term; } + switch ($area) { case 'global': $select_querypart = 'CONCAT(news_id, "_studip") AS idx, range_id, news.* '; @@ -249,6 +250,17 @@ class StudipNews extends SimpleORMap implements PrivacyObject } $query_vars[] = 'studip'; break; + case 'login': + $select_querypart = 'CONCAT(news_id, "_studip") AS idx, range_id, news.* '; + $from_querypart = 'news_range INNER JOIN news USING(news_id)'; + $where_querypart[] = 'range_id = ?'; + if (Config::get()->SORT_NEWS_BY_CHDATE) { + $order_querypart = 'news.chdate DESC, news.date DESC'; + } else { + $order_querypart = 'news.date DESC, news.chdate DESC'; + } + $query_vars[] = 'login'; + break; case 'sem': $select_querypart = 'CONCAT(news_id, "_", range_id) AS idx, range_id, seminare.Name AS title, ' .'news.*, ' @@ -280,7 +292,7 @@ class StudipNews extends SimpleORMap implements PrivacyObject } break; default: - foreach (['global', 'inst', 'sem', 'user'] as $type) { + foreach (['global', 'login', 'inst', 'sem', 'user'] as $type) { $add_news = static::GetNewsRangesByFilter($user_id, $type, $term, $startdate, $enddate, $as_objects, $limit); if (is_array($add_news) && isset($add_news[$type])) { $limit = $limit - count($add_news[$type]); @@ -321,6 +333,9 @@ class StudipNews extends SimpleORMap implements PrivacyObject } elseif ($area === 'global') { $objects[$area][$id]['title'] = _('Ankündigungen auf der Stud.IP Startseite'); } + elseif ($area === 'login') { + $objects[$area][$id]['title'] = _('Ankündigungen auf der Stud.IP Loginseite'); + } if ($as_objects) { $objects[$area][$id]['object'] = self::build($result, false); } diff --git a/lib/navigation/LoginNavigation.php b/lib/navigation/LoginNavigation.php index 7eb3cf0..4b4241c 100644 --- a/lib/navigation/LoginNavigation.php +++ b/lib/navigation/LoginNavigation.php @@ -45,21 +45,5 @@ class LoginNavigation extends Navigation $this->addSubNavigation('login_' . $auth_plugin->plugin_name, $navigation); } } - - if (Config::get()->ENABLE_SELF_REGISTRATION) { - $navigation = new Navigation(_('Registrieren'), 'dispatch.php/registration'); - $navigation->setDescription(_('um das System erstmalig zu nutzen')); - $this->addSubNavigation('registration', $navigation); - } - - if (Config::get()->ENABLE_FREE_ACCESS) { - $navigation = new Navigation(_('Freier Zugang'), 'dispatch.php/public_courses'); - $navigation->setDescription(_('ohne Registrierung')); - $this->addSubNavigation('browse', $navigation); - } - - $navigation = new Navigation(_('Hilfe'), format_help_url('Basis.Allgemeines')); - $navigation->setDescription(_('zu Bedienung und Funktionsumfang')); - $this->addSubNavigation('help', $navigation); } } diff --git a/lib/navigation/StudipNavigation.php b/lib/navigation/StudipNavigation.php index bc3fae9..2ceed480 100644 --- a/lib/navigation/StudipNavigation.php +++ b/lib/navigation/StudipNavigation.php @@ -143,19 +143,6 @@ class StudipNavigation extends Navigation // quick links $links = new Navigation('Links'); - // login / logout - if (!isset($user) || $user->id === 'nobody') { - if (in_array('CAS', $GLOBALS['STUDIP_AUTH_PLUGIN'])) { - $links->addSubNavigation('login_cas', new Navigation(_('Login CAS'), Request::url(), ['again' => 'yes', 'sso' => 'cas', 'cancel_login' => 1])); - } - - if (in_array('Shib', $GLOBALS['STUDIP_AUTH_PLUGIN'])) { - $links->addSubNavigation('login_shib', new Navigation(_('Login Shibboleth'), Request::url(), ['again' => 'yes', 'sso' => 'shib', 'cancel_login' => 1])); - } - - $links->addSubNavigation('login', new Navigation(_('Login'), Request::url(), ['again' => 'yes'])); - } - $this->addSubNavigation('links', $links); // footer links diff --git a/lib/phplib/Seminar_Auth.php b/lib/phplib/Seminar_Auth.php index 546d6d8..17d8b89 100644 --- a/lib/phplib/Seminar_Auth.php +++ b/lib/phplib/Seminar_Auth.php @@ -316,15 +316,13 @@ class Seminar_Auth $_SESSION['contrast'] = 1; } - foreach (array_keys($GLOBALS['INSTALLED_LANGUAGES']) as $language_key) { - if (Request::submitted('set_language_' . $language_key)) { + if (Request::get('set_language') === $language_key) { $_SESSION['forced_language'] = $language_key; $_SESSION['_language'] = $language_key; } } } - $this->check_environment(); PageLayout::setBodyElementId('login'); @@ -340,6 +338,7 @@ class Seminar_Auth page_close(); die(); } else { + $news_entries = StudipNews::GetNewsByRange('login', true, false); unset($_SESSION['semi_logged_in']); // used by email activation $login_template = $GLOBALS['template_factory']->open('loginform'); if (isset($this->auth['uname']) && $this->error_msg) { @@ -348,13 +347,9 @@ class Seminar_Auth $login_template->set_attribute('error_msg', $this->error_msg); $login_template->set_attribute('uname', (isset($this->auth["uname"]) ? $this->auth["uname"] : Request::username('loginname'))); $login_template->set_attribute('self_registration_activated', Config::get()->ENABLE_SELF_REGISTRATION); - - $query = "SHOW TABLES LIKE 'login_faq'"; - $result = DBManager::get()->query($query); - - if ($result && $result->rowCount() > 0) { - $login_template->set_attribute('faq_entries', LoginFaq::findBySQL("1")); - } + $login_template->set_attribute('logout', Request::bool('logout', false)); + $login_template->set_attribute('faq_entries', LoginFaq::findBySQL("1")); + $login_template->set_attribute('news_entries', array_values($news_entries)); } PageLayout::setHelpKeyword('Basis.AnmeldungLogin'); $header_template = $GLOBALS['template_factory']->open('header'); diff --git a/lib/visual.inc.php b/lib/visual.inc.php index 2282a68..751663f 100644 --- a/lib/visual.inc.php +++ b/lib/visual.inc.php @@ -416,7 +416,7 @@ function tooltip2($text, $with_alt = TRUE, $with_popup = FALSE) { * @param bool $important render icon in "important" style * @param bool $html tooltip text is HTML content */ -function tooltipIcon($text, $important = false, $html = false): string +function tooltipIcon($text, $important = false, $html = false, bool $alt_info= false): string { if (!trim($text)) { return ''; @@ -424,7 +424,7 @@ function tooltipIcon($text, $important = false, $html = false): string // render tooltip $template = $GLOBALS['template_factory']->open('shared/tooltip'); - return $template->render(compact('text', 'important', 'html')); + return $template->render(compact('text', 'important', 'html', 'alt_info')); } /** diff --git a/public/assets/images/icons/black/faq.svg b/public/assets/images/icons/black/faq.svg new file mode 100644 index 0000000..7775766 --- /dev/null +++ b/public/assets/images/icons/black/faq.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/assets/images/icons/blue/faq.svg b/public/assets/images/icons/blue/faq.svg new file mode 100644 index 0000000..1af2e2e --- /dev/null +++ b/public/assets/images/icons/blue/faq.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/assets/images/icons/green/faq.svg b/public/assets/images/icons/green/faq.svg new file mode 100644 index 0000000..2b702af --- /dev/null +++ b/public/assets/images/icons/green/faq.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/assets/images/icons/grey/faq.svg b/public/assets/images/icons/grey/faq.svg new file mode 100644 index 0000000..6c949d2 --- /dev/null +++ b/public/assets/images/icons/grey/faq.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/assets/images/icons/red/faq.svg b/public/assets/images/icons/red/faq.svg new file mode 100644 index 0000000..faec143 --- /dev/null +++ b/public/assets/images/icons/red/faq.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/assets/images/icons/white/accessibility.svg b/public/assets/images/icons/white/accessibility.svg new file mode 100644 index 0000000..7bf95ab --- /dev/null +++ b/public/assets/images/icons/white/accessibility.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/assets/images/icons/white/faq.svg b/public/assets/images/icons/white/faq.svg new file mode 100644 index 0000000..8300128 --- /dev/null +++ b/public/assets/images/icons/white/faq.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/assets/images/icons/yellow/faq.svg b/public/assets/images/icons/yellow/faq.svg new file mode 100644 index 0000000..9a39678 --- /dev/null +++ b/public/assets/images/icons/yellow/faq.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/logout.php b/public/logout.php index 76d079d..6f94194 100644 --- a/public/logout.php +++ b/public/logout.php @@ -90,4 +90,4 @@ if ($auth->auth['uid'] !== 'nobody') { page_close(); } -header('Location: ' . URLHelper::getURL('index.php')); +header('Location: ' . URLHelper::getURL('index.php?logout=1')); diff --git a/resources/assets/stylesheets/scss/index.scss b/resources/assets/stylesheets/scss/index.scss index 046d027..134fd9a 100644 --- a/resources/assets/stylesheets/scss/index.scss +++ b/resources/assets/stylesheets/scss/index.scss @@ -34,11 +34,8 @@ $gap-between-boxes: calc($login-page-margin / 2); z-index: -1; } -#index, -#login { - #content { - padding: $login-page-margin; - } +#index #content { + padding: $login-page-margin; } #login_flex { @@ -50,83 +47,299 @@ $gap-between-boxes: calc($login-page-margin / 2); align-items: flex-start; } -#loginbox { - background-color: var(--white); +#login-wrapper { + margin: auto; + display: flex; + flex-direction: column; + position: relative; + top: -100px; +} + +#login-content-wrapper { + position: relative; + height: 540px; + display: flex; box-shadow: 0 0 8px rgba(0, 0, 0, 0.5); - padding: 20px; - width: 450px; - float: left; - header { - margin: 0 0 0 0; + #login-infobox { + max-width: 540px; + min-width: 540px; + height: calc(100% - 64px); + padding: 32px 64px 32px 32px; + background-color: rgba(255, 255, 255, 0.8); + color: var(--black); + position: relative; + + &.no-toggle { + padding: 32px; + } - h1 { - border-bottom: 0; + &.hide { + display: none; } - } - nav { - ul { - display: inline-block; - list-style-type: none; - margin: 0; - width: 450px; - padding-inline-start: 0; - - .login_link { - display: inline-block; - width: 180px; - vertical-align: top; - padding-right: 25px; - - a { - font-size: 1.5em; - - p { - font-size: 0.5em; - color: var(--black); - } + #login-infobox-button-wrapper { + position: absolute; + right: 24px; + top: 32px; + gap: 4px; + display: flex; + flex-direction: column; + background-color: var(--white); + + button { + border: none; + background-color: transparent; + cursor: pointer; + padding: 4px 4px 4px 8px; + border-left: solid 4px var(--white); + + &.selected { + border-color: var(--base-color); + } + + &:hover:not(.selected) { + border-color: var(--content-color); } } } + + .hidden { + visibility: hidden; + height: 0; + padding: 0; + overflow: hidden; + } } +} - footer { - overflow: auto; +#login-faq-box { + background-color: var(--white); + padding: 8px 16px 8px 32px; + height: calc(100% - 16px); + position: relative; + overflow-y: auto; - #languages, - #contrast { - display: flex; - align-items: center; - gap: 5px; - border-top: 1px solid var(--light-gray-color); - font-size: 0.9em; - padding: 10px; + #login-faq-content-wrapper { + height: calc(100% - 32px); + } + + .login-box-header { + h2 { + margin-bottom: 20px; + margin-left: 0.5ex; + } + } + .login-faq-content { + overflow-y: auto; + padding-right: 16px; + height: calc(100% - 64px); + + article.studip { + margin-bottom: 1.5ex; } + } +} - #contrast { - padding-bottom: 0; +#login-news-box { + display: flex; + flex-direction: column; + height: 100%; + justify-content: space-between; + + #login-news-content-wrapper { + height: calc(100% - 60px); + flex-grow: 1; + background-color: var(--white); + padding: 8px 16px 8px 32px; + margin-bottom: 16px; + position: relative; + + #show-faq { + position: absolute; + top: 16px; + right: 16px; + border: none; + background-color: transparent; + text-align: right; + cursor: pointer; } + } - div.login_info { - border-top: 1px solid var(--light-gray-color); - font-size: 0.8em; - div { - text-align: right; - float: left; - padding: 5px; + .login-news { + height: 100%; + visibility: visible; + .hidden { + visibility: hidden; + height: 0; + } - &:last-child { - float:right; - } + .login-news-details { + max-height: 340px; + overflow-y: auto; + margin: 0 0 16px 0; + padding-right: 16px; + } + } + + #login-news-nav { + display: flex; + flex-direction: row; + justify-content: center; + margin: 0 64px; + height: 36px; + + .login-news-nav { + margin-left: 8px; + width: 48px; + height: 48px; + border: none; + @include background-icon('bullet-dot', clickable, 24); + background-repeat: no-repeat; + background-color: transparent; + background-position: center; + cursor: pointer; + + &.active-news-bullet { + background-size: 28px; } } - > a { - margin-left: 12px; + } +} + +#loginbox { + display: flex; + flex-direction: column; + align-content: space-between; + background-color: var(--white); + padding: 32px; + min-width: 340px; + width: 340px; + + header h1 { + margin: 32px 0; + text-align: center; + } + + .login-message { + display: flex; + margin: 0 32px 16px 32px; + &#login-error { + background-color: var(--red-20); + } + + &#logout-info { + background-color: var(--base-color-20); + } + + img { + height: calc(100% - 8px); + padding: 4px 8px; + } + + .login-message-content { + padding: 8px; + + p { + margin: 0; + } } } + form#login-form { + margin: 0; + padding: 0 32px; + border-top: none; + border-bottom: solid thin var(--content-color-40); + + &.login-bottom { + border-top: solid thin var(--content-color-40); + border-bottom: none; + padding-top: 1em; + } + + section { + padding-top: 0; + + input { + margin-top: 0; + } + } + + label { + display: inline-block; + min-width: 100%; + text-indent: 0; + + &.with-tooltip { + min-width: calc(100% - 24px); + } + } + + .tooltip { + line-height: 36px; + margin-left: 4px; + } + + .login-button-wrapper { + margin: 1em 0; + + button { + width: 100%; + font-weight: 700; + } + } + } + + nav { + margin: 1em; + padding: 0 16px; + text-align: center; + flex-grow: 1; + display: flex; + flex-direction: column; + justify-content: flex-end; + + a { + display: block; + margin: 0 auto; + padding: 4px 8px; + width: 100%; + text-align: left; + color: var(--base-color); + + .title { + font-size: 1.25em; + font-weight: 700; + margin: 0 0 4px; + } + .description { + font-size: 0.75em; + max-width: 75%; + white-space: collapse; + color: var(--black); + } + + &:hover { + color: var(--active-color); + } + + &.link-registration { + margin-top: 1em; + } + } + + &.login-bottom { + justify-content: flex-start; + } + } + + footer { + border-top: solid thin #ccc; + padding-top: 1em; + margin-top: 1em; + } input#loginname, input#password { @@ -142,10 +355,6 @@ $gap-between-boxes: calc($login-page-margin / 2); right: 7px; bottom: 0; cursor: pointer; - - #visible-password, - #invisible-password { - } } } @@ -155,14 +364,10 @@ $gap-between-boxes: calc($login-page-margin / 2); padding: 20px; width: 450px; float: left; - > header { - margin: 0 0 0 0; - } } #index, #login { - #current-page-structure { display: none; } @@ -177,38 +382,63 @@ $gap-between-boxes: calc($login-page-margin / 2); display: none; } - -#login-form { - max-height: 300px; - overflow: hidden; - transition: max-height var(--transition-duration-slow) linear; - - // This prevents the focus border from being cut due to overflow: hidden - margin-left: -5px; - margin-right: -5px; - padding-left: 5px; - padding-right: 5px; - - &.hide { - max-height: 0px; +#top-bar { + #language-selector { + margin-right: 4px; } +} - #submit_login { - margin-top: 0 !important; - float: left !important; +#header-links { + #contrast { + margin-left: 4px; + button.as-link { + color: var(--white); + line-height: 28px; + &:hover { + text-decoration: underline; + } + } + img { + vertical-align: middle; + } } +} - #login-footer-bottom { - display: flex; - align-items: flex-start; - justify-content: space-between; - margin-bottom: 1.5ex; +/* * * * * * * * * * +responsive settings +* * * * * * * * * */ +html:not(.size-large) { + #login-wrapper { + margin: 0 15px; + width: 100%; + top: 0; + #login-content-wrapper { + #login-infobox { + max-width: unset; + min-width: unset; + flex-grow: 1; + } + } } +} +html:not(.size-medium) { + #login-wrapper { + margin: 0; + #login-content-wrapper { + height: unset; + + flex-wrap: wrap; + + #loginbox { + min-width: unset; + width: unset; + flex-grow: 1; + } - #login-footer-top { - display: flex; - align-items: flex-start; - justify-content: space-between; + #login-infobox { + width: 100%; + } + } } } diff --git a/resources/assets/stylesheets/scss/layouts.scss b/resources/assets/stylesheets/scss/layouts.scss index b76791f..5d65bea 100644 --- a/resources/assets/stylesheets/scss/layouts.scss +++ b/resources/assets/stylesheets/scss/layouts.scss @@ -111,6 +111,7 @@ body { } #main-footer-navigation { + flex-grow: 1; ul { display: flex; flex: 1; diff --git a/resources/assets/stylesheets/scss/system-notifications.scss b/resources/assets/stylesheets/scss/system-notifications.scss index d4795f9..57c00a0 100644 --- a/resources/assets/stylesheets/scss/system-notifications.scss +++ b/resources/assets/stylesheets/scss/system-notifications.scss @@ -63,8 +63,9 @@ } &.system-notifications-login { - margin-bottom: 15px; - overflow: hidden; + min-height: 100px; + width: 404px; + margin: auto; } overflow: visible; diff --git a/resources/assets/stylesheets/scss/tooltip.scss b/resources/assets/stylesheets/scss/tooltip.scss index 34e6352..223b72b 100644 --- a/resources/assets/stylesheets/scss/tooltip.scss +++ b/resources/assets/stylesheets/scss/tooltip.scss @@ -35,6 +35,12 @@ &.tooltip-important { @include icon(before, info-circle, attention, $icon-size-inline); } + &.tooltip-info-alt { + @include icon(before, info-circle, info-alt); + &::before { + vertical-align: middle; + } + } .tooltip-content { @extend %tooltip; diff --git a/templates/_standard_loginform.php b/templates/_standard_loginform.php deleted file mode 100644 index 5335d19..0000000 --- a/templates/_standard_loginform.php +++ /dev/null @@ -1,80 +0,0 @@ -USERNAME_TOOLTIP_TEXT; -$password_tooltip_text = (string)Config::get()->PASSWORD_TOOLTIP_TEXT; -?> - - -> -
      - - - -
      - - - - - - - diff --git a/templates/footer.php b/templates/footer.php index 63cb064..37389c6 100644 --- a/templates/footer.php +++ b/templates/footer.php @@ -12,26 +12,22 @@ - - - - + + + + diff --git a/templates/header.php b/templates/header.php index 75a87b4..7cb271c 100644 --- a/templates/header.php +++ b/templates/header.php @@ -82,7 +82,6 @@ if ($navigation) { -
- + - +
- + {{ $gettextInterpolate($ngettext('Ein Eintrag für den Begriff "%{searchterm}" gefunden', '%{count} Einträge für den Begriff "%{searchterm}" gefunden', courses.length), { count: courses.length, searchterm: searchConfig.searchterm}) }} diff --git a/templates/_standard_loginform.php b/templates/_standard_loginform.php index 4ae9f27..5335d19 100644 --- a/templates/_standard_loginform.php +++ b/templates/_standard_loginform.php @@ -47,11 +47,11 @@ $password_tooltip_text = (string)Config::get()->PASSWORD_TOOLTIP_TEXT; > - asImg(20, [ + asImg([ 'id ' => 'visible-password', 'title' => _('Passwort anzeigen'), ]) ?> - asImg(20, [ + asImg([ 'id' => 'invisible-password', 'style' => 'display: none', 'title' => _('Passwort verstecken'), diff --git a/templates/admin/topLinks.php b/templates/admin/topLinks.php index b63befc..1fc7970 100644 --- a/templates/admin/topLinks.php +++ b/templates/admin/topLinks.php @@ -18,7 +18,7 @@ @@ -27,15 +27,15 @@ - \ No newline at end of file + diff --git a/templates/blubber/course_context.php b/templates/blubber/course_context.php index e611992..d13752e 100644 --- a/templates/blubber/course_context.php +++ b/templates/blubber/course_context.php @@ -9,7 +9,7 @@
  • $course->getId(), 'redirect_to' => $icon->getURL()]) ?>"getTitle() ? ' title="'.htmlReady($icon->getTitle()).'"' : "" ?>> - getImage()->asImg(20) ?> + getImage() ?>
  • @@ -21,7 +21,7 @@ diff --git a/templates/blubber/disable-notifications.php b/templates/blubber/disable-notifications.php index a3a63c4..d80fa7a 100644 --- a/templates/blubber/disable-notifications.php +++ b/templates/blubber/disable-notifications.php @@ -5,8 +5,8 @@ class="followunfollow" title="" data-thread_id="id) ?>"> - asImg(20, ['class' => "follow text-bottom"]) ?> - asImg(20, ['class' => "unfollow text-bottom"]) ?> + asImg(['class' => 'follow text-bottom']) ?> + asImg(['class' => 'unfollow text-bottom']) ?> diff --git a/templates/blubber/global_context.php b/templates/blubber/global_context.php index 4a52407..0045592 100644 --- a/templates/blubber/global_context.php +++ b/templates/blubber/global_context.php @@ -10,8 +10,8 @@ aria-pressed="" role="button" data-thread_id="global"> - asImg(20, ['class' => "follow text-bottom"]) ?> - asImg(20, ['class' => "unfollow text-bottom"]) ?> + asImg(['class' => 'follow text-bottom']) ?> + asImg(['class' => 'unfollow text-bottom']) ?> diff --git a/templates/blubber/private_context.php b/templates/blubber/private_context.php index b9a5ba9..164a20a 100644 --- a/templates/blubber/private_context.php +++ b/templates/blubber/private_context.php @@ -13,8 +13,8 @@ getId() !== $GLOBALS['user']->id && count($mentions) > 2) : ?> - getId()) ?>" data-dialog title=""> - asImg(20, ['class' => "text-bottom"]) ?> + getId()) ?>" data-dialog title=""> + asImg(['class' => 'text-bottom']) ?> getId() === $GLOBALS['user']->id) : ?> diff --git a/templates/blubber/public_context.php b/templates/blubber/public_context.php index b7643be..7d77c25 100644 --- a/templates/blubber/public_context.php +++ b/templates/blubber/public_context.php @@ -1,18 +1,18 @@
    - id) || $GLOBALS['perm']->have_perm("root")) : ?> + id || $GLOBALS['perm']->have_perm("root")) : ?>
    getId()) ?>" data-dialog title=""> - asImg(30) ?> + asImg(30) ?> getId()) ?>" method="post" data-confirm=""> - asInput(30, ['title' => _('Diesen Blubber löschen.')]) ?> + asInput(30, ['title' => _('Diesen Blubber löschen.')]) ?>
    diff --git a/templates/contact/header-groups.php b/templates/contact/header-groups.php index 3a6be9b..ae7d262 100644 --- a/templates/contact/header-groups.php +++ b/templates/contact/header-groups.php @@ -32,7 +32,7 @@ - _('Nachricht an alle Personen dieser Gruppe schicken')])->asImg() ?> + asImg(['title' => _('Nachricht an alle Personen dieser Gruppe schicken')]) ?> diff --git a/templates/contact/index.php b/templates/contact/index.php index 358d854..7d6d08d 100644 --- a/templates/contact/index.php +++ b/templates/contact/index.php @@ -6,11 +6,11 @@ - asImg() ?> + - asImg() ?> +
    • - asImg() ?> +
    • - asImg() ?> +
    • - asImg() ?> +
    • - asImg() ?> +
    • - asImg() ?> +
    • - asImg() ?> +
    • - +
    • diff --git a/templates/forms/form.php b/templates/forms/form.php index fe19404..b367974 100644 --- a/templates/forms/form.php +++ b/templates/forms/form.php @@ -46,7 +46,7 @@ $form_id = md5(uniqid()); v-if="STUDIPFORM_REQUIRED.length > 0 || STUDIPFORM_VALIDATIONNOTES.length > 0">

      - asImg(17, ['class' => "text-bottom validation_notes_icon"]) ?> + asImg(['class' => 'text-bottom validation_notes_icon']) ?>

      diff --git a/templates/news/comment-box.php b/templates/news/comment-box.php index eb19810..d9d8bb7 100644 --- a/templates/news/comment-box.php +++ b/templates/news/comment-box.php @@ -17,7 +17,10 @@
    - _('Kommentar entfernen')])->asInput(['name'=>'news_delete_comment_'.$comment['comment_id']]) ?> + asInput([ + 'name' => 'news_delete_comment_'.$comment['comment_id'], + 'title' => _('Kommentar entfernen'), + ]) ?>
    - _("Blubber diesen Nutzer an"), 'class' => 'text-bottom'])->asImg() ?> + asImg(['title' => _("Blubber diesen Nutzer an"), 'class' => 'text-bottom']) ?> - _("Nachricht an Benutzer verschicken"), 'class' => 'text-bottom'])->asImg() ?> + asImg(['title' => _('Nachricht an Benutzer verschicken'), 'class' => 'text-bottom']) ?> - _('Rauminformationen'), - 'class' => 'text-bottom' - ])?> + asImg([ + 'title' => _('Rauminformationen'), + 'class' => 'text-bottom' + ])?> - _('Semesterbelegung'), - 'class' => 'text-bottom' - ] - )?> + asImg([ + 'title' => _('Semesterbelegung'), + 'class' => 'text-bottom' + ])?> - asInput( - [ - 'data-confirm-message' => _('Sind Sie sicher?'), - 'class' => 'text-bottom clipboard-item-remove-button' - ] - ) ?> + asInput([ + 'data-confirm-message' => _('Sind Sie sicher?'), + 'class' => 'text-bottom clipboard-item-remove-button' + ]) ?>
    - - - + + getFullName()) ?> + + admission_turnout ?: '') ?> + + admission_prelim && $additional['accepted_count']) : ?> + - + + - + + - + + - start_semester instanceof Semester) ? strftime('%x %R', $course->start_semester->beginn) : '-') ?> + start_semester->beginn) ?> - end_semester instanceof Semester) ? strftime('%x %R', $course->end_semester->ende) : '-') ?> + end_semester ? date('d.m.Y H:i', $course->end_semester->ende) : '-' ?>
    - +
    @@ -196,7 +196,7 @@
    @@ -222,7 +222,7 @@
    -- cgit v1.0 From d1375e5f7b5d7543ec694df7c2f47b0a967f8951 Mon Sep 17 00:00:00 2001 From: Thomas Hackl Date: Mon, 25 Nov 2024 08:41:07 +0000 Subject: =?UTF-8?q?Resolve=20"Garuda=20in=20den=20Kern=20=C3=BCbernehmen"?= 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 + db/migrations/6.0.32_integrate_garuda_plugin.php | 285 ++++++++++++ .../conditionaladmission/ConditionalAdmission.php | 2 + .../PreferentialAdmission.php | 1 + lib/classes/JsonApi/RouteMap.php | 9 + lib/classes/JsonApi/Routes/MassMail/Authority.php | 30 ++ .../Routes/MassMail/MassMailMessagesIndex.php | 71 +++ .../Routes/MassMail/MassMailPermissionsIndex.php | 31 ++ .../Routes/MassMail/MassMailPermissionsShow.php | 32 ++ .../JsonApi/Routes/UserFilters/Authority.php | 11 +- .../Routes/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 ++++ lib/classes/JsonApi/Schemas/MassMailMessage.php | 84 ++++ lib/classes/JsonApi/Schemas/MassMailPermission.php | 121 +++++ lib/classes/UserFilter.php | 320 +++++++++++++ lib/classes/UserFilterField.php | 517 +++++++++++++++++++++ .../UserFilterFields/DatafieldCondition.php | 166 +++++++ lib/classes/UserFilterFields/DegreeCondition.php | 54 +++ lib/classes/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 ++++++ .../MassMail/MassMailSemesterOfStudyFilter.php | 75 +++ .../MassMail/MassMailStatusgroupFilter.php | 88 ++++ .../MassMail/MassMailSubjectFilter.php | 125 +++++ .../UserFilterFields/PermissionCondition.php | 49 ++ .../UserFilterFields/SemesterOfStudyCondition.php | 84 ++++ .../UserFilterFields/StgteilVersionCondition.php | 86 ++++ lib/classes/UserFilterFields/SubjectCondition.php | 55 +++ .../UserFilterFields/SubjectConditionAny.php | 50 ++ lib/classes/UserFilterRange.php | 29 ++ lib/classes/admission/CourseSet.php | 46 +- lib/classes/admission/UserFilter.php | 280 ----------- lib/classes/admission/UserFilterField.php | 478 ------------------- .../admission/userfilter/DatafieldCondition.php | 164 ------- .../admission/userfilter/DegreeCondition.php | 51 -- .../admission/userfilter/PermissionCondition.php | 46 -- .../userfilter/SemesterOfStudyCondition.php | 81 ---- .../userfilter/StgteilVersionCondition.php | 83 ---- .../admission/userfilter/SubjectCondition.php | 52 --- .../admission/userfilter/SubjectConditionAny.php | 50 -- 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 ++++ .../components/form_inputs/SerialTextMarkers.vue | 80 ++++ .../vue/components/form_inputs/UserFilterInput.vue | 146 ++++++ .../components/massmail/MassMailMessagesList.vue | 153 ++++++ .../components/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 + 86 files changed, 6110 insertions(+), 1341 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 create mode 100644 lib/classes/UserFilter.php create mode 100644 lib/classes/UserFilterField.php create mode 100644 lib/classes/UserFilterFields/DatafieldCondition.php create mode 100644 lib/classes/UserFilterFields/DegreeCondition.php 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 create mode 100644 lib/classes/UserFilterFields/PermissionCondition.php create mode 100644 lib/classes/UserFilterFields/SemesterOfStudyCondition.php create mode 100644 lib/classes/UserFilterFields/StgteilVersionCondition.php create mode 100644 lib/classes/UserFilterFields/SubjectCondition.php create mode 100644 lib/classes/UserFilterFields/SubjectConditionAny.php create mode 100644 lib/classes/UserFilterRange.php delete mode 100644 lib/classes/admission/UserFilter.php delete mode 100644 lib/classes/admission/UserFilterField.php delete mode 100644 lib/classes/admission/userfilter/DatafieldCondition.php delete mode 100644 lib/classes/admission/userfilter/DegreeCondition.php delete mode 100644 lib/classes/admission/userfilter/PermissionCondition.php delete mode 100644 lib/classes/admission/userfilter/SemesterOfStudyCondition.php delete mode 100644 lib/classes/admission/userfilter/StgteilVersionCondition.php delete mode 100644 lib/classes/admission/userfilter/SubjectCondition.php delete mode 100644 lib/classes/admission/userfilter/SubjectConditionAny.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 3d63cd9..890a9ab 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'] = ''; + $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 0000000..3f7a009 --- /dev/null +++ b/app/controllers/massmail/message.php @@ -0,0 +1,394 @@ +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('
    ' . $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('
    ' . $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 0000000..db71d35 --- /dev/null +++ b/app/controllers/massmail/overview.php @@ -0,0 +1,32 @@ +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 0000000..bd8200b --- /dev/null +++ b/app/controllers/massmail/permissions.php @@ -0,0 +1,174 @@ +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 0000000..b7cdf94 --- /dev/null +++ b/app/controllers/massmail/quick.php @@ -0,0 +1,90 @@ +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 0000000..1b6c626 --- /dev/null +++ b/app/controllers/massmail/settings.php @@ -0,0 +1,109 @@ +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 0000000..e871040 --- /dev/null +++ b/app/views/admin/courses/massmail.php @@ -0,0 +1,10 @@ + + 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 0000000..2e08614 --- /dev/null +++ b/db/migrations/6.0.32_integrate_garuda_plugin.php @@ -0,0 +1,285 @@ +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 ab26cc2..10bcb99 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 23633d6..9617f2b 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 b13887f..ec3668d 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 0000000..20a79ce --- /dev/null +++ b/lib/classes/JsonApi/Routes/MassMail/Authority.php @@ -0,0 +1,30 @@ +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 0000000..46f2c98 --- /dev/null +++ b/lib/classes/JsonApi/Routes/MassMail/MassMailMessagesIndex.php @@ -0,0 +1,71 @@ +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 0000000..1d8f3ab --- /dev/null +++ b/lib/classes/JsonApi/Routes/MassMail/MassMailPermissionsIndex.php @@ -0,0 +1,31 @@ +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 0000000..1f91b05 --- /dev/null +++ b/lib/classes/JsonApi/Routes/MassMail/MassMailPermissionsShow.php @@ -0,0 +1,32 @@ +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 d934894..e84e4d0 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 ede43cf..d4e9efd 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 42cd583..e57dc13 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 6f2b0cb..e3cd534 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 309da9b..f9adecc 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 44bfd04..50d6760 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 0000000..05a3080 --- /dev/null +++ b/lib/classes/JsonApi/Schemas/Degree.php @@ -0,0 +1,78 @@ +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 0000000..598b0f6 --- /dev/null +++ b/lib/classes/JsonApi/Schemas/MassMailMessage.php @@ -0,0 +1,84 @@ +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 0000000..27150b4 --- /dev/null +++ b/lib/classes/JsonApi/Schemas/MassMailPermission.php @@ -0,0 +1,121 @@ +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/UserFilter.php b/lib/classes/UserFilter.php new file mode 100644 index 0000000..7745587 --- /dev/null +++ b/lib/classes/UserFilter.php @@ -0,0 +1,320 @@ + + * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 + * @category Stud.IP + */ +class UserFilter +{ + // --- ATTRIBUTES --- + + /** + * All condition fields that form this condition. + */ + public $fields = []; + + /** + * Unique identifier for this condition. + */ + public $id = ''; + + // Data about where this filter belongs. + public string $range_id = ''; + public string $range_type = ''; + + public $show_user_count = false; + + // --- OPERATIONS --- + + /** + * Standard constructor. + * + * @param String conditionId + * @return UserFilter + */ + public function __construct($conditionId = '') + { + $this->id = $conditionId; + if ($conditionId) { + $this->load(); + } else { + $this->id = $this->generateId(); + } + return $this; + } + + /** + * Add a new condition field. + * + * @param UserFilterField fieldId + * @return UserFilter + */ + public function addField($field) + { + $this->fields[$field->getId()] = $field; + $field->setConditionId($this->id); + return $this; + } + + /** + * Deletes the condition and all associated fields. + */ + public function delete() + { + // Delete condition data. + $stmt = DBManager::get()->prepare("DELETE FROM `userfilter` + WHERE `filter_id`=?"); + $stmt->execute([$this->id]); + // Delete all defined condition fields. + foreach ($this->fields as $field) { + $field->delete(); + } + } + + /** + * Generate a new unique ID. + * + * @param String tableName + */ + public function generateId() + { + do { + $newid = md5(uniqid(get_class($this) . microtime(), true)); + $id = DBManager::get()->fetchColumn("SELECT `filter_id` + FROM `userfilter` WHERE `filter_id`=?", [$newid]); + } while ($id); + return $newid; + } + + /** + * Get all fields (without checking for validity according + * to the current time). + * + * @return Array + */ + public function getFields() + { + uasort($this->fields, function ($a, $b) { + return $a->sortOrder - $b->sortOrder; + }); + return $this->fields; + } + + /** + * Get ID. + * + * @return String + */ + public function getId() + { + return $this->id; + } + + /** + * Gets all users that fulfill the current condition. + * + * @return Array + */ + public function getUsers() + { + $users = null; + foreach ($this->fields as $field) { + // Check if restrictions for the field value must be taken into consideration. + $restrictions = []; + foreach ($field->relations as $className => $related) { + if ($other = $this->hasField($className)) { + if ($other->getValue()) { + $restrictions[$className] = [ + 'table' => $other->userDataDbTable, + 'field' => $other->userDataDbField, + 'compare' => $other->getCompareOperator(), + 'value' => $other->getValue() + ]; + } + } + } + $users = isset($users) ? array_intersect($users, $field->getUsers($restrictions)) : $field->getUsers($restrictions); + } + return (array)$users; + } + + /** + * Checks whether the current filter object contains a field + * of the given type. + * + * @param String $className the type to check for + * @return UserFilterField Return the found field or null if not applicable. + */ + public function hasField($className) + { + foreach ($this->fields as $field) { + if ($field instanceof $className) { + return $field; + break; + } + } + return null; + } + + /** + * Is the current condition fulfilled (that means, are all + * required field values matched)? + * + * @return boolean + */ + public function isFulfilled($userId) + { + // Check all fields. + foreach ($this->fields as $field) { + if (!$field->checkValue($field->getUserValues($userId, $this->fields))) { + return false; + } + } + return true; + } + + /** + * Helper function for loading data from DB. + */ + 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` + WHERE `filter_id`=?"); + $stmt->execute([$this->id]); + while ($data = $stmt->fetch(PDO::FETCH_ASSOC)) { + /* + * Create instance of appropriate UserFilterField subclass. + * We just "try" here because the class definition could have + * 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']); + } + + $this->fields[$field->getId()] = $field; + //} catch (Exception $e) {} + } + } + } + + /** + * Removes the field with the given ID from the condition fields. + * + * @param String fieldId + * @return UserFilter + */ + public function removeField($fieldId) + { + unset($this->fields[$fieldId]); + return $this; + } + + /** + * Stores data to DB. + */ + public function store() + { + // Generate new ID if condition entry doesn't exist in DB yet. + if (!$this->id) { + $this->id = $this->generateId(); + } + + // Store condition data. + $stmt = DBManager::get()->prepare("INSERT INTO `userfilter` + (`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)) . "')"); + // Store all fields. + foreach ($this->fields as $field) { + $field->store($this->id); + } + } + + public function toString() + { + $tpl = $GLOBALS['template_factory']->open('userfilter/display'); + $tpl->set_attribute('filter', $this); + return $tpl->render(); + } + + public function __toString() + { + return $this->toString(); + } + + public function __clone() + { + $this->id = md5(uniqid(get_class($this))); + $cloned_fields = []; + foreach ($this->fields as $field) { + $dolly = clone $field; + $dolly->conditionId = $this->id; + $cloned_fields[$dolly->id] = $dolly; + } + $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/UserFilterField.php b/lib/classes/UserFilterField.php new file mode 100644 index 0000000..d997489 --- /dev/null +++ b/lib/classes/UserFilterField.php @@ -0,0 +1,517 @@ + + * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 + * @category Stud.IP + */ +class UserFilterField +{ + // --- ATTRIBUTES --- + + /** + * Which of the valid compare operators is currently chosen? + */ + public $compareOperator = ''; + + /** + * ID of the UserFilter this field belongs to. + */ + public $conditionId = ''; + + /** + * Unique ID for this condition field. + */ + public $id = ''; + + /** + * The set of valid compare operators. + */ + public $validCompareOperators = []; + + /** + * All valid values for this field. + */ + public $validValues = []; + + /** + * Which of the valid values is currently chosen? + */ + public $value = null; + + /* + * 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 static $sortOrder = 99; + + public static $isParameterized = false; + + protected static $cached_valid_values; + protected static $available_filter_fields; + + /** + * Database tables and fields to get valid values and concrete user values + * from. + */ + public $valuesDbTable = ''; + public $valuesDbIdField = ''; + public $valuesDbNameField = ''; + public $userDataDbTable = ''; + public $userDataDbField = ''; + public $relations = []; + + // --- OPERATIONS --- + + public static function getParameterizedTypes() + { + + } + + /** + * 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. + * + * @param String $fieldId If a fieldId is given, the corresponding data is + * loaded from database. + * + */ + public function __construct($fieldId = '') + { + $this->validCompareOperators = [ + '=' => _('ist'), + '!=' => _('ist nicht') + ]; + if ($this->valuesDbNameField) { + if (isset(self::$cached_valid_values[static::class])) { + $this->validValues = self::$cached_valid_values[static::class]; + } 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"); + while ($current = $stmt->fetch(PDO::FETCH_ASSOC)) { + $this->validValues[$current[$this->valuesDbIdField]] = $current[$this->valuesDbNameField]; + } + self::$cached_valid_values[static::class] = $this->validValues; + } + } + if ($fieldId) { + $this->id = $fieldId; + $this->load(); + } else { + $this->id = $this->generateId(); + } + } + + /** + * Checks whether the given value fits the configured condition. The + * value is compared to the currently selected value by using the + * currently selected compare operator. + * + * @param Array values + * @return Boolean + */ + public function checkValue($values) + { + // Validate compare operator + if (!isset($this->validCompareOperators[$this->compareOperator])) { + throw new Exception('Invalid compare operator'); + } + + $result = false; + foreach ($values as $value) { + switch ($this->compareOperator) { + case '=': + $result = $value == $this->value; + break; + case '!=': + $result = $value != $this->value; + break; + case '<': + $result = $value < $this->value; + break; + case '<=': + $result = $value <= $this->value; + break; + case '>=': + $result = $value >= $this->value; + break; + case '>': + $result = $value > $this->value; + break; + default: + throw new Exception('Unknown compare operator.'); + } + + if ($result) { + break; + } + } + return $result; + } + + /** + * Deletes the stored data for this condition field from DB. + */ + public function delete() + { + // Delete condition data. + $stmt = DBManager::get()->prepare("DELETE FROM `userfilter_fields` + WHERE `field_id`=?"); + $stmt->execute([$this->id]); + } + + /** + * Generate a new unique ID. + * + * @param String tableName + */ + public function generateId() + { + do { + $newid = md5(uniqid(get_class($this) . microtime(), true)); + $id = DBManager::get()->fetchColumn("SELECT `field_id` + FROM `userfilter_fields` WHERE `field_id`=?", [$newid]); + } while ($id); + return $newid; + } + + /** + * Reads all available UserFilterField subclasses and loads their definitions. + */ + public static function getAvailableFilterFields(string $context = '', string $target = '') + { + if (self::$available_filter_fields === null) { + $fields = []; + $i = new FileSystemIterator( + $GLOBALS['STUDIP_BASE_PATH'] . '/lib/classes/UserFilterFields' . ($context !== '' ? '/' . $context : ''), + FileSystemIterator::SKIP_DOTS + ); + + foreach ($i as $class) { + if ($class->isFile()) { + require_once $class; + } + } + + // Get all classes in given context. + $classes = array_filter( + get_declared_classes(), + 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()); + } else { + $filter = new $class(); + $fields[$class] = $filter->getName(); + } + } + self::$available_filter_fields = $fields; + } + return self::$available_filter_fields; + } + + + /** + * Which compare operator is set? + * + * @return String + */ + public function getCompareOperator() + { + return $this->compareOperator; + } + + /** + * Which compare operator is set? + * + * @return String + */ + public function getCompareOperatorAsText() + { + return $this->getValidCompareOperators()[$this->compareOperator] ?? ''; + } + + /** + * Field ID. + * + * @return String + */ + public function getId() + { + return $this->id; + } + + /** + * Get this field's display name. + * + * @return String + */ + public function getName() + { + return _("Nutzerfilterfeld"); + } + + /** + * Compares all the users' values by using the specified compare operator + * and returns all users that fulfill the condition. This can be + * an important information when checking on validity of a combination + * of conditions. + * + * @param Array $restrictions values from other fields that restrict the valid + * values for a user (e.g. a semester of study in + * a given subject) + * @return Array All users that are affected by the current condition + * field. + */ + public function getUsers($restrictions = []) + { + $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 . "?"; + $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']; + } + } + // Get all the users that fulfill the condition. + $stmt = $db->prepare($select . $from . $where); + $stmt->execute($parameters); + while ($current = $stmt->fetch(PDO::FETCH_ASSOC)) { + $users[] = $current['user_id']; + } + 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) + { + $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; + } + + /** + * Returns all valid compare operators. + * + * @return Array Array of valid compare operators. + */ + public function getValidCompareOperators() + { + return $this->validCompareOperators; + } + + /** + * Returns all valid values. Values can be loaded dynamically from + * database or be returned as static array. + * + * @return Array Valid values in the form $value => $displayname. + */ + public function getValidValues() + { + return $this->validValues; + } + + /** + * Which value is set? + * + * @return String + */ + public function getValue() + { + return $this->value; + } + + /** + * Helper function for loading data from DB. + */ + public function load() + { + $stmt = DBManager::get()->prepare( + "SELECT * FROM `userfilter_fields` WHERE `field_id`=? LIMIT 1"); + $stmt->execute([$this->id]); + if ($data = $stmt->fetch(PDO::FETCH_ASSOC)) { + $this->conditionId = $data['filter_id']; + $this->value = $data['value']; + $this->compareOperator = $data['compare_op']; + } + } + + /** + * Sets a new selected compare operator + * + * @param String newOperator + * @return UserFilterField + */ + public function setCompareOperator($newOperator) + { + if (in_array($newOperator, array_keys($this->validCompareOperators))) { + $this->compareOperator = $newOperator; + return $this; + } else { + return false; + } + } + + /** + * Connects the current field to a UserFilter. + * + * @param String $id ID of a UserFilter object. + * @return UserFilterField + */ + public function setConditionId($id) + { + $this->conditionId = $id; + return $this; + } + + /** + * Sets a new selected value. + * + * @param String newValue + * @return UserFilterField + */ + public function setValue($newValue) + { + if ($this->validValues[$newValue]) { + $this->value = $newValue; + return $this; + } else { + return false; + } + } + + /** + * Stores data to DB. + * + * @param String conditionId The condition this field belongs to. + */ + public function store() + { + // Generate new ID if field entry doesn't exist in DB yet. + if (!$this->id) { + $this->id = $this->generateId(); + } + // Store field data. + $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`), + `type`=VALUES(`type`),`value`=VALUES(`value`), + `compare_op`=VALUES(`compare_op`), `chdate`=VALUES(`chdate`)"); + $stmt->execute([$this->id, $this->conditionId, get_class($this), + $this->value, $this->compareOperator, time(), time()]); + } + + public function __clone() + { + $this->id = md5(uniqid(get_class($this))); + $this->conditionId = null; + } + +} /* end of class UserFilterField */ diff --git a/lib/classes/UserFilterFields/DatafieldCondition.php b/lib/classes/UserFilterFields/DatafieldCondition.php new file mode 100644 index 0000000..9e7a2c3 --- /dev/null +++ b/lib/classes/UserFilterFields/DatafieldCondition.php @@ -0,0 +1,166 @@ + + * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 + * @category Stud.IP + */ +namespace UserFilterFields; + +class DatafieldCondition extends \UserFilterField +{ + public static $isParameterized = true; + + public $datafield_id, $null_yields, $datafield_name; + + 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) { + $ret[__CLASS__ . '_' . $df->id] = utf8_encode(chr(160)) . _("Datenfeld") . ': ' . $df->name; + } + } catch (\PDOException $e) {} //migration 128 chokes on this... + return $ret; + } + /** + * @see UserFilterField::__construct + */ + public function __construct($typeparam, $fieldId = '') + { + $this->validCompareOperators = [ + '>=' => _('mindestens'), + '<=' => _('höchstens'), + '=' => _('ist'), + '!=' => _('ist nicht') + ]; + if ($fieldId) { + $this->id = $fieldId; + $this->load(); + } else { + $this->id = $this->generateId(); + $this->datafield_id = $typeparam; + } + + $df = \DataField::find($this->datafield_id); + if ($df) { + $this->datafield_name = $df->name; + } else { + throw new \UnexpectedValueException('datafield not found, id: ' . $typeparam); + } + $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) { + 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); + } else { + $this->null_yields = ''; + } + + } + + /** + * Get this field's display name. + * + * @return String + */ + public function getName() + { + return $this->datafield_name; + } + + public function getUsers($restrictions = []) + { + $db = \DBManager::get(); + // Standard query getting the values without respecting other values. + $select = "SELECT user_id FROM + auth_user_md5 LEFT JOIN + datafields_entries ON range_id = user_id AND datafield_id = ? + WHERE perms IN ('user','autor','tutor','dozent') AND IFNULL(content, ?) + " . $this->compareOperator . " ?"; + $users = $db->fetchFirst($select, [$this->datafield_id, $this->null_yields,$this->value]); + return $users; + } + + /** + * Gets the value for the given user that is relevant for this + * + * @param String $userId User to check. + * @param Array $additional additional conditions that are required for check. + * @return array The value(s) for this user. + */ + public function getUserValues($userId, $additional = null) + { + $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]; + } + + /** + * Helper function for loading data from DB. + */ + public function load() + { + $stmt = \DBManager::get()->prepare( + "SELECT * FROM `userfilter_fields` WHERE `field_id`=? LIMIT 1"); + $stmt->execute([$this->id]); + if ($data = $stmt->fetch(\PDO::FETCH_ASSOC)) { + $this->conditionId = $data['filter_id']; + $this->value = $data['value']; + $this->compareOperator = $data['compare_op']; + list(,$this->datafield_id) = explode('_', $data['type']); + } + } + + /** + * Sets a new selected value. + * + * @param String newValue + * @return UserFilterField + */ + public function setValue($newValue) + { + $this->value = $newValue; + return $this; + } + + /** + * Stores data to DB. + * + */ + public function store() + { + // Generate new ID if field entry doesn't exist in DB yet. + if (!$this->id) { + $this->id = $this->generateId(); + } + // Store field data. + $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`), + `type`=VALUES(`type`),`value`=VALUES(`value`), + `compare_op`=VALUES(`compare_op`), `chdate`=VALUES(`chdate`)"); + $stmt->execute([$this->id, $this->conditionId, get_class($this).'_'.$this->datafield_id, + $this->value, $this->compareOperator, time(), time()]); + } +} diff --git a/lib/classes/UserFilterFields/DegreeCondition.php b/lib/classes/UserFilterFields/DegreeCondition.php new file mode 100644 index 0000000..6b26fb0 --- /dev/null +++ b/lib/classes/UserFilterFields/DegreeCondition.php @@ -0,0 +1,54 @@ + + * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 + * @category Stud.IP + */ +namespace UserFilterFields; + +class DegreeCondition extends \UserFilterField +{ + // --- ATTRIBUTES --- + public $valuesDbTable = 'abschluss'; + public $valuesDbIdField = 'abschluss_id'; + public $valuesDbNameField = 'name'; + public $userDataDbTable = 'user_studiengang'; + public $userDataDbField = 'abschluss_id'; + + public static $sortOrder = 1; + + /** + * @see UserFilterField::__construct + */ + public function __construct($fieldId = '') + { + parent::__construct($fieldId); + $this->relations = [ + 'SubjectCondition' => [ + 'local_field' => 'fach_id', + 'foreign_field' => 'fach_id' + ] + ]; + } + + /** + * Get this field's display name. + * + * @return String + */ + public function getName() + { + return _('Abschluss'); + } + +} diff --git a/lib/classes/UserFilterFields/DomainCondition.php b/lib/classes/UserFilterFields/DomainCondition.php new file mode 100644 index 0000000..125a16b --- /dev/null +++ b/lib/classes/UserFilterFields/DomainCondition.php @@ -0,0 +1,45 @@ + + * @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 0000000..71640d4 --- /dev/null +++ b/lib/classes/UserFilterFields/MassMail/MassMailDegreeFilter.php @@ -0,0 +1,126 @@ +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 0000000..7d7a922 --- /dev/null +++ b/lib/classes/UserFilterFields/MassMail/MassMailDomainFilter.php @@ -0,0 +1,75 @@ + + * @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 0000000..089fa95 --- /dev/null +++ b/lib/classes/UserFilterFields/MassMail/MassMailGenderFilter.php @@ -0,0 +1,74 @@ +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 0000000..4b4d49d --- /dev/null +++ b/lib/classes/UserFilterFields/MassMail/MassMailInstituteFilter.php @@ -0,0 +1,140 @@ +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 0000000..eba3307 --- /dev/null +++ b/lib/classes/UserFilterFields/MassMail/MassMailPermissionFilter.php @@ -0,0 +1,111 @@ + + * @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 0000000..8b732d2 --- /dev/null +++ b/lib/classes/UserFilterFields/MassMail/MassMailSelfAssignedInstituteFilter.php @@ -0,0 +1,137 @@ +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 0000000..f92c220 --- /dev/null +++ b/lib/classes/UserFilterFields/MassMail/MassMailSemesterOfStudyFilter.php @@ -0,0 +1,75 @@ +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 0000000..61a04be --- /dev/null +++ b/lib/classes/UserFilterFields/MassMail/MassMailStatusgroupFilter.php @@ -0,0 +1,88 @@ +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 0000000..977b277 --- /dev/null +++ b/lib/classes/UserFilterFields/MassMail/MassMailSubjectFilter.php @@ -0,0 +1,125 @@ +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/UserFilterFields/PermissionCondition.php b/lib/classes/UserFilterFields/PermissionCondition.php new file mode 100644 index 0000000..10212c7 --- /dev/null +++ b/lib/classes/UserFilterFields/PermissionCondition.php @@ -0,0 +1,49 @@ + + * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 + * @category Stud.IP + */ +namespace UserFilterFields; + +class PermissionCondition extends \UserFilterField +{ + public static $sortOrder = 7; + + /** + * @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'); + } +} diff --git a/lib/classes/UserFilterFields/SemesterOfStudyCondition.php b/lib/classes/UserFilterFields/SemesterOfStudyCondition.php new file mode 100644 index 0000000..f66789f --- /dev/null +++ b/lib/classes/UserFilterFields/SemesterOfStudyCondition.php @@ -0,0 +1,84 @@ + + * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 + * @category Stud.IP + */ +namespace UserFilterFields; + +class SemesterOfStudyCondition extends \UserFilterField +{ + // --- ATTRIBUTES --- + public $valuesDbTable = 'user_studiengang'; + public $valuesDbIdField = 'semester'; + public $userDataDbTable = 'user_studiengang'; + public $userDataDbField = 'semester'; + + public static $sortOrder = 4; + + // --- OPERATIONS --- + + /** + * @see UserFilterField::__construct + */ + public function __construct($fieldId='') + { + parent::__construct($fieldId); + $this->validValues = []; + $this->relations = [ + 'DegreeCondition' => [ + 'local_field' => 'abschluss_id', + 'foreign_field' => 'abschluss_id' + ], + 'SubjectCondition' => [ + '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/StgteilVersionCondition.php b/lib/classes/UserFilterFields/StgteilVersionCondition.php new file mode 100644 index 0000000..59bb035 --- /dev/null +++ b/lib/classes/UserFilterFields/StgteilVersionCondition.php @@ -0,0 +1,86 @@ + + * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 + * @category Stud.IP + */ +namespace UserFilterFields; + +class StgteilVersionCondition extends \UserFilterField +{ + // --- ATTRIBUTES --- + public $valuesDbTable = 'mvv_stgteilversion'; + public $valuesDbIdField = 'version_id'; + public $valuesDbNameField = 'code'; + public $userDataDbTable = 'user_studiengang'; + public $userDataDbField = 'version_id'; + + public static $sortOrder = 5; + + public static $isParameterized = true; + + public static function getParameterizedTypes() + { + if (\Config::get()->DISPLAY_STGTEILVERSION_USERFILTER) { + $filter = new StgteilVersionCondition(); + $fields['StgteilVersionCondition'] = $filter->getName(); + return $fields; + } else { + return []; + } + } + + /** + * @see UserFilterField::__construct + */ + public function __construct($fieldId = '') + { + $this->validCompareOperators = [ + '=' => _('ist'), + '!=' => _('ist nicht') + ]; + if ($this->valuesDbNameField) { + // Get all available values from database. + $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)) { + $this->validValues[$current[$this->valuesDbIdField]] = $current[$this->valuesDbNameField]; + } + } + if ($fieldId) { + $this->id = $fieldId; + $this->load(); + } else { + $this->id = $this->generateId(); + } + + foreach ($this->validValues as $version_id => $name) { + $stgteilversion = \StgteilVersion::find($version_id); + $this->validValues[$version_id] = $stgteilversion->getDisplayName(); + } + } + + /** + * Get this field's display name. + * + * @return String + */ + public function getName() + { + return _('Studiengangteil-Version'); + } +} diff --git a/lib/classes/UserFilterFields/SubjectCondition.php b/lib/classes/UserFilterFields/SubjectCondition.php new file mode 100644 index 0000000..e9ac1a0 --- /dev/null +++ b/lib/classes/UserFilterFields/SubjectCondition.php @@ -0,0 +1,55 @@ + + * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 + * @category Stud.IP + */ +namespace UserFilterFields; + +class SubjectCondition extends \UserFilterField +{ + // --- ATTRIBUTES --- + public $valuesDbTable = 'fach'; + public $valuesDbIdField = 'fach_id'; + public $valuesDbNameField = 'name'; + public $userDataDbTable = 'user_studiengang'; + public $userDataDbField = 'fach_id'; + + public static $sortOrder = 2; + + // --- OPERATIONS --- + + /** + * @see UserFilterField::__construct + */ + public function __construct($fieldId = '') + { + parent::__construct($fieldId); + $this->relations = [ + 'DegreeCondition' => [ + 'local_field' => 'abschluss_id', + 'foreign_field' => 'abschluss_id' + ] + ]; + } + + /** + * Get this field's display name. + * + * @return String + */ + public function getName() + { + return _('Studienfach'); + } +} diff --git a/lib/classes/UserFilterFields/SubjectConditionAny.php b/lib/classes/UserFilterFields/SubjectConditionAny.php new file mode 100644 index 0000000..c99bcb8 --- /dev/null +++ b/lib/classes/UserFilterFields/SubjectConditionAny.php @@ -0,0 +1,50 @@ + + * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 + * @category Stud.IP + */ + +namespace UserFilterFields; + +class SubjectConditionAny extends \UserFilterField +{ + // --- ATTRIBUTES --- + public $userDataDbTable = 'user_studiengang'; + public $userDataDbField = 'fach_id'; + + public static $sortOrder = 3; + + // --- OPERATIONS --- + + /** + * @see UserFilterField::__construct + */ + public function __construct($fieldId = '') + { + parent::__construct($fieldId); + $this->validCompareOperators = [ + '!=' => ' ' + ]; + $this->validValues = ['' => ' ']; + } + + /** + * Get this field's display name. + * + * @return String + */ + public function getName() + { + return _('Alle Studienfächer'); + } +} diff --git a/lib/classes/UserFilterRange.php b/lib/classes/UserFilterRange.php new file mode 100644 index 0000000..a5a53e1 --- /dev/null +++ b/lib/classes/UserFilterRange.php @@ -0,0 +1,29 @@ + + * @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 e7f3dee..d93cfb0 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/admission/UserFilter.php b/lib/classes/admission/UserFilter.php deleted file mode 100644 index fd160d6..0000000 --- a/lib/classes/admission/UserFilter.php +++ /dev/null @@ -1,280 +0,0 @@ - - * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 - * @category Stud.IP - */ - -class UserFilter -{ - // --- ATTRIBUTES --- - - /** - * All condition fields that form this condition. - */ - public $fields = []; - - /** - * Unique identifier for this condition. - */ - public $id = ''; - - public $show_user_count = false; - - // --- OPERATIONS --- - - /** - * Standard constructor. - * - * @param String conditionId - * @return UserFilter - */ - public function __construct($conditionId='') - { - UserFilterField::getAvailableFilterFields(); - $this->id = $conditionId; - if ($conditionId) { - $this->load(); - } else { - $this->id = $this->generateId(); - } - return $this; - } - - /** - * Add a new condition field. - * - * @param ConditionField fieldId - * @return UserFilter - */ - public function addField($field) - { - $this->fields[$field->getId()] = $field; - $field->setConditionId($this->id); - return $this; - } - - /** - * Deletes the condition and all associated fields. - */ - public function delete() { - // Delete condition data. - $stmt = DBManager::get()->prepare("DELETE FROM `userfilter` - WHERE `filter_id`=?"); - $stmt->execute([$this->id]); - // Delete all defined condition fields. - foreach ($this->fields as $field) { - $field->delete(); - } - } - - /** - * Generate a new unique ID. - * - * @param String tableName - */ - public function generateId() { - do { - $newid = md5(uniqid(get_class($this).microtime(), true)); - $id = DBManager::get()->fetchColumn("SELECT `filter_id` - FROM `userfilter` WHERE `filter_id`=?", [$newid]); - } while ($id); - return $newid; - } - - /** - * Get all fields (without checking for validity according - * to the current time). - * - * @return Array - */ - public function getFields() - { - uasort($this->fields, function($a, $b) { - return $a->sortOrder - $b->sortOrder; - }); - return $this->fields; - } - - /** - * Get ID. - * - * @return String - */ - public function getId() - { - return $this->id; - } - - /** - * Gets all users that fulfill the current condition. - * - * @return Array - */ - public function getUsers() { - $users = null; - foreach ($this->fields as $field) { - // Check if restrictions for the field value must be taken into consideration. - $restrictions = []; - foreach ($field->relations as $className => $related) { - if ($other = $this->hasField($className)) { - if ($other->getValue()) { - $restrictions[$className] = [ - 'table' => $other->userDataDbTable, - 'field' => $other->userDataDbField, - 'compare' => $other->getCompareOperator(), - 'value' => $other->getValue() - ]; - } - } - } - $users = isset($users) ? array_intersect($users, $field->getUsers($restrictions)) : $field->getUsers($restrictions); - } - return (array) $users; - } - - /** - * Checks whether the current filter object contains a field - * of the given type. - * - * @param String $className the type to check for - * @return UserFilterField Return the found field or null if not applicable. - */ - public function hasField($className) { - foreach ($this->fields as $field) { - if ($field instanceof $className) { - return $field; - break; - } - } - return null; - } - - /** - * Is the current condition fulfilled (that means, are all - * required field values matched)? - * - * @return boolean - */ - public function isFulfilled($userId) - { - // Check all fields. - foreach ($this->fields as $field) { - if (!$field->checkValue($field->getUserValues($userId, $this->fields))) { - return false; - } - } - return true; - } - - /** - * Helper function for loading data from DB. - */ - 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']; - // Load the associated condition fields. - $stmt = DBManager::get()->prepare( - "SELECT `field_id`, `type` FROM `userfilter_fields` - WHERE `filter_id`=?"); - $stmt->execute([$this->id]); - while ($data = $stmt->fetch(PDO::FETCH_ASSOC)) { - /* - * Create instance of appropriate UserFilterField subclass. - * We just "try" here because the class definition could have - * 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']); - } - - $this->fields[$field->getId()] = $field; - //} catch (Exception $e) {} - } - } - } - - /** - * Removes the field with the given ID from the condition fields. - * - * @param String fieldId - * @return UserFilter - */ - public function removeField($fieldId) - { - unset($this->fields[$fieldId]); - return $this; - } - - /** - * Stores data to DB. - */ - public function store() { - // Generate new ID if condition entry doesn't exist in DB yet. - if (!$this->id) { - $this->id = $this->generateId(); - } - - // 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()]); - // 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))."')"); - // Store all fields. - foreach ($this->fields as $field) { - $field->store($this->id); - } - } - - public function toString() { - $tpl = $GLOBALS['template_factory']->open('userfilter/display'); - $tpl->set_attribute('filter', $this); - return $tpl->render(); - } - - public function __toString() { - return $this->toString(); - } - - public function __clone() - { - $this->id = md5(uniqid(get_class($this))); - $cloned_fields= []; - foreach ($this->fields as $field) { - $dolly = clone $field; - $dolly->conditionId = $this->id; - $cloned_fields[$dolly->id] = $dolly; - } - $this->fields = $cloned_fields; - } - -} /* end of class UserFilter */ - -?> diff --git a/lib/classes/admission/UserFilterField.php b/lib/classes/admission/UserFilterField.php deleted file mode 100644 index 2a34807..0000000 --- a/lib/classes/admission/UserFilterField.php +++ /dev/null @@ -1,478 +0,0 @@ - - * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 - * @category Stud.IP - */ - -class UserFilterField -{ - // --- ATTRIBUTES --- - - /** - * Which of the valid compare operators is currently chosen? - */ - public $compareOperator = ''; - - /** - * ID of the UserFilter this field belongs to. - */ - public $conditionId = ''; - - /** - * Unique ID for this condition field. - */ - public $id = ''; - - /** - * The set of valid compare operators. - */ - public $validCompareOperators = []; - - /** - * All valid values for this field. - */ - public $validValues = []; - - /** - * Which of the valid values is currently chosen? - */ - public $value = null; - - /* - * 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 $isParameterized = false; - - protected static $cached_valid_values; - protected static $available_filter_fields; - - /** - * Database tables and fields to get valid values and concrete user values - * from. - */ - public $valuesDbTable = ''; - public $valuesDbIdField = ''; - public $valuesDbNameField = ''; - public $userDataDbTable = ''; - public $userDataDbField = ''; - public $relations = []; - - // --- OPERATIONS --- - - public static function getParameterizedTypes() - { - - } - - - /** - * Standard constructor. - * - * @param String $fieldId If a fieldId is given, the corresponding data is - * loaded from database. - * - */ - public function __construct($fieldId = '') - { - $this->validCompareOperators = [ - '=' => _('ist'), - '!=' => _('ist nicht') - ]; - if ($this->valuesDbNameField) { - if (isset(self::$cached_valid_values[static::class])) { - $this->validValues = self::$cached_valid_values[static::class]; - } 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"); - while ($current = $stmt->fetch(PDO::FETCH_ASSOC)) { - $this->validValues[$current[$this->valuesDbIdField]] = $current[$this->valuesDbNameField]; - } - self::$cached_valid_values[static::class] = $this->validValues; - } - } - if ($fieldId) { - $this->id = $fieldId; - $this->load(); - } else { - $this->id = $this->generateId(); - } - } - - /** - * Checks whether the given value fits the configured condition. The - * value is compared to the currently selected value by using the - * currently selected compare operator. - * - * @param Array values - * @return Boolean - */ - public function checkValue($values) - { - // Validate compare operator - if (!isset($this->validCompareOperators[$this->compareOperator])) { - throw new Exception('Invalid compare operator'); - } - - $result = false; - foreach ($values as $value) { - switch ($this->compareOperator) { - case '=': - $result = $value == $this->value; - break; - case '!=': - $result = $value != $this->value; - break; - case '<': - $result = $value < $this->value; - break; - case '<=': - $result = $value <= $this->value; - break; - case '>=': - $result = $value >= $this->value; - break; - case '>': - $result = $value > $this->value; - break; - default: - throw new Exception('Unknown compare operator.'); - } - - if ($result) { - break; - } - } - return $result; - } - - /** - * Deletes the stored data for this condition field from DB. - */ - public function delete() - { - // Delete condition data. - $stmt = DBManager::get()->prepare("DELETE FROM `userfilter_fields` - WHERE `field_id`=?"); - $stmt->execute([$this->id]); - } - - /** - * Generate a new unique ID. - * - * @param String tableName - */ - public function generateId() - { - do { - $newid = md5(uniqid(get_class($this).microtime(), true)); - $id = DBManager::get()->fetchColumn("SELECT `field_id` - FROM `userfilter_fields` WHERE `field_id`=?", [$newid]); - } while ($id); - return $newid; - } - - /** - * Reads all available UserFilterField subclasses and loads their definitions. - */ - public static function getAvailableFilterFields() - { - if (self::$available_filter_fields === null) { - $fields = []; - $i = new FileSystemIterator( - $GLOBALS['STUDIP_BASE_PATH'] . '/lib/classes/admission/userfilter', - FileSystemIterator::SKIP_DOTS - ); - - foreach ($i as $class) { - require_once $class; - } - - $classes = array_filter( - get_declared_classes(), - fn($c) => is_subclass_of($c, UserFilterField::class) - ); - foreach ($classes as $class) { - if ($class::$isParameterized) { - $fields = array_merge($fields, $class::getParameterizedTypes()); - } else { - $filter = new $class(); - $fields[$class] = $filter->getName(); - } - } - asort($fields); - self::$available_filter_fields = $fields; - } - return self::$available_filter_fields; - } - - - /** - * Which compare operator is set? - * - * @return String - */ - public function getCompareOperator() - { - return $this->compareOperator; - } - - /** - * Which compare operator is set? - * - * @return String - */ - public function getCompareOperatorAsText() - { - return $this->getValidCompareOperators()[$this->compareOperator] ?? ''; - } - - /** - * Field ID. - * - * @return String - */ - public function getId() - { - return $this->id; - } - - /** - * Get this field's display name. - * - * @return String - */ - public function getName() - { - return _("Nutzerfilterfeld"); - } - - /** - * Compares all the users' values by using the specified compare operator - * and returns all users that fulfill the condition. This can be - * an important information when checking on validity of a combination - * of conditions. - * - * @param Array $restrictions values from other fields that restrict the valid - * values for a user (e.g. a semester of study in - * a given subject) - * @return Array All users that are affected by the current condition - * field. - */ - public function getUsers($restrictions = []) - { - $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."?"; - $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']; - } - } - // Get all the users that fulfill the condition. - $stmt = $db->prepare($select.$from.$where); - $stmt->execute($parameters); - while ($current = $stmt->fetch(PDO::FETCH_ASSOC)) { - $users[] = $current['user_id']; - } - 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) - { - $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; - } - - /** - * Returns all valid compare operators. - * - * @return Array Array of valid compare operators. - */ - public function getValidCompareOperators() - { - return $this->validCompareOperators; - } - - /** - * Returns all valid values. Values can be loaded dynamically from - * database or be returned as static array. - * - * @return Array Valid values in the form $value => $displayname. - */ - public function getValidValues() - { - return $this->validValues; - } - - /** - * Which value is set? - * - * @return String - */ - public function getValue() - { - return $this->value; - } - - /** - * Helper function for loading data from DB. - */ - public function load() - { - $stmt = DBManager::get()->prepare( - "SELECT * FROM `userfilter_fields` WHERE `field_id`=? LIMIT 1"); - $stmt->execute([$this->id]); - if ($data = $stmt->fetch(PDO::FETCH_ASSOC)) { - $this->conditionId = $data['filter_id']; - $this->value = $data['value']; - $this->compareOperator = $data['compare_op']; - } - } - - /** - * Sets a new selected compare operator - * - * @param String newOperator - * @return UserFilterField - */ - public function setCompareOperator($newOperator) - { - if (in_array($newOperator, array_keys($this->validCompareOperators))) { - $this->compareOperator = $newOperator; - return $this; - } else { - return false; - } - } - - /** - * Connects the current field to a UserFilter. - * - * @param String $id ID of a UserFilter object. - * @return UserFilterField - */ - public function setConditionId($id) - { - $this->conditionId = $id; - return $this; - } - - /** - * Sets a new selected value. - * - * @param String newValue - * @return UserFilterField - */ - public function setValue($newValue) - { - if ($this->validValues[$newValue]) { - $this->value = $newValue; - return $this; - } else { - return false; - } - } - - /** - * Stores data to DB. - * - * @param String conditionId The condition this field belongs to. - */ - public function store() - { - // Generate new ID if field entry doesn't exist in DB yet. - if (!$this->id) { - $this->id = $this->generateId(); - } - // Store field data. - $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`), - `type`=VALUES(`type`),`value`=VALUES(`value`), - `compare_op`=VALUES(`compare_op`), `chdate`=VALUES(`chdate`)"); - $stmt->execute([$this->id, $this->conditionId, get_class($this), - $this->value, $this->compareOperator, time(), time()]); - } - - public function __clone() - { - $this->id = md5(uniqid(get_class($this))); - $this->conditionId = null; - } - -} /* end of class UserFilterField */ diff --git a/lib/classes/admission/userfilter/DatafieldCondition.php b/lib/classes/admission/userfilter/DatafieldCondition.php deleted file mode 100644 index 1bc93e8..0000000 --- a/lib/classes/admission/userfilter/DatafieldCondition.php +++ /dev/null @@ -1,164 +0,0 @@ - - * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 - * @category Stud.IP - */ -class DatafieldCondition extends UserFilterField -{ - public static $isParameterized = true; - - public $datafield_id, $null_yields, $datafield_name; - - public $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) { - $ret[__CLASS__ . '_' . $df->id] = utf8_encode(chr(160)) . _("Datenfeld") . ': ' . $df->name; - } - } catch (PDOException $e) {} //migration 128 chokes on this... - return $ret; - } - /** - * @see UserFilterField::__construct - */ - public function __construct($typeparam, $fieldId = '') - { - $this->validCompareOperators = [ - '>=' => _('mindestens'), - '<=' => _('höchstens'), - '=' => _('ist'), - '!=' => _('ist nicht') - ]; - if ($fieldId) { - $this->id = $fieldId; - $this->load(); - } else { - $this->id = $this->generateId(); - $this->datafield_id = $typeparam; - } - - $df = DataField::find($this->datafield_id); - if ($df) { - $this->datafield_name = $df->name; - } else { - throw new UnexpectedValueException('datafield not found, id: ' . $typeparam); - } - $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) { - 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); - } else { - $this->null_yields = ''; - } - - } - - /** - * Get this field's display name. - * - * @return String - */ - public function getName() - { - return $this->datafield_name; - } - - public function getUsers($restrictions = []) - { - $db = DBManager::get(); - // Standard query getting the values without respecting other values. - $select = "SELECT user_id FROM - auth_user_md5 LEFT JOIN - datafields_entries ON range_id = user_id AND datafield_id = ? - WHERE perms IN ('user','autor','tutor','dozent') AND IFNULL(content, ?) - " . $this->compareOperator . " ?"; - $users = $db->fetchFirst($select, [$this->datafield_id, $this->null_yields,$this->value]); - return $users; - } - - /** - * Gets the value for the given user that is relevant for this - * - * @param String $userId User to check. - * @param Array $additional additional conditions that are required for check. - * @return array The value(s) for this user. - */ - public function getUserValues($userId, $additional = null) - { - $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]; - } - - /** - * Helper function for loading data from DB. - */ - public function load() - { - $stmt = DBManager::get()->prepare( - "SELECT * FROM `userfilter_fields` WHERE `field_id`=? LIMIT 1"); - $stmt->execute([$this->id]); - if ($data = $stmt->fetch(PDO::FETCH_ASSOC)) { - $this->conditionId = $data['filter_id']; - $this->value = $data['value']; - $this->compareOperator = $data['compare_op']; - list(,$this->datafield_id) = explode('_', $data['type']); - } - } - - /** - * Sets a new selected value. - * - * @param String newValue - * @return UserFilterField - */ - public function setValue($newValue) - { - $this->value = $newValue; - return $this; - } - - /** - * Stores data to DB. - * - */ - public function store() - { - // Generate new ID if field entry doesn't exist in DB yet. - if (!$this->id) { - $this->id = $this->generateId(); - } - // Store field data. - $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`), - `type`=VALUES(`type`),`value`=VALUES(`value`), - `compare_op`=VALUES(`compare_op`), `chdate`=VALUES(`chdate`)"); - $stmt->execute([$this->id, $this->conditionId, get_class($this).'_'.$this->datafield_id, - $this->value, $this->compareOperator, time(), time()]); - } -} diff --git a/lib/classes/admission/userfilter/DegreeCondition.php b/lib/classes/admission/userfilter/DegreeCondition.php deleted file mode 100644 index 61ce456..0000000 --- a/lib/classes/admission/userfilter/DegreeCondition.php +++ /dev/null @@ -1,51 +0,0 @@ - - * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 - * @category Stud.IP - */ -class DegreeCondition extends UserFilterField -{ - // --- ATTRIBUTES --- - public $valuesDbTable = 'abschluss'; - public $valuesDbIdField = 'abschluss_id'; - public $valuesDbNameField = 'name'; - public $userDataDbTable = 'user_studiengang'; - public $userDataDbField = 'abschluss_id'; - - public $sortOrder = 1; - - /** - * @see UserFilterField::__construct - */ - public function __construct($fieldId = '') - { - parent::__construct($fieldId); - $this->relations = [ - 'SubjectCondition' => [ - 'local_field' => 'fach_id', - 'foreign_field' => 'fach_id' - ] - ]; - } - - /** - * Get this field's display name. - * - * @return String - */ - public function getName() - { - return _('Abschluss'); - } - -} diff --git a/lib/classes/admission/userfilter/PermissionCondition.php b/lib/classes/admission/userfilter/PermissionCondition.php deleted file mode 100644 index fe9458c..0000000 --- a/lib/classes/admission/userfilter/PermissionCondition.php +++ /dev/null @@ -1,46 +0,0 @@ - - * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 - * @category Stud.IP - */ -class PermissionCondition extends UserFilterField -{ - public $sortOrder = 7; - - /** - * @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'); - } -} diff --git a/lib/classes/admission/userfilter/SemesterOfStudyCondition.php b/lib/classes/admission/userfilter/SemesterOfStudyCondition.php deleted file mode 100644 index 5794f75..0000000 --- a/lib/classes/admission/userfilter/SemesterOfStudyCondition.php +++ /dev/null @@ -1,81 +0,0 @@ - - * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 - * @category Stud.IP - */ -class SemesterOfStudyCondition extends UserFilterField -{ - // --- ATTRIBUTES --- - public $valuesDbTable = 'user_studiengang'; - public $valuesDbIdField = 'semester'; - public $userDataDbTable = 'user_studiengang'; - public $userDataDbField = 'semester'; - - public $sortOrder = 4; - - // --- OPERATIONS --- - - /** - * @see UserFilterField::__construct - */ - public function __construct($fieldId='') - { - parent::__construct($fieldId); - $this->validValues = []; - $this->relations = [ - 'DegreeCondition' => [ - 'local_field' => 'abschluss_id', - 'foreign_field' => 'abschluss_id' - ], - 'SubjectCondition' => [ - '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/admission/userfilter/StgteilVersionCondition.php b/lib/classes/admission/userfilter/StgteilVersionCondition.php deleted file mode 100644 index ec5c1f3..0000000 --- a/lib/classes/admission/userfilter/StgteilVersionCondition.php +++ /dev/null @@ -1,83 +0,0 @@ - - * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 - * @category Stud.IP - */ -class StgteilVersionCondition extends UserFilterField -{ - // --- ATTRIBUTES --- - public $valuesDbTable = 'mvv_stgteilversion'; - public $valuesDbIdField = 'version_id'; - public $valuesDbNameField = 'code'; - public $userDataDbTable = 'user_studiengang'; - public $userDataDbField = 'version_id'; - - public $sortOrder = 5; - - public static $isParameterized = true; - - public static function getParameterizedTypes() - { - if (Config::get()->DISPLAY_STGTEILVERSION_USERFILTER) { - $filter = new StgteilVersionCondition; - $fields['StgteilVersionCondition'] = $filter->getName(); - return $fields; - } else { - return []; - } - } - - /** - * @see UserFilterField::__construct - */ - public function __construct($fieldId = '') - { - $this->validCompareOperators = [ - '=' => _('ist'), - '!=' => _('ist nicht') - ]; - if ($this->valuesDbNameField) { - // Get all available values from database. - $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)) { - $this->validValues[$current[$this->valuesDbIdField]] = $current[$this->valuesDbNameField]; - } - } - if ($fieldId) { - $this->id = $fieldId; - $this->load(); - } else { - $this->id = $this->generateId(); - } - - foreach ($this->validValues as $version_id => $name) { - $stgteilversion = StgteilVersion::find($version_id); - $this->validValues[$version_id] = $stgteilversion->getDisplayName(); - } - } - - /** - * Get this field's display name. - * - * @return String - */ - public function getName() - { - return _('Studiengangteil-Version'); - } -} diff --git a/lib/classes/admission/userfilter/SubjectCondition.php b/lib/classes/admission/userfilter/SubjectCondition.php deleted file mode 100644 index 7aa5f26..0000000 --- a/lib/classes/admission/userfilter/SubjectCondition.php +++ /dev/null @@ -1,52 +0,0 @@ - - * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 - * @category Stud.IP - */ -class SubjectCondition extends UserFilterField -{ - // --- ATTRIBUTES --- - public $valuesDbTable = 'fach'; - public $valuesDbIdField = 'fach_id'; - public $valuesDbNameField = 'name'; - public $userDataDbTable = 'user_studiengang'; - public $userDataDbField = 'fach_id'; - - public $sortOrder = 2; - - // --- OPERATIONS --- - - /** - * @see UserFilterField::__construct - */ - public function __construct($fieldId = '') - { - parent::__construct($fieldId); - $this->relations = [ - 'DegreeCondition' => [ - 'local_field' => 'abschluss_id', - 'foreign_field' => 'abschluss_id' - ] - ]; - } - - /** - * Get this field's display name. - * - * @return String - */ - public function getName() - { - return _('Studienfach'); - } -} diff --git a/lib/classes/admission/userfilter/SubjectConditionAny.php b/lib/classes/admission/userfilter/SubjectConditionAny.php deleted file mode 100644 index 3a3712b..0000000 --- a/lib/classes/admission/userfilter/SubjectConditionAny.php +++ /dev/null @@ -1,50 +0,0 @@ - - * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 - * @category Stud.IP - */ - -require_once realpath(__DIR__ . '/..') . '/UserFilterField.php'; - -class SubjectConditionAny extends UserFilterField -{ - // --- ATTRIBUTES --- - public $userDataDbTable = 'user_studiengang'; - public $userDataDbField = 'fach_id'; - - public $sortOrder = 3; - - // --- OPERATIONS --- - - /** - * @see UserFilterField::__construct - */ - public function __construct($fieldId = '') - { - parent::__construct($fieldId); - $this->validCompareOperators = [ - '!=' => ' ' - ]; - $this->validValues = ['' => ' ']; - } - - /** - * Get this field's display name. - * - * @return String - */ - public function getName() - { - return _('Alle Studienfächer'); - } -} diff --git a/lib/classes/forms/CheckboxCollectionInput.php b/lib/classes/forms/CheckboxCollectionInput.php new file mode 100644 index 0000000..7859b9a --- /dev/null +++ b/lib/classes/forms/CheckboxCollectionInput.php @@ -0,0 +1,25 @@ +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 e7bced0..d1915bd 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 0000000..5862b3c --- /dev/null +++ b/lib/classes/forms/FileInput.php @@ -0,0 +1,23 @@ +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 0148c9c..f9b27cd 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 0000000..3cbc29a --- /dev/null +++ b/lib/classes/forms/QuicksearchListInput.php @@ -0,0 +1,19 @@ +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 0000000..3d4551f --- /dev/null +++ b/lib/classes/forms/SerialWysiwygInput.php @@ -0,0 +1,34 @@ +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 0000000..0b415b7 --- /dev/null +++ b/lib/classes/forms/UserFilterInput.php @@ -0,0 +1,60 @@ +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 0000000..f712f60 --- /dev/null +++ b/lib/cronjobs/send_massmails.php @@ -0,0 +1,107 @@ + + * @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 0000000..8d88c65 --- /dev/null +++ b/lib/models/MassMail/MassMailFilter.php @@ -0,0 +1,34 @@ +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 0000000..d3dca6e --- /dev/null +++ b/lib/models/MassMail/MassMailMarker.php @@ -0,0 +1,181 @@ +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 0000000..d2cd844 --- /dev/null +++ b/lib/models/MassMail/MassMailMessage.php @@ -0,0 +1,373 @@ + 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 0000000..33af43d --- /dev/null +++ b/lib/models/MassMail/MassMailPermission.php @@ -0,0 +1,139 @@ + \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 0000000..aacfe4b --- /dev/null +++ b/lib/models/MassMail/MassMailToken.php @@ -0,0 +1,25 @@ + 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 563b760..77405a0 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 ec18d59..567e9ff 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 f9e6741..20ca162 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 799c5f1..3b36cc8 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 0000000..b512c02 --- /dev/null +++ b/resources/vue/components/form_inputs/FileUpload.vue @@ -0,0 +1,198 @@ + + + + + diff --git a/resources/vue/components/form_inputs/QuicksearchListInput.vue b/resources/vue/components/form_inputs/QuicksearchListInput.vue new file mode 100644 index 0000000..4a3e21c --- /dev/null +++ b/resources/vue/components/form_inputs/QuicksearchListInput.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/resources/vue/components/form_inputs/SerialTextMarkers.vue b/resources/vue/components/form_inputs/SerialTextMarkers.vue new file mode 100644 index 0000000..20e542d --- /dev/null +++ b/resources/vue/components/form_inputs/SerialTextMarkers.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/resources/vue/components/form_inputs/UserFilterInput.vue b/resources/vue/components/form_inputs/UserFilterInput.vue new file mode 100644 index 0000000..d63e97b --- /dev/null +++ b/resources/vue/components/form_inputs/UserFilterInput.vue @@ -0,0 +1,146 @@ + + + + + diff --git a/resources/vue/components/massmail/MassMailMessagesList.vue b/resources/vue/components/massmail/MassMailMessagesList.vue new file mode 100644 index 0000000..9d71445 --- /dev/null +++ b/resources/vue/components/massmail/MassMailMessagesList.vue @@ -0,0 +1,153 @@ + + + diff --git a/resources/vue/components/massmail/MassMailPermissions.vue b/resources/vue/components/massmail/MassMailPermissions.vue new file mode 100644 index 0000000..6342427 --- /dev/null +++ b/resources/vue/components/massmail/MassMailPermissions.vue @@ -0,0 +1,108 @@ + + + diff --git a/templates/forms/checkbox_collection_input.php b/templates/forms/checkbox_collection_input.php new file mode 100644 index 0000000..9e6dec3 --- /dev/null +++ b/templates/forms/checkbox_collection_input.php @@ -0,0 +1,33 @@ + +