From d436a45e1576a0080d8c966529095f8d187a5941 Mon Sep 17 00:00:00 2001 From: Ron Lucke Date: Fri, 6 Jan 2023 10:19:34 +0000 Subject: Optimierung der Darstellungsstruktur mehrerer Lernmaterialien in Courseware Closes #1599 Merge request studip/studip!1201 --- app/controllers/contents/courseware.php | 223 +----- app/controllers/course/courseware.php | 293 ++------ app/controllers/courseware_controller.php | 76 ++ app/views/contents/courseware/bookmarks.php | 2 +- app/views/contents/courseware/courseware.php | 10 +- app/views/contents/courseware/index.php | 8 +- app/views/course/courseware/activities.php | 6 + app/views/course/courseware/courseware.php | 10 + app/views/course/courseware/dashboard.php | 12 - app/views/course/courseware/index.php | 7 +- app/views/course/courseware/tasks.php | 6 + db/migrations/5.3.16_create_cw_units_table.php | 52 ++ db/migrations/5.3.17_change_cw_config.php | 56 ++ lib/activities/CoursewareProvider.php | 417 +++++++---- lib/classes/JsonApi/RouteMap.php | 12 + .../JsonApi/Routes/Courseware/Authority.php | 79 ++- .../Routes/Courseware/CoursesUnitsIndex.php | 46 ++ .../Courseware/CoursewareInstancesHelper.php | 18 +- .../Courseware/CoursewareInstancesUpdate.php | 7 +- .../Routes/Courseware/StructuralElementsCopy.php | 7 + .../Routes/Courseware/StructuralElementsShow.php | 3 +- .../JsonApi/Routes/Courseware/UnitsCopy.php | 46 ++ .../JsonApi/Routes/Courseware/UnitsCreate.php | 122 ++++ .../JsonApi/Routes/Courseware/UnitsDelete.php | 34 + .../JsonApi/Routes/Courseware/UnitsIndex.php | 35 + .../JsonApi/Routes/Courseware/UnitsShow.php | 42 ++ .../JsonApi/Routes/Courseware/UnitsUpdate.php | 95 +++ .../Courseware/UserProgressesOfUnitsShow.php | 191 +++++ .../JsonApi/Routes/Courseware/UsersUnitsIndex.php | 46 ++ lib/classes/JsonApi/SchemaMap.php | 1 + .../JsonApi/Schemas/Courseware/Instance.php | 8 +- .../Schemas/Courseware/StructuralElement.php | 24 + lib/classes/JsonApi/Schemas/Courseware/Unit.php | 79 +++ lib/models/Courseware/Instance.php | 52 +- lib/models/Courseware/StructuralElement.php | 49 +- lib/models/Courseware/Unit.php | 112 +++ lib/modules/CoursewareModule.class.php | 38 +- lib/navigation/ContentsNavigation.php | 8 +- package-lock.json | 12 +- .../assets/javascripts/bootstrap/courseware.js | 35 +- resources/assets/stylesheets/scss/buttons.scss | 10 + resources/assets/stylesheets/scss/courseware.scss | 594 ++++++++-------- resources/assets/stylesheets/scss/dialog.scss | 111 +++ resources/assets/stylesheets/scss/wizard.scss | 214 ++++++ resources/assets/stylesheets/studip.scss | 7 +- resources/vue/components/StudipDialog.vue | 74 +- resources/vue/components/StudipWizardDialog.vue | 254 +++++++ .../vue/components/courseware/ActivitiesApp.vue | 31 + resources/vue/components/courseware/AdminApp.vue | 8 +- .../components/courseware/ContentOverviewApp.vue | 25 - .../courseware/CoursewareActionWidget.vue | 146 +--- .../components/courseware/CoursewareActivities.vue | 124 ++++ .../CoursewareActivitiesWidgetFilterType.vue | 61 ++ .../CoursewareActivitiesWidgetFilterUnit.vue | 58 ++ .../courseware/CoursewareActivityItem.vue | 66 +- .../courseware/CoursewareAdminActionWidget.vue | 18 +- .../courseware/CoursewareAdminTemplates.vue | 5 +- .../courseware/CoursewareAdminViewWidget.vue | 13 +- .../courseware/CoursewareBlockAdderArea.vue | 23 +- .../courseware/CoursewareBlockComments.vue | 10 +- .../components/courseware/CoursewareBlockEdit.vue | 11 +- .../courseware/CoursewareBlockFeedback.vue | 10 +- .../components/courseware/CoursewareBlockInfo.vue | 8 +- .../courseware/CoursewareBlockadderItem.vue | 11 +- .../courseware/CoursewareCanvasBlock.vue | 3 +- .../courseware/CoursewareCompanionBox.vue | 2 +- .../courseware/CoursewareCompanionOverlay.vue | 7 +- .../courseware/CoursewareConfirmBlock.vue | 3 +- .../CoursewareContentOverviewActionWidget.vue | 25 - .../CoursewareContentOverviewElements.vue | 622 ----------------- .../CoursewareContentOverviewFilterWidget.vue | 99 --- .../courseware/CoursewareCourseDashboard.vue | 93 --- .../courseware/CoursewareDashboardActivities.vue | 110 --- .../courseware/CoursewareDashboardProgress.vue | 113 --- .../courseware/CoursewareDashboardProgressItem.vue | 31 - .../courseware/CoursewareDashboardStudents.vue | 15 +- .../courseware/CoursewareDashboardViewWidget.vue | 56 -- .../courseware/CoursewareDefaultContainer.vue | 6 +- .../courseware/CoursewareDownloadBlock.vue | 3 +- .../courseware/CoursewareEmptyElementBox.vue | 25 +- .../courseware/CoursewareExportWidget.vue | 8 +- .../courseware/CoursewareHeadlineBlock.vue | 38 +- .../courseware/CoursewareImportWidget.vue | 42 ++ .../courseware/CoursewareKeyPointBlock.vue | 36 +- .../courseware/CoursewareManagerElement.vue | 4 +- .../courseware/CoursewareManagerFiling.vue | 15 +- .../vue/components/courseware/CoursewareRibbon.vue | 30 +- .../courseware/CoursewareRibbonToolbar.vue | 24 +- .../courseware/CoursewareSearchWidget.vue | 78 ++- .../courseware/CoursewareShelfActionWidget.vue | 30 + .../courseware/CoursewareShelfDialogAdd.vue | 277 ++++++++ .../courseware/CoursewareShelfDialogCopy.vue | 362 ++++++++++ .../courseware/CoursewareShelfDialogImport.vue | 371 ++++++++++ .../courseware/CoursewareShelfImportWidget.vue | 37 + .../courseware/CoursewareStructuralElement.vue | 274 +++----- .../CoursewareStructuralElementComments.vue | 10 +- .../CoursewareStructuralElementDialogCopy.vue | 466 +++++++++++++ .../CoursewareStructuralElementDialogImport.vue | 198 ++++++ .../CoursewareStructuralElementDialogLink.vue | 264 +++++++ .../CoursewareStructuralElementFeedback.vue | 10 +- .../courseware/CoursewareTableOfContentsBlock.vue | 2 +- .../courseware/CoursewareTasksActionWidget.vue | 31 + .../courseware/CoursewareTasksDialogDistribute.vue | 633 +++++++++++++++++ .../vue/components/courseware/CoursewareTile.vue | 137 ++++ .../courseware/CoursewareTimelineBlock.vue | 24 +- .../components/courseware/CoursewareToolsAdmin.vue | 272 -------- .../courseware/CoursewareToolsBlockadder.vue | 13 +- .../components/courseware/CoursewareUnitItem.vue | 184 +++++ .../courseware/CoursewareUnitItemDialogExport.vue | 132 ++++ .../CoursewareUnitItemDialogSettings.vue | 311 +++++++++ .../components/courseware/CoursewareUnitItems.vue | 77 +++ .../courseware/CoursewareUnitProgress.vue | 117 ++++ .../courseware/CoursewareUnitProgressItem.vue | 31 + .../components/courseware/CoursewareViewWidget.vue | 2 +- .../courseware/CoursewareWelcomeScreen.vue | 86 +++ .../courseware/CoursewareWellcomeScreen.vue | 81 --- .../vue/components/courseware/DashboardApp.vue | 36 - resources/vue/components/courseware/IndexApp.vue | 25 +- resources/vue/components/courseware/ShelfApp.vue | 65 ++ resources/vue/components/courseware/TasksApp.vue | 31 + resources/vue/courseware-activities-app.js | 93 +++ resources/vue/courseware-content-overview-app.js | 100 --- resources/vue/courseware-dashboard-app.js | 95 --- resources/vue/courseware-index-app.js | 14 +- resources/vue/courseware-shelf-app.js | 93 +++ resources/vue/courseware-tasks-app.js | 98 +++ resources/vue/mixins/courseware/colors.js | 149 ++++ resources/vue/mixins/courseware/export.js | 63 +- resources/vue/mixins/courseware/import.js | 45 +- .../courseware/courseware-activities.module.js | 49 ++ .../store/courseware/courseware-shelf.module.js | 766 +++++++++++++++++++++ .../store/courseware/courseware-tasks.module.js | 37 + .../vue/store/courseware/courseware.module.js | 95 ++- resources/vue/store/courseware/structure.module.js | 6 +- 134 files changed, 8600 insertions(+), 3451 deletions(-) create mode 100644 app/controllers/courseware_controller.php create mode 100644 app/views/course/courseware/activities.php create mode 100644 app/views/course/courseware/courseware.php delete mode 100644 app/views/course/courseware/dashboard.php create mode 100644 app/views/course/courseware/tasks.php create mode 100644 db/migrations/5.3.16_create_cw_units_table.php create mode 100644 db/migrations/5.3.17_change_cw_config.php create mode 100644 lib/classes/JsonApi/Routes/Courseware/CoursesUnitsIndex.php create mode 100644 lib/classes/JsonApi/Routes/Courseware/UnitsCopy.php create mode 100644 lib/classes/JsonApi/Routes/Courseware/UnitsCreate.php create mode 100644 lib/classes/JsonApi/Routes/Courseware/UnitsDelete.php create mode 100644 lib/classes/JsonApi/Routes/Courseware/UnitsIndex.php create mode 100644 lib/classes/JsonApi/Routes/Courseware/UnitsShow.php create mode 100644 lib/classes/JsonApi/Routes/Courseware/UnitsUpdate.php create mode 100644 lib/classes/JsonApi/Routes/Courseware/UserProgressesOfUnitsShow.php create mode 100644 lib/classes/JsonApi/Routes/Courseware/UsersUnitsIndex.php create mode 100644 lib/classes/JsonApi/Schemas/Courseware/Unit.php create mode 100644 lib/models/Courseware/Unit.php create mode 100644 resources/assets/stylesheets/scss/wizard.scss create mode 100644 resources/vue/components/StudipWizardDialog.vue create mode 100644 resources/vue/components/courseware/ActivitiesApp.vue delete mode 100644 resources/vue/components/courseware/ContentOverviewApp.vue create mode 100644 resources/vue/components/courseware/CoursewareActivities.vue create mode 100644 resources/vue/components/courseware/CoursewareActivitiesWidgetFilterType.vue create mode 100644 resources/vue/components/courseware/CoursewareActivitiesWidgetFilterUnit.vue delete mode 100644 resources/vue/components/courseware/CoursewareContentOverviewActionWidget.vue delete mode 100644 resources/vue/components/courseware/CoursewareContentOverviewElements.vue delete mode 100644 resources/vue/components/courseware/CoursewareContentOverviewFilterWidget.vue delete mode 100644 resources/vue/components/courseware/CoursewareCourseDashboard.vue delete mode 100644 resources/vue/components/courseware/CoursewareDashboardActivities.vue delete mode 100644 resources/vue/components/courseware/CoursewareDashboardProgress.vue delete mode 100644 resources/vue/components/courseware/CoursewareDashboardProgressItem.vue delete mode 100644 resources/vue/components/courseware/CoursewareDashboardViewWidget.vue create mode 100644 resources/vue/components/courseware/CoursewareImportWidget.vue create mode 100644 resources/vue/components/courseware/CoursewareShelfActionWidget.vue create mode 100644 resources/vue/components/courseware/CoursewareShelfDialogAdd.vue create mode 100644 resources/vue/components/courseware/CoursewareShelfDialogCopy.vue create mode 100644 resources/vue/components/courseware/CoursewareShelfDialogImport.vue create mode 100644 resources/vue/components/courseware/CoursewareShelfImportWidget.vue create mode 100644 resources/vue/components/courseware/CoursewareStructuralElementDialogCopy.vue create mode 100644 resources/vue/components/courseware/CoursewareStructuralElementDialogImport.vue create mode 100644 resources/vue/components/courseware/CoursewareStructuralElementDialogLink.vue create mode 100644 resources/vue/components/courseware/CoursewareTasksActionWidget.vue create mode 100644 resources/vue/components/courseware/CoursewareTasksDialogDistribute.vue create mode 100644 resources/vue/components/courseware/CoursewareTile.vue delete mode 100644 resources/vue/components/courseware/CoursewareToolsAdmin.vue create mode 100644 resources/vue/components/courseware/CoursewareUnitItem.vue create mode 100644 resources/vue/components/courseware/CoursewareUnitItemDialogExport.vue create mode 100644 resources/vue/components/courseware/CoursewareUnitItemDialogSettings.vue create mode 100644 resources/vue/components/courseware/CoursewareUnitItems.vue create mode 100644 resources/vue/components/courseware/CoursewareUnitProgress.vue create mode 100644 resources/vue/components/courseware/CoursewareUnitProgressItem.vue create mode 100644 resources/vue/components/courseware/CoursewareWelcomeScreen.vue delete mode 100644 resources/vue/components/courseware/CoursewareWellcomeScreen.vue delete mode 100644 resources/vue/components/courseware/DashboardApp.vue create mode 100644 resources/vue/components/courseware/ShelfApp.vue create mode 100644 resources/vue/components/courseware/TasksApp.vue create mode 100644 resources/vue/courseware-activities-app.js delete mode 100644 resources/vue/courseware-content-overview-app.js delete mode 100644 resources/vue/courseware-dashboard-app.js create mode 100644 resources/vue/courseware-shelf-app.js create mode 100644 resources/vue/courseware-tasks-app.js create mode 100644 resources/vue/mixins/courseware/colors.js create mode 100644 resources/vue/store/courseware/courseware-activities.module.js create mode 100644 resources/vue/store/courseware/courseware-shelf.module.js create mode 100644 resources/vue/store/courseware/courseware-tasks.module.js diff --git a/app/controllers/contents/courseware.php b/app/controllers/contents/courseware.php index a91569c..6ee2b78 100644 --- a/app/controllers/contents/courseware.php +++ b/app/controllers/contents/courseware.php @@ -1,8 +1,11 @@ user_id = $GLOBALS['user']->id; - $this->setOverviewSidebar(); - $this->courseware_root = \Courseware\StructuralElement::getCoursewareUser($this->user_id); - if (!$this->courseware_root) { - // create initial courseware dataset - $new = \Courseware\StructuralElement::createEmptyCourseware($this->user_id, 'user'); - $this->courseware_root = $new->getRoot(); - } - $this->licenses = $this->getLicences(); + $this->setShelfSidebar(); + + $this->licenses = $this->getLicenses(); } - private function setOverviewSidebar() + private function setShelfSidebar(): void { $sidebar = Sidebar::Get(); - $views = new TemplateWidget( - _('Aktionen'), - $this->get_template_factory()->open('contents/courseware/overview_action_widget') - ); - $sidebar->addWidget($views)->addLayoutCSSClass('courseware-overview-filter-widget'); - - $views = new TemplateWidget( - _('Filter'), - $this->get_template_factory()->open('contents/courseware/overview_filter_widget') - ); - $sidebar->addWidget($views)->addLayoutCSSClass('courseware-overview-filter-widget'); + $sidebar->addWidget(new VueWidget('courseware-action-widget')); + $sidebar->addWidget(new VueWidget('courseware-import-widget')); } /** @@ -69,90 +58,29 @@ class Contents_CoursewareController extends AuthenticatedController * @SuppressWarnings(PHPMD.Superglobals) * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ - public function courseware_action($action = false, $widgetId = null) + public function courseware_action($unit_id = null): void { global $perm, $user; - Navigation::activateItem('/contents/courseware/courseware'); $this->user_id = $user->id; - + /** @var array $last */ $last = UserConfig::get($this->user_id)->getValue('COURSEWARE_LAST_ELEMENT'); - if (!empty($last[$this->user_id])) { - $this->entry_element_id = $last['global']; - $struct = \Courseware\StructuralElement::findOneBySQL( - "id = ? AND range_id = ? AND range_type = 'user'", - [$this->entry_element_id, $this->user_id] - ); - } - - // load courseware for current user - if (!$this->entry_element_id || !$struct || !$struct->canRead($user)) { - - if (!$user->courseware) { - // create initial courseware dataset - $struct = StructuralElement::createEmptyCourseware($this->user_id, 'user'); - } - - $this->entry_element_id = $user->courseware->id; - } - - $last[$this->user_id] = $this->entry_element_id; - UserConfig::get($this->user_id)->store('COURSEWARE_LAST_ELEMENT', $last); + if ($unit_id === null) { + $this->redirectToFirstUnit('user', $this->user_id, $last); - $this->licenses = $this->getLicences(); - - $this->oer_enabled = Config::get()->OERCAMPUS_ENABLED && $perm->have_perm(Config::get()->OER_PUBLIC_STATUS); - - // Make sure struct has value., to evaluate the export (edit) capability. - if (!isset($struct)) { - $struct = \Courseware\StructuralElement::findOneBySQL( - "id = ? AND range_id = ? AND range_type = 'user'", - [$this->entry_element_id, $this->user_id] - ); + return; } - $this->setCoursewareSidebar(); - } - - private function setCoursewareSidebar() - { - $sidebar = \Sidebar::Get(); - $sidebar->addWidget(new VueWidget('courseware-action-widget')); - $views = new TemplateWidget( - _('Suche'), - $this->get_template_factory()->open('course/courseware/search_widget') - ); - $sidebar->addWidget($views)->addLayoutCSSClass('courseware-search-widget'); - - $sidebar->addWidget(new VueWidget('courseware-view-widget')); - $sidebar->addWidget(new VueWidget('courseware-export-widget')); - } - - private function getLicences() - { - $licenses = array(); - $sorm_licenses = License::findBySQL("1 ORDER BY name ASC"); - foreach($sorm_licenses as $license) { - array_push($licenses, $license->toArray()); + $this->entry_element_id = null; + $this->unit_id = null; + $unit = Unit::find($unit_id); + if (isset($unit)) { + $this->setEntryElement('user', $unit, $last, $this->user_id); + Navigation::activateItem('/contents/courseware/courseware'); + $this->licenses = $this->getLicenses(); + $this->setCoursewareSidebar(); } - return json_encode($licenses); - } - - /** - * displays the courseware manager - * - * @param string $action - * @param string $widgetId - * @SuppressWarnings(PHPMD.CamelCaseMethodName) - * @SuppressWarnings(PHPMD.Superglobals) - * @SuppressWarnings(PHPMD.UnusedFormalParameter) - */ - public function courseware_manager_action($action = false, $widgetId = null) - { - Navigation::activateItem('/contents/courseware/courseware_manager'); - - $this->user_id = $GLOBALS['user']->id; } /** @@ -165,7 +93,7 @@ class Contents_CoursewareController extends AuthenticatedController * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ - public function bookmarks_action() + public function bookmarks_action(): void { Navigation::activateItem('/contents/courseware/bookmarks'); $this->user_id = $GLOBALS['user']->id; @@ -180,13 +108,13 @@ class Contents_CoursewareController extends AuthenticatedController * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ - public function releases_action() + public function releases_action(): void { Navigation::activateItem('/contents/courseware/releases'); $this->user_id = $GLOBALS['user']->id; } - private function setBookmarkSidebar() + private function setBookmarkSidebar(): void { $sidebar = Sidebar::Get(); $views = new TemplateWidget( @@ -205,7 +133,7 @@ class Contents_CoursewareController extends AuthenticatedController * @SuppressWarnings(PHPMD.Superglobals) * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ - public function courses_overview_action($action = false, $widgetId = null) + public function courses_overview_action($action = false, $widgetId = null): void { Navigation::activateItem('/contents/courseware/courses_overview'); @@ -244,7 +172,7 @@ class Contents_CoursewareController extends AuthenticatedController * * @return array */ - private function getCoursewareCourses($sem_key) + private function getCoursewareCourses($sem_key): array { $this->current_semester = Semester::findCurrent(); @@ -326,7 +254,7 @@ class Contents_CoursewareController extends AuthenticatedController * @param string $course_id the course to check * @return boolean true if courseware is enabled, false otherwise */ - private function isCoursewareEnabled($course_id) + private function isCoursewareEnabled($course_id): bool { $studip_module = PluginManager::getInstance()->getPlugin('CoursewareModule'); @@ -338,7 +266,7 @@ class Contents_CoursewareController extends AuthenticatedController } - private function getProjects($purpose) + private function getProjects($purpose): array { $elements = StructuralElement::findProjects($this->user->id, $purpose); foreach($elements as &$element) { @@ -348,85 +276,8 @@ class Contents_CoursewareController extends AuthenticatedController return $elements; } - public function create_project_action($action = false, $widgetId = null) - { - PageLayout::setTitle(_('Neues Lernmaterial')); - - if (!Request::submitted('create_project')) { - return; - } - - CSRFProtection::verifyUnsafeRequest(); - $this->user_id = $GLOBALS['user']->id; - - $structural_element = new StructuralElement(); - $structural_element->title = Request::get('title'); - $structural_element->purpose = Request::get('project_type'); - $structural_element->owner_id = $this->user_id; - $structural_element->editor_id = $this->user_id; - $structural_element->release_date = ""; - $structural_element->withdraw_date = ""; - $structural_element->range_id = $this->user_id; - $structural_element->range_type = 'user'; - $structural_element->parent_id = StructuralElement::getCoursewareUser($this->user_id)->id; - $structural_element->payload = json_encode([ - 'description' => Request::get('description'), - 'color' => Request::get('color'), - 'required_time' => Request::get('required_time'), - 'license_type' => Request::get('license_type'), - 'difficulty_start' => Request::get('difficulty_start'), - 'difficulty_end' => Request::get('difficulty_end'), - ]); - $structural_element->store(); - - // set image - if ($_FILES['previewfile'] && $_FILES['previewfile']['name']) { - $coursewareInstance = new Courseware\Instance($structural_element); - $publicFolder = Courseware\Filesystem\PublicFolder::findOrCreateTopFolder($coursewareInstance); - $fileRef = $this->handleUpload($publicFolder, $structural_element); - $structural_element->image_id = $fileRef->id; - $structural_element->store(); - } - - $this->redirect('contents/courseware/index'); - } - - private function handleUpload(Courseware\Filesystem\PublicFolder $folder, StructuralElement $structuralElement) - { - $file = $_FILES['previewfile']; - $upload = [ - 'tmp_name' => [$file['tmp_name']], - 'name' => [$file['name']], - 'size' => [$file['size']], - 'type' => [$file['type']], - 'error' => [$file['error']] - ]; - - $uploaded = FileManager::handleFileUpload( - $upload, - $folder - ); - - if ($uploaded['error']) { - throw new RuntimeException(implode("\n", $uploaded['error'])); - } - - if (count($uploaded['files'])) { - return $uploaded['files'][0]; - } - - throw new RuntimeException('Could not create preview image.'); - } - - private function setProjectsSidebar($action) - { - $sidebar = Sidebar::Get(); - $actions = new ActionsWidget(); - $actions->addLink(_('Neues Lernmaterial anlegen'), $this->url_for('contents/courseware/create_project'), Icon::create('add', 'clickable'))->asDialog('size=700'); - $sidebar->addWidget($actions); - } - public function pdf_export_action($element_id, $with_children) + public function pdf_export_action($element_id, $with_children): void { $element = \Courseware\StructuralElement::findOneById($element_id); @@ -438,7 +289,7 @@ class Contents_CoursewareController extends AuthenticatedController * * @param string $entry_element_id the shared struct element id */ - public function shared_content_courseware_action($entry_element_id) + public function shared_content_courseware_action($entry_element_id): void { global $perm, $user; @@ -463,7 +314,7 @@ class Contents_CoursewareController extends AuthenticatedController $this->user_id = $struct->owner_id; - $this->licenses = $this->getLicences(); + $this->licenses = $this->getLicenses(); $this->oer_enabled = Config::get()->OERCAMPUS_ENABLED && $perm->have_perm(Config::get()->OER_PUBLIC_STATUS); diff --git a/app/controllers/course/courseware.php b/app/controllers/course/courseware.php index 3b4a42a..5577987 100644 --- a/app/controllers/course/courseware.php +++ b/app/controllers/course/courseware.php @@ -1,8 +1,9 @@ last_visitdate = object_get_visit(Context::getId(), $this->studip_module->getPluginId()); } - public function index_action() + public function index_action(): void { - /** @var array $last */ - $last = UserConfig::get($GLOBALS['user']->id)->getValue('COURSEWARE_LAST_ELEMENT'); - if (isset($last[Context::getId()])) { - $this->entry_element_id = $last[Context::getId()]; - /** @var ?StructuralElement $struct */ - $struct = StructuralElement::findOneBySQL("id = ? AND range_id = ? AND range_type = 'course'", [ - $this->entry_element_id, - Context::getId(), - ]); - } - - // load courseware for course - if (!$this->entry_element_id || !$struct || !$struct->canRead($GLOBALS['user'])) { - $course = Course::find(Context::getId()); + Navigation::activateItem('course/courseware/shelf'); + $this->licenses = $this->getLicenses(); + $this->setIndexSidebar(); + } - if (!$course->courseware) { - // create initial courseware dataset - $instance = StructuralElement::createEmptyCourseware(Context::getId(), 'course'); - $struct = $instance->getRoot(); - } + public function courseware_action($unit_id = null): void + { + global $perm, $user; - $this->entry_element_id = $course->courseware->id; - } + $this->user_id = $user->id; + /** @var array $last */ + $last = UserConfig::get($this->user_id)->getValue('COURSEWARE_LAST_ELEMENT'); - $last[Context::getId()] = $this->entry_element_id; - UserConfig::get($GLOBALS['user']->id)->store('COURSEWARE_LAST_ELEMENT', $last); + if ($unit_id === null) { + $this->redirectToFirstUnit('course', Context::getId(), $last); - Navigation::activateItem('course/courseware/content'); - $this->licenses = []; - $sorm_licenses = License::findBySQL('1 ORDER BY name ASC'); - foreach ($sorm_licenses as $license) { - array_push($this->licenses, $license->toArray()); + return; } - $this->licenses = json_encode($this->licenses); - // Make sure struct has value., to evaluate the export (edit) capability. - if (!isset($struct)) { - $struct = StructuralElement::findOneBySQL("id = ? AND range_id = ? AND range_type = 'course'", [ - $this->entry_element_id, - Context::getId(), - ]); + $this->entry_element_id = null; + $this->unit_id = null; + $unit = Unit::find($unit_id); + if (isset($unit)) { + $this->setEntryElement('course', $unit, $last, Context::getId()); + + Navigation::activateItem('course/courseware/unit'); + $this->licenses = $this->getLicenses(); + $this->setCoursewareSidebar(); } - $this->setIndexSidebar(); } - public function dashboard_action(): void + public function tasks_action(): void { global $perm, $user; $this->is_teacher = $perm->have_studip_perm('tutor', Context::getId(), $user->id); - $this->courseware_progress_data = $this->getProgressData($this->is_teacher); - $this->courseware_chapter_counter = $this->getChapterCounter($this->courseware_progress_data); - Navigation::activateItem('course/courseware/dashboard'); - $this->setDashboardSidebar(); + Navigation::activateItem('course/courseware/tasks'); + $this->setTasksSidebar(); } - public function manager_action(): void + public function activities_action(): void { - $courseId = Context::getId(); - $element = StructuralElement::getCoursewareCourse($courseId); - $instance = new Instance($element); - if (!$GLOBALS['perm']->have_studip_perm($instance->getEditingPermissionLevel(), $courseId)) { - $this->redirect('course/courseware/index'); - } else { - Navigation::activateItem('course/courseware/manager'); - } + global $perm, $user; + $this->is_teacher = $perm->have_studip_perm('tutor', Context::getId(), $user->id); + Navigation::activateItem('course/courseware/activities'); + $this->setActivitiesSidebar(); } - public function pdf_export_action($element_id, $with_children) + public function pdf_export_action($element_id, $with_children): void { $element = \Courseware\StructuralElement::findOneById($element_id); $user = User::find($GLOBALS['user']->id); @@ -111,205 +92,19 @@ class Course_CoursewareController extends AuthenticatedController { $sidebar = Sidebar::Get(); $sidebar->addWidget(new VueWidget('courseware-action-widget')); - - $views = new TemplateWidget( - _('Suche'), - $this->get_template_factory()->open('course/courseware/search_widget') - ); - $sidebar->addWidget($views)->addLayoutCSSClass('courseware-search-widget'); - - $sidebar->addWidget(new VueWidget('courseware-view-widget')); - $sidebar->addWidget(new VueWidget('courseware-export-widget')); + $sidebar->addWidget(new VueWidget('courseware-import-widget')); } - private function setDashboardSidebar(): void + private function setTasksSidebar(): void { $sidebar = Sidebar::Get(); - $views = new TemplateWidget( - _('Ansichten'), - $this->get_template_factory()->open('course/courseware/dashboard_view_widget') - ); - $sidebar->addWidget($views)->addLayoutCSSClass('courseware-dashboard-view-widget'); - } - - private function getProgressData(bool $showProgressForAllParticipants = false): iterable - { - /** @var ?\Course $course */ - $course = Context::get(); - if (!$course || !$course->courseware) { - return []; - } - - $instance = new Instance($course->courseware); - $user = \User::findCurrent(); - - $elements = $this->findElements($instance, $user); - $progress = $this->computeSelfProgresses($instance, $user, $elements, $showProgressForAllParticipants); - $progress = $this->computeCumulativeProgresses($instance, $elements, $progress); - - return $this->prepareProgressData($elements, $progress); - } - - private function findElements(Instance $instance, User $user): iterable - { - $elements = $instance->getRoot()->findDescendants($user); - $elements[] = $instance->getRoot(); - - return array_combine(array_column($elements, 'id'), $elements); - } - - private function computeChildrenOf(iterable &$elements): iterable - { - $childrenOf = []; - foreach ($elements as $elementId => $element) { - if ($element['parent_id']) { - if (!isset($childrenOf[$element['parent_id']])) { - $childrenOf[$element['parent_id']] = []; - } - $childrenOf[$element['parent_id']][] = $elementId; - } - } - - return $childrenOf; - } - - private function computeSelfProgresses( - Instance $instance, - User $user, - iterable &$elements, - bool $showProgressForAllParticipants - ): iterable { - $progress = []; - /** @var \Course $course */ - $course = $instance->getRange(); - $allBlockIds = $instance->findAllBlocksGroupedByStructuralElementId(function ($row) { - return $row['id']; - }); - $courseMemberIds = $showProgressForAllParticipants - ? array_column($course->getMembersWithStatus('autor'), 'user_id') - : [$user->getId()]; - - $sql = - 'SELECT block_id, COUNT(grade) as count, SUM(grade) as grade ' . - 'FROM cw_user_progresses ' . - 'WHERE block_id IN (?) AND user_id IN (?) ' . - 'GROUP BY block_id'; - $userProgresses = \DBManager::get()->fetchGrouped($sql, [$allBlockIds, $courseMemberIds]); - - foreach ($elements as $elementId => $element) { - $selfProgress = $this->getSelfProgresses($allBlockIds, $elementId, $userProgresses, $courseMemberIds); - $progress[$elementId] = [ - 'self' => $selfProgress['counter'] ? $selfProgress['progress'] / $selfProgress['counter'] : 1, - ]; - } - - return $progress; - } - - private function getSelfProgresses( - array &$allBlockIds, - string $elementId, - array &$userProgresses, - array &$courseMemberIds - ): array { - $blks = $allBlockIds[$elementId] ?: []; - if (!count($blks)) { - return [ - 'counter' => 0, - 'progress' => 1, - ]; - } - - $data = [ - 'counter' => count($blks), - 'progress' => 0, - ]; - - $usersCounter = count($courseMemberIds); - foreach ($blks as $blk) { - $progresses = $userProgresses[$blk]; - $usersProgress = $progresses['count'] ? (float) $progresses['grade'] : 0; - $data['progress'] += $usersCounter ? $usersProgress / $usersCounter : 0; - } - - return $data; - } - - private function computeCumulativeProgresses(Instance $instance, iterable &$elements, iterable &$progress): iterable - { - $childrenOf = $this->computeChildrenOf($elements); - - // compute `cumulative` of each element - $visitor = function (&$progress, $element) use (&$childrenOf, &$elements, &$visitor) { - $elementId = $element->getId(); - $numberOfNodes = 0; - $cumulative = 0; - - // visit children first - if (isset($childrenOf[$elementId])) { - foreach ($childrenOf[$elementId] as $childId) { - $visitor($progress, $elements[$childId]); - $numberOfNodes += $progress[$childId]['numberOfNodes']; - $cumulative += $progress[$childId]['cumulative']; - } - } - - $progress[$elementId]['cumulative'] = $cumulative + $progress[$elementId]['self']; - $progress[$elementId]['numberOfNodes'] = $numberOfNodes + 1; - - return $progress; - }; - - $visitor($progress, $instance->getRoot()); - - return $progress; - } - - private function prepareProgressData(iterable &$elements, iterable &$progress): iterable - { - $data = []; - foreach ($elements as $elementId => $element) { - $elementProgress = $progress[$elementId]; - $cumulative = $elementProgress['cumulative'] / $elementProgress['numberOfNodes']; - - $data[$elementId] = [ - 'id' => (int) $elementId, - 'parent_id' => (int) $element['parent_id'], - 'name' => $element['title'], - 'progress' => [ - 'cumulative' => round($cumulative, 2) * 100, - 'self' => round($elementProgress['self'], 2) * 100, - ], - ]; - } - - return $data; + $sidebar->addWidget(new VueWidget('courseware-action-widget')); } - private function getChapterCounter(array &$chapters): array + private function setActivitiesSidebar(): void { - $finished = 0; - $started = 0; - $ahead = 0; - - foreach ($chapters as $chapter) { - if ($chapter['parent_id'] != null) { - if ($chapter['progress']['self'] == 0) { - $ahead += 1; - } - if ($chapter['progress']['self'] > 0 && $chapter['progress']['self'] < 100) { - $started += 1; - } - if ($chapter['progress']['self'] == 100) { - $finished += 1; - } - } - } - - return [ - 'started' => $started, - 'finished' => $finished, - 'ahead' => $ahead, - ]; + $sidebar = Sidebar::Get(); + $sidebar->addWidget(new VueWidget('courseware-activities-widget-filter-type')); + $sidebar->addWidget(new VueWidget('courseware-activities-widget-filter-unit')); } } diff --git a/app/controllers/courseware_controller.php b/app/controllers/courseware_controller.php new file mode 100644 index 0000000..30fec90 --- /dev/null +++ b/app/controllers/courseware_controller.php @@ -0,0 +1,76 @@ +getLastElement($last, $context, $rangeId); + if ($last_element) { + $unit = $last_element->findUnit($last); + } else { + $unit = Unit::findOneBySql('range_id = ? ORDER BY mkdate ASC', [$rangeId]); + } + $this->redirect($path . '/courseware/courseware/' . $unit->id); + } + + public function setEntryElement(string $context, Unit $unit, array $last, string $rangeId): void + { + $this->unit_id = $unit->id; + $last_element = $this->getLastElement($last, $context, $rangeId); + if($last_element) { + $last_element_unit = $last_element->findUnit(); + } + if ($last_element_unit->id === $unit->id) { + $this->entry_element_id = $last_element->id; + } else { + $this->entry_element_id = $unit->structural_element_id; + } + if ($this->entry_element_id) { + $last_element_item = $context === 'user' ? 'global' : $rangeId; + $last[$last_element_item] = $this->entry_element_id; + UserConfig::get($GLOBALS['user']->id)->store('COURSEWARE_LAST_ELEMENT', $last); + } + } + + public function getLastElement(array $last, string $context, string $rangeId): ?StructuralElement + { + $last_element_item = $context === 'user' ? 'global' : $rangeId; + $last_element_id = $last[$last_element_item] ?? false; + + if ($last_element_id) { + return StructuralElement::findOneBySQL("id = ? AND range_id = ? AND range_type = ?", [ + $last_element_id, + $rangeId, + $context + ]); + } + + return null; + } + + public function getLicenses(): string + { + $licenses = License::findAndMapBySQL( + function (License $license) { + return $license->toArray(); + }, + '1 ORDER BY name ASC' + ); + + return json_encode($licenses); + } + + public function setCoursewareSidebar(): void + { + $sidebar = \Sidebar::Get(); + $sidebar->addWidget(new VueWidget('courseware-action-widget')); + $sidebar->addWidget(new VueWidget('courseware-search-widget')); + $sidebar->addWidget(new VueWidget('courseware-view-widget')); + $sidebar->addWidget(new VueWidget('courseware-import-widget')); + $sidebar->addWidget(new VueWidget('courseware-export-widget')); + } +} \ No newline at end of file diff --git a/app/views/contents/courseware/bookmarks.php b/app/views/contents/courseware/bookmarks.php index a080320..988c692 100644 --- a/app/views/contents/courseware/bookmarks.php +++ b/app/views/contents/courseware/bookmarks.php @@ -1,6 +1,6 @@
diff --git a/app/views/contents/courseware/courseware.php b/app/views/contents/courseware/courseware.php index b50a963..bba4f1c 100644 --- a/app/views/contents/courseware/courseware.php +++ b/app/views/contents/courseware/courseware.php @@ -1,8 +1,10 @@
diff --git a/app/views/contents/courseware/index.php b/app/views/contents/courseware/index.php index c0d761d..b05d731 100644 --- a/app/views/contents/courseware/index.php +++ b/app/views/contents/courseware/index.php @@ -1,10 +1,6 @@ -
-
+> \ No newline at end of file diff --git a/app/views/course/courseware/activities.php b/app/views/course/courseware/activities.php new file mode 100644 index 0000000..67726e0 --- /dev/null +++ b/app/views/course/courseware/activities.php @@ -0,0 +1,6 @@ +
+
diff --git a/app/views/course/courseware/courseware.php b/app/views/course/courseware/courseware.php new file mode 100644 index 0000000..68503b0 --- /dev/null +++ b/app/views/course/courseware/courseware.php @@ -0,0 +1,10 @@ +
+
\ No newline at end of file diff --git a/app/views/course/courseware/dashboard.php b/app/views/course/courseware/dashboard.php deleted file mode 100644 index 830cc90..0000000 --- a/app/views/course/courseware/dashboard.php +++ /dev/null @@ -1,12 +0,0 @@ - - -
-
diff --git a/app/views/course/courseware/index.php b/app/views/course/courseware/index.php index 518f765..81296cb 100644 --- a/app/views/course/courseware/index.php +++ b/app/views/course/courseware/index.php @@ -1,9 +1,6 @@
-
+> diff --git a/app/views/course/courseware/tasks.php b/app/views/course/courseware/tasks.php new file mode 100644 index 0000000..7ebd70a --- /dev/null +++ b/app/views/course/courseware/tasks.php @@ -0,0 +1,6 @@ +
+
diff --git a/db/migrations/5.3.16_create_cw_units_table.php b/db/migrations/5.3.16_create_cw_units_table.php new file mode 100644 index 0000000..7b9fac3 --- /dev/null +++ b/db/migrations/5.3.16_create_cw_units_table.php @@ -0,0 +1,52 @@ +exec($query); + + //get all courseware root nodes + $query = "SELECT * FROM `cw_structural_elements` WHERE `parent_id` IS NULL"; + $cw_root_nodes = $db->fetchAll($query); + + // create unit for each courseware root node + $insert = $db->prepare( + "INSERT INTO `cw_units` (`range_id`, `range_type`, `structural_element_id`, `content_type`, `public`, `creator_id`) + VALUES (?, ?, ?, 'courseware', true, ?)" + ); + foreach ($cw_root_nodes as $courseware) { + $insert->execute([$courseware['range_id'], $courseware['range_type'], $courseware['id'], $courseware['owner_id']]); + } + } + + public function down() + { + $db = \DBManager::get(); + $db->exec('DROP TABLE IF EXISTS `cw_units`'); + } +} diff --git a/db/migrations/5.3.17_change_cw_config.php b/db/migrations/5.3.17_change_cw_config.php new file mode 100644 index 0000000..949ec58 --- /dev/null +++ b/db/migrations/5.3.17_change_cw_config.php @@ -0,0 +1,56 @@ +exec($query); + + $query = "UPDATE `config` SET `value` = '{}', `type` = 'array' WHERE `config`.`field` = 'COURSEWARE_EDITING_PERMISSION'"; + $db->exec($query); + + + $update_permission = $db->prepare("UPDATE `config_values` SET `value` = ? WHERE `field` = 'COURSEWARE_EDITING_PERMISSION' AND `range_id` = ?"); + + $find_root = $db->prepare("SELECT * FROM `cw_structural_elements` WHERE `parent_id` IS NULL AND `range_id` = ? "); + + + // get all COURSEWARE_EDITING_PERMISSION + $stmt = $db->prepare("SELECT * FROM `config_values` WHERE `field` = 'COURSEWARE_EDITING_PERMISSION'"); + $stmt->execute(); + $cw_permissions = $stmt->fetchAll(); + + foreach ($cw_permissions as $permission) { + $find_root->execute([$permission['range_id']]); + $root = $find_root->fetchAll(); + $value = json_encode([$root[0]['id'] => $permission['value']], true); + $update_permission->execute([$value, $permission['range_id']]); + } + + $update_progression = $db->prepare("UPDATE `config_values` SET `value` = ? WHERE `field` = 'COURSEWARE_SEQUENTIAL_PROGRESSION' AND `range_id` = ?"); + + // get all COURSEWARE_SEQUENTIAL_PROGRESSION + $stmt = $db->prepare("SELECT * FROM `config_values` WHERE `field` = 'COURSEWARE_SEQUENTIAL_PROGRESSION'"); + $stmt->execute(); + $cw_progressions = $stmt->fetchAll(); + + foreach ($cw_progressions as $progression) { + $find_root->execute([$progression['range_id']]); + $root = $find_root->fetchAll(); + $value = json_encode([$root[0]['id'] => $progression['value']], true); + $update_progression->execute([$value, $progression['range_id']]); + } + } + + public function down() + { + $db = \DBManager::get(); + } +} diff --git a/lib/activities/CoursewareProvider.php b/lib/activities/CoursewareProvider.php index 335f7bb..f771094 100644 --- a/lib/activities/CoursewareProvider.php +++ b/lib/activities/CoursewareProvider.php @@ -58,104 +58,225 @@ class CoursewareProvider implements ActivityProvider */ public static function postActivity($event, $resource) { - $data = null; - switch ($event) { - case Block::class . 'DidCreate': - /** - * @var \Courseware\Block $resource - * @var \Courseware\StructuralElement $structuralElement - */ - $structuralElement = $resource->getStructuralElement(); - $data = [ - 'provider' => self::class, - 'context' => $structuralElement->range_type, - 'context_id' => $structuralElement->range_id, - 'content' => null, - 'actor_type' => 'user', - 'actor_id' => $resource->owner_id, - 'verb' => 'created', - 'object_id' => $structuralElement->id, - 'object_type' => 'courseware', - 'mkdate' => time(), - ]; - break; + $structuralElement = null; + $rangeType = null; + if ($resource instanceof StructuralElement) { + $structuralElement = $resource; + } + if ($resource instanceof Task || + $resource instanceof StructuralElementComment || + $resource instanceof StructuralElementFeedback + ) { + $structuralElement = $resource['structural_element']; + } + if ($resource instanceof Block || + $resource instanceof BlockComment || + $resource instanceof BlockFeedback || + $resource instanceof Container || + $resource instanceof TaskFeedback + ) { + $structuralElement = $resource->getStructuralElement(); + } - case Block::class . 'DidUpdate': - /** - * @var \Courseware\Block $resource - * @var \Courseware\StructuralElement $structuralElement - */ - $structuralElement = $resource->getStructuralElement(); - $payload = $resource->type->getPayload(); - if ( - (isset($payload['text']) && $payload['text'] != '') || - (isset($payload['content']) && $payload['content'] != '') - ) { + if ($structuralElement !== null) { + $rangeType = $structuralElement->range_type; + } + + if ($rangeType === 'courses' || $rangeType === 'course') { + $data = null; + switch ($event) { + case Block::class . 'DidCreate': + /** + * @var \Courseware\Block $resource + * @var \Courseware\StructuralElement $structuralElement + */ + $blockType = $resource->type; + $blockTitle = $blockType->getTitle() ?: $resource->getBlockType(); + $content = _('Ein Block vom Typ "%1$s" wurde auf der Seite "%2$s" eingefügt.'); + $content = sprintf($content, $blockTitle, $structuralElement->title); $data = [ 'provider' => self::class, 'context' => $structuralElement->range_type, 'context_id' => $structuralElement->range_id, - 'content' => null, + 'content' => $content, + 'actor_type' => 'user', + 'actor_id' => $resource->owner_id, + 'verb' => 'created', + 'object_id' => $structuralElement->id, + 'object_type' => 'courseware', + 'mkdate' => time(), + ]; + break; + + case Block::class . 'DidUpdate': + /** + * @var \Courseware\Block $resource + * @var \Courseware\StructuralElement $structuralElement + */ + if (!$resource->edit_blocker_id) { + $blockType = $resource->type; + $blockTitle = $blockType->getTitle() ?? $resource->getBlockType(); + $content = _('Ein Block vom Typ "%1$s" wurde auf der Seite "%2$s" verändert.'); + $content = sprintf($content, $blockTitle, $structuralElement->title); + $data = [ + 'provider' => self::class, + 'context' => $structuralElement->range_type, + 'context_id' => $structuralElement->range_id, + 'content' => $content, + 'actor_type' => 'user', + 'actor_id' => $resource->editor_id, + 'verb' => 'edited', + 'object_id' => $structuralElement->id, + 'object_type' => 'courseware', + 'mkdate' => time(), + ]; + } + break; + + case Block::class . 'DidDelete': + /** + * @var \Courseware\Block $resource + * @var \Courseware\StructuralElement $structuralElement + */ + $blockType = $resource->type; + $blockTitle = $blockType->getTitle() ?: $resource->getBlockType(); + $content = _('Ein Block vom Typ "%1$s" wurde auf der Seite "%2$s" gelöscht.'); + $content = sprintf($content, $blockTitle, $structuralElement->title); + $data = [ + 'provider' => self::class, + 'context' => $structuralElement->range_type, + 'context_id' => $structuralElement->range_id, + 'content' => $content, 'actor_type' => 'user', 'actor_id' => $resource->editor_id, - 'verb' => 'edited', + 'verb' => 'voided', + 'object_id' => $structuralElement->id, + 'object_type' => 'courseware', + 'mkdate' => time(), + ]; + break; + + case BlockComment::class . 'DidCreate': + /** + * @var \Courseware\BlockComment $resource + * @var \Courseware\StructuralElement $structuralElement + */ + $structuralElement = $resource->getStructuralElement(); + $data = [ + 'provider' => self::class, + 'context' => $structuralElement->range_type, + 'context_id' => $structuralElement->range_id, + 'content' => $resource->comment, + 'actor_type' => 'user', + 'actor_id' => $resource->user_id, + 'verb' => 'interacted', + 'object_id' => $structuralElement->id, + 'object_type' => 'courseware', + 'mkdate' => time(), + ]; + break; + + case BlockFeedback::class . 'DidCreate': + /** + * @var \Courseware\BlockFeedback $resource + * @var \Courseware\StructuralElement $structuralElement + */ + $data = [ + 'provider' => self::class, + 'context' => $structuralElement->range_type, + 'context_id' => $structuralElement->range_id, + 'content' => $resource->feedback, + 'actor_type' => 'user', + 'actor_id' => $resource->user_id, + 'verb' => 'answered', + 'object_id' => $structuralElement->id, + 'object_type' => 'courseware', + 'mkdate' => time(), + ]; + break; + + case Container::class . 'DidCreate': + /** + * @var \Courseware\Container $resource + * @var \Courseware\StructuralElement $structuralElement + */ + $containerType = $resource->type; + $containerTitle = $containerType->getTitle() ?: _('unbekannt'); + $content = _('Ein Abschnitt vom Typ "%1$s" wurde auf der Seite "%2$s" eingefügt.'); + $content = sprintf($content, $containerTitle, $structuralElement->title); + $data = [ + 'provider' => self::class, + 'context' => $structuralElement->range_type, + 'context_id' => $structuralElement->range_id, + 'content' => $content, + 'actor_type' => 'user', + 'actor_id' => $resource->owner_id, + 'verb' => 'created', 'object_id' => $structuralElement->id, 'object_type' => 'courseware', 'mkdate' => time(), ]; - } - break; + break; - case BlockComment::class . 'DidCreate': - /** - * @var \Courseware\BlockComment $resource - * @var \Courseware\StructuralElement $structuralElement - */ - $structuralElement = $resource->getStructuralElement(); - $data = [ - 'provider' => self::class, - 'context' => $structuralElement->range_type, - 'context_id' => $structuralElement->range_id, - 'content' => $resource->comment, - 'actor_type' => 'user', - 'actor_id' => $resource->user_id, - 'verb' => 'interacted', - 'object_id' => $structuralElement->id, - 'object_type' => 'courseware', - 'mkdate' => time(), - ]; - break; + case Container::class . 'DidUpdate': + /** + * @var \Courseware\Block $resource + * @var \Courseware\StructuralElement $structuralElement + */ + if (!$resource->edit_blocker_id) { + $containerType = $resource->type; + $containerTitle = $containerType->getTitle() ?: _('unbekannt'); + $content = _('Ein Abschnitt vom Typ "%1$s" wurde auf der Seite "%2$s" verändert.'); + $content = sprintf($content, $containerTitle, $structuralElement->title); + $data = [ + 'provider' => self::class, + 'context' => $structuralElement->range_type, + 'context_id' => $structuralElement->range_id, + 'content' => $content, + 'actor_type' => 'user', + 'actor_id' => $resource->editor_id, + 'verb' => 'edited', + 'object_id' => $structuralElement->id, + 'object_type' => 'courseware', + 'mkdate' => time(), + ]; + } + break; - case BlockFeedback::class . 'DidCreate': - /** - * @var \Courseware\BlockFeedback $resource - * @var \Courseware\StructuralElement $structuralElement - */ - $structuralElement = $resource->getStructuralElement(); - $data = [ - 'provider' => self::class, - 'context' => $structuralElement->range_type, - 'context_id' => $structuralElement->range_id, - 'content' => $resource->feedback, - 'actor_type' => 'user', - 'actor_id' => $resource->user_id, - 'verb' => 'answered', - 'object_id' => $structuralElement->id, - 'object_type' => 'courseware', - 'mkdate' => time(), - ]; - break; + case Container::class . 'DidDelete': + /** + * @var \Courseware\Container $resource + * @var \Courseware\StructuralElement $structuralElement + */ + $containerType = $resource->type; + $containerTitle = $containerType->getTitle() ?: _('unbekannt'); + $content = _('Ein Abschnitt vom Typ "%1$s" wurde auf der Seite "%2$s" gelöscht.'); + $content = sprintf($content, $containerTitle, $structuralElement->title); + $data = [ + 'provider' => self::class, + 'context' => $structuralElement->range_type, + 'context_id' => $structuralElement->range_id, + 'content' => $content, + 'actor_type' => 'user', + 'actor_id' => $resource->editor_id, + 'verb' => 'voided', + 'object_id' => $structuralElement->id, + 'object_type' => 'courseware', + 'mkdate' => time(), + ]; + break; - case StructuralElement::class . 'DidCreate': - /** - * @var \Courseware\StructuralElement $resource - */ - if ($resource->range_type === 'courses') { + case StructuralElement::class . 'DidCreate': + /** + * @var \Courseware\StructuralElement $resource + */ + $content = _('Eine Seite mit dem Titel "%s" wurde angelegt.'); + $content = sprintf($content, $structuralElement->title); $data = [ 'provider' => self::class, 'context' => $resource->range_type, 'context_id' => $resource->range_id, - 'content' => null, + 'content' => $content, 'actor_type' => 'user', 'actor_id' => $resource->owner_id, 'verb' => 'created', @@ -163,56 +284,70 @@ class CoursewareProvider implements ActivityProvider 'object_type' => 'courseware', 'mkdate' => time(), ]; - } - break; + break; + case StructuralElement::class . 'DidDelete': + /** + * @var \Courseware\StructuralElement $resource + */ + $content = _('Eine Seite mit dem Titel "%s" wurde gelöscht.'); + $content = sprintf($content, $structuralElement->title); + $data = [ + 'provider' => self::class, + 'context' => $resource->range_type, + 'context_id' => $resource->range_id, + 'content' => null, + 'actor_type' => 'user', + 'actor_id' => $resource->owner_id, + 'verb' => 'voided', + 'object_id' => $resource->id, + 'object_type' => 'courseware', + 'mkdate' => time(), + ]; + break; - case StructuralElementComment::class . 'DidCreate': - /** - * @var \Courseware\StructuralElementComment $resource - * @var \Courseware\StructuralElement $structuralElement - */ - $structuralElement = $resource['structural_element']; - $data = [ - 'provider' => self::class, - 'context' => $structuralElement->range_type, - 'context_id' => $structuralElement->range_id, - 'content' => $resource->comment, - 'actor_type' => 'user', - 'actor_id' => $resource->user_id, - 'verb' => 'interacted', - 'object_id' => $structuralElement->id, - 'object_type' => 'courseware', - 'mkdate' => time(), - ]; - break; + case StructuralElementComment::class . 'DidCreate': + /** + * @var \Courseware\StructuralElementComment $resource + * @var \Courseware\StructuralElement $structuralElement + */ + $data = [ + 'provider' => self::class, + 'context' => $structuralElement->range_type, + 'context_id' => $structuralElement->range_id, + 'content' => $resource->comment, + 'actor_type' => 'user', + 'actor_id' => $resource->user_id, + 'verb' => 'interacted', + 'object_id' => $structuralElement->id, + 'object_type' => 'courseware', + 'mkdate' => time(), + ]; + break; - case StructuralElementFeedback::class . 'DidCreate': - /** - * @var \Courseware\StructuralElementFeedback $resource - * @var \Courseware\StructuralElement $structuralElement - */ - $structuralElement = $resource['structural_element']; - $data = [ - 'provider' => self::class, - 'context' => $structuralElement->range_type, - 'context_id' => $structuralElement->range_id, - 'content' => $resource->feedback, - 'actor_type' => 'user', - 'actor_id' => $resource->user_id, - 'verb' => 'answered', - 'object_id' => $structuralElement->id, - 'object_type' => 'courseware', - 'mkdate' => time(), - ]; - break; + case StructuralElementFeedback::class . 'DidCreate': + /** + * @var \Courseware\StructuralElementFeedback $resource + * @var \Courseware\StructuralElement $structuralElement + */ + $data = [ + 'provider' => self::class, + 'context' => $structuralElement->range_type, + 'context_id' => $structuralElement->range_id, + 'content' => $resource->feedback, + 'actor_type' => 'user', + 'actor_id' => $resource->user_id, + 'verb' => 'answered', + 'object_id' => $structuralElement->id, + 'object_type' => 'courseware', + 'mkdate' => time(), + ]; + break; - case Task::class . 'DidCreate': - /** - * @var \Courseware\Task $resource - * @var \Courseware\StructuralElement $structuralElement - */ - $structuralElement = $resource['structural_element']; - if ($structuralElement->range_type === 'courses') { + case Task::class . 'DidCreate': + /** + * @var \Courseware\Task $resource + * @var \Courseware\StructuralElement $structuralElement + */ $data = [ 'provider' => self::class, 'context' => $structuralElement->range_type, @@ -220,21 +355,18 @@ class CoursewareProvider implements ActivityProvider 'content' => null, 'actor_type' => 'user', 'actor_id' => $resource->task_group->lecturer_id, - 'verb' => 'set', + 'verb' => 'created', 'object_id' => $structuralElement->id, 'object_type' => 'courseware', 'mkdate' => time(), ]; - } - break; + break; - case TaskFeedback::class . 'DidCreate': - /** - * @var \Courseware\TaskFeedback $resource - * @var \Courseware\StructuralElement $structuralElement - */ - $structuralElement = $resource->getStructuralElement(); - if ($structuralElement->range_type === 'courses') { + case TaskFeedback::class . 'DidCreate': + /** + * @var \Courseware\TaskFeedback $resource + * @var \Courseware\StructuralElement $structuralElement + */ $data = [ 'provider' => self::class, 'context' => $structuralElement->range_type, @@ -247,12 +379,11 @@ class CoursewareProvider implements ActivityProvider 'object_type' => 'courseware', 'mkdate' => time(), ]; - } - break; - } - - if ($data) { - Activity::create($data); + break; + } + if ($data) { + Activity::create($data); + } } } } diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php index 312a90a..0709ef3 100644 --- a/lib/classes/JsonApi/RouteMap.php +++ b/lib/classes/JsonApi/RouteMap.php @@ -430,6 +430,8 @@ class RouteMap $group->get('/courseware-blocks/{id}/user-progress', Routes\Courseware\UserProgressOfBlocksShow::class); $group->get('/courseware-user-progresses/{id}', Routes\Courseware\UserProgressesShow::class); + // not a JSON route + $group->get('/courseware-units/{id}/courseware-user-progresses', Routes\Courseware\UserProgressesOfUnitsShow::class); $group->patch('/courseware-user-progresses/{id}', Routes\Courseware\UserProgressesUpdate::class); $group->get('/courseware-blocks/{id}/comments', Routes\Courseware\BlockCommentsOfBlocksIndex::class); @@ -468,6 +470,15 @@ class RouteMap $group->post('/courseware-public-links', Routes\Courseware\PublicLinksCreate::class); $group->patch('/courseware-public-links/{id}', Routes\Courseware\PublicLinksUpdate::class); $group->delete('/courseware-public-links/{id}', Routes\Courseware\PublicLinksDelete::class); + + $group->get('/courses/{id}/courseware-units', Routes\Courseware\CoursesUnitsIndex::class); + $group->get('/users/{id}/courseware-units', Routes\Courseware\UsersUnitsIndex::class); + $group->get('/courseware-units/{id}', Routes\Courseware\UnitsShow::class); + $group->post('/courseware-units', Routes\Courseware\UnitsCreate::class); + $group->patch('/courseware-units/{id}', Routes\Courseware\UnitsUpdate::class); + $group->delete('/courseware-units/{id}', Routes\Courseware\UnitsDelete::class); + // not a JSON route + $group->post('/courseware-units/{id}/copy', Routes\Courseware\UnitsCopy::class); } private function addAuthenticatedFilesRoutes(RouteCollectorProxy $group): void @@ -550,3 +561,4 @@ class RouteMap $group->map(['GET', 'PATCH', 'POST', 'DELETE'], $url, $handler); } } + diff --git a/lib/classes/JsonApi/Routes/Courseware/Authority.php b/lib/classes/JsonApi/Routes/Courseware/Authority.php index 0331be7..1c7c903 100644 --- a/lib/classes/JsonApi/Routes/Courseware/Authority.php +++ b/lib/classes/JsonApi/Routes/Courseware/Authority.php @@ -14,10 +14,12 @@ use Courseware\Task; use Courseware\TaskFeedback; use Courseware\TaskGroup; use Courseware\Template; +use Courseware\Unit; use Courseware\UserDataField; use Courseware\UserProgress; use Courseware\PublicLink; use User; +use Course; /** * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) @@ -69,11 +71,7 @@ class Authority return $structural_element->canEdit($user); } - $perm = $GLOBALS['perm']->have_studip_perm( - $structural_element->course->config->COURSEWARE_EDITING_PERMISSION, - $structural_element->course->id, - $user->id - ); + $perm = $structural_element->hasEditingPermission($user); return $resource->getBlockerUserId() === $user->id || $perm; } @@ -111,11 +109,7 @@ class Authority return $structural_element->canEdit($user); } - $perm = $GLOBALS['perm']->have_studip_perm( - $structural_element->course->config->COURSEWARE_EDITING_PERMISSION, - $structural_element->course->id, - $user->id - ); + $perm = $structural_element->hasEditingPermission($user); return $resource->edit_blocker_id == '' || $resource->edit_blocker_id === $user->id || $perm; } @@ -262,15 +256,7 @@ class Authority public static function canUpdateBlockComment(User $user, BlockComment $resource) { - if ($resource->block->container->structural_element->range_type === 'user') { - return $resource->block->container->structural_element->range_id === $user->id; - } - - $perm = $GLOBALS['perm']->have_studip_perm( - $resource->block->container->structural_element->course->config->COURSEWARE_EDITING_PERMISSION, - $resource->block->container->structural_element->course->id, - $user->id - ); + $perm = $resource->block->container->structural_element->hasEditingPermission($user); return $user->id === $resource->user_id || $perm; } @@ -387,15 +373,7 @@ class Authority return true; } - if ($resource->structural_element->range_type === 'user') { - return $resource->structural_element->range_id === $user->id; - } - - $perm = $GLOBALS['perm']->have_studip_perm( - $resource->structural_element->course->config->COURSEWARE_EDITING_PERMISSION, - $resource->structural_element->course->id, - $user->id - ); + $perm = $resource->structural_element->hasEditingPermission($user); return $user->id == $resource->user_id || $perm; } @@ -416,15 +394,7 @@ class Authority return true; } - if ($resource->range_type === 'user') { - return $resource->range_id === $user->id; - } - - $perm = $GLOBALS['perm']->have_studip_perm( - $resource->course->config->COURSEWARE_EDITING_PERMISSION, - $resource->course->id, - $user->id - ); + $perm = $resource->hasEditingPermission($user); return $perm; } @@ -504,4 +474,39 @@ class Authority return (bool) $publicLink; } + public static function canShowUnit(User $user, Unit $resource): bool + { + return $resource->canRead($user); + } + + public static function canIndexUnits(User $user): bool + { + return $GLOBALS['perm']->have_perm('root', $user->id); + } + + public static function canCreateUnit(User $user): bool + { + return $GLOBALS['perm']->have_perm('tutor', $user->id); + } + + public static function canUpdateUnit(User $user, Unit $resource): bool + { + return $resource->canEdit($user); + } + + public static function canDeleteUnit(User $user, Unit $resource): bool + { + return self::canUpdateUnit($user, $resource); + } + + public static function canIndexUnitsOfACourse(User $user, Course $course): bool + { + return $GLOBALS['perm']->have_studip_perm('user', $course->id, $user->id); + } + + public static function canIndexUnitsOfAUser(User $request_user, User $user): bool + { + return $request_user->id === $user->id; + } + } diff --git a/lib/classes/JsonApi/Routes/Courseware/CoursesUnitsIndex.php b/lib/classes/JsonApi/Routes/Courseware/CoursesUnitsIndex.php new file mode 100644 index 0000000..dde67bc --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/CoursesUnitsIndex.php @@ -0,0 +1,46 @@ +getUser($request); + if (!Authority::canIndexUnitsOfACourse($user, $course)) { + throw new AuthorizationFailedException(); + } + + $resources = Unit::findCoursesUnits($course); + $total = count($resources); + [$offset, $limit] = $this->getOffsetAndLimit(); + + return $this->getPaginatedContentResponse(array_slice($resources, $offset, $limit), $total); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/CoursewareInstancesHelper.php b/lib/classes/JsonApi/Routes/Courseware/CoursewareInstancesHelper.php index 46a2e68..a120f63 100644 --- a/lib/classes/JsonApi/Routes/Courseware/CoursewareInstancesHelper.php +++ b/lib/classes/JsonApi/Routes/Courseware/CoursewareInstancesHelper.php @@ -4,6 +4,7 @@ namespace JsonApi\Routes\Courseware; use Courseware\Instance; use Courseware\StructuralElement; +use Courseware\Unit; use JsonApi\Errors\BadRequestException; use JsonApi\Errors\RecordNotFoundException; @@ -31,7 +32,22 @@ trait CoursewareInstancesHelper if (!($method = $methods[$rangeType])) { throw new BadRequestException('Invalid range type: "' . $rangeType . '".'); } - if (!($root = StructuralElement::$method($rangeId))) { + $root = null; + if ($rangeType !== 'sharedusers') { + $chunks = explode('_', $rangeId); + $courseId = $chunks[0]; + $unitId = $chunks[1] ?? null; + + if ($unitId) { + $unit = Unit::findOneBySQL('range_id = ? AND id = ?', [$courseId, $unitId]); + } else { + $unit = Unit::findOneBySQL('range_id = ?', [$courseId]); + } + $root = $unit->structural_element; + } else { + $root = StructuralElement::$method($rangeId); + } + if (!$root) { throw new RecordNotFoundException(); } diff --git a/lib/classes/JsonApi/Routes/Courseware/CoursewareInstancesUpdate.php b/lib/classes/JsonApi/Routes/Courseware/CoursewareInstancesUpdate.php index 2b70cbf..e485884 100644 --- a/lib/classes/JsonApi/Routes/Courseware/CoursewareInstancesUpdate.php +++ b/lib/classes/JsonApi/Routes/Courseware/CoursewareInstancesUpdate.php @@ -22,7 +22,12 @@ class CoursewareInstancesUpdate extends JsonApiController */ public function __invoke(Request $request, Response $response, $args) { - $resource = $this->findInstance($args['id']); + $chunks = explode('_', $args['id']); + $rangeType = $chunks[0]; + $rangeId = $chunks[1]; + $unitId = $chunks[2] ?? null; + + $resource = $this->findInstanceWithRange($rangeType, $rangeId . '_' . $unitId); $json = $this->validate($request, $resource); if (!Authority::canUpdateCoursewareInstance($user = $this->getUser($request), $resource)) { throw new AuthorizationFailedException(); diff --git a/lib/classes/JsonApi/Routes/Courseware/StructuralElementsCopy.php b/lib/classes/JsonApi/Routes/Courseware/StructuralElementsCopy.php index 6e14b72..234d8f0 100644 --- a/lib/classes/JsonApi/Routes/Courseware/StructuralElementsCopy.php +++ b/lib/classes/JsonApi/Routes/Courseware/StructuralElementsCopy.php @@ -41,6 +41,13 @@ class StructuralElementsCopy extends NonJsonApiController if ($data['remove_purpose']) { $newElement->purpose = ''; } + if (!empty($data['modifications'])) { + $newElement->title = $data['modifications']['title'] ?? $newElement->title; + $newElement->payload['color'] = $data['modifications']['color'] ?? 'studip-blue'; + $newElement->payload['description'] = $data['modifications']['description']; + } + + $newElement->store(); return $this->redirectToStructuralElement($response, $newElement); } diff --git a/lib/classes/JsonApi/Routes/Courseware/StructuralElementsShow.php b/lib/classes/JsonApi/Routes/Courseware/StructuralElementsShow.php index 4aa0716..40a96b3 100644 --- a/lib/classes/JsonApi/Routes/Courseware/StructuralElementsShow.php +++ b/lib/classes/JsonApi/Routes/Courseware/StructuralElementsShow.php @@ -31,7 +31,8 @@ class StructuralElementsShow extends JsonApiController 'edit-blocker', 'owner', 'parent', - 'target' + 'target', + 'unit', ]; /** diff --git a/lib/classes/JsonApi/Routes/Courseware/UnitsCopy.php b/lib/classes/JsonApi/Routes/Courseware/UnitsCopy.php new file mode 100644 index 0000000..5365459 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/UnitsCopy.php @@ -0,0 +1,46 @@ + +* @license GPL2 or any later version +* +* @since Stud.IP 5.3 +*/ + +class UnitsCopy extends NonJsonApiController +{ + public function __invoke(Request $request, Response $response, array $args) + { + $data = $request->getParsedBody()['data']; + + $sourceUnit = Unit::find($args['id']); + $user = $this->getUser($request); + $rangeId = $data['rangeId']; + $rangeType = $data['rangeType']; + $modified = $data['modified']; + + if (!Authority::canCreateUnit($user)) { + throw new AuthorizationFailedException(); + } + + $newUnit = $sourceUnit->copy($user, $rangeId, $rangeType, $modified); + + $response = $response->withHeader('Content-Type', 'application/json'); + $response->getBody()->write((string) json_encode($newUnit)); + + return $response; + } +} \ No newline at end of file diff --git a/lib/classes/JsonApi/Routes/Courseware/UnitsCreate.php b/lib/classes/JsonApi/Routes/Courseware/UnitsCreate.php new file mode 100644 index 0000000..8098bca --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/UnitsCreate.php @@ -0,0 +1,122 @@ +validate($request); + $user = $this->getUser($request); + if (!Authority::canCreateUnit($user)) { + throw new AuthorizationFailedException(); + } + $struct = $this->createUnit($user, $json); + + return $this->getCreatedResponse($struct); + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameters) + */ + protected function validateResourceDocument($json, $data) + { + if (!self::arrayHas($json, 'data')) { + return 'Missing `data` member at document´s top level.'; + } + if (UnitSchema::TYPE !== self::arrayGet($json, 'data.type')) { + return 'Wrong `type` member of document´s `data`.'; + } + if (!self::arrayHas($json, 'data.attributes.title')) { + return 'Missing `title` value.'; + } + if (!self::arrayHas($json, 'data.attributes.payload.description')) { + return 'Missing `description` value.'; + } + if (!self::arrayHas($json, 'data.relationships.range')) { + return 'Missing `range` relationship.'; + } + if (!$this->validateRange($json)) { + return 'Invalid `range` relationship.'; + } + } + + private function validateRange($json): bool + { + $rangeData = self::arrayGet($json, 'data.relationships.range.data'); + + if (!in_array($rangeData['type'], ['courses','users'])) { + return false; + } + if ($rangeData['type'] === 'courses') { + $range = \Course::find($rangeData['id']); + } else { + $range = \User::find($rangeData['id']); + } + + return isset($range); + } + + private function createUnit(\User $user, array $json) + { + $range_id = self::arrayGet($json, 'data.relationships.range.data.id'); + $range_type = self::getRangeType(self::arrayGet($json, 'data.relationships.range.data.type')); + + $struct = \Courseware\StructuralElement::build([ + 'parent_id' => null, + 'range_id' => $range_id, + 'range_type' => $range_type, + 'owner_id' => $user->id, + 'editor_id' => $user->id, + 'edit_blocker_id' => '', + 'title' => self::arrayGet($json, 'data.attributes.title', ''), + 'purpose' => self::arrayGet($json, 'data.attributes.purpose', ''), + 'payload' => self::arrayGet($json, 'data.attributes.payload', ''), + 'position' => 0 + ]); + + $struct->store(); + + $unit = \Courseware\Unit::build([ + 'range_id' => $range_id, + 'range_type' => $range_type, + 'structural_element_id' => $struct->id, + 'content_type' => 'courseware', + 'creator_id' => $user->id, + 'public' => self::arrayGet($json, 'data.attributes.public', ''), + 'release_date' => self::arrayGet($json, 'data.attributes.release-date', ''), + 'withdraw_date' => self::arrayGet($json, 'data.attributes.withdraw-date', ''), + ]); + + $unit->store(); + + return $unit; + } + + private function getRangeType($type): ?string + { + $type_map = [ + 'courses' => 'course', + 'users' => 'user', + ]; + + return $type_map[$type] ?? null; + } +} + diff --git a/lib/classes/JsonApi/Routes/Courseware/UnitsDelete.php b/lib/classes/JsonApi/Routes/Courseware/UnitsDelete.php new file mode 100644 index 0000000..6c9f708 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/UnitsDelete.php @@ -0,0 +1,34 @@ +getUser($request); + if (!Authority::canDeleteUnit($user, $resource)) { + throw new AuthorizationFailedException(); + } + $resource->delete(); + + return $this->getCodeResponse(204); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/UnitsIndex.php b/lib/classes/JsonApi/Routes/Courseware/UnitsIndex.php new file mode 100644 index 0000000..f9c9376 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/UnitsIndex.php @@ -0,0 +1,35 @@ +getUser($request); + if (!Authority::canIndexUnits($user)) { + throw new AuthorizationFailedException(); + } + + list($offset, $limit) = $this->getOffsetAndLimit(); + $resources = Unit::findBySQL('1 ORDER BY mkdate LIMIT ? OFFSET ?', [$limit, $offset]); + + return $this->getPaginatedContentResponse($resources, count($resources)); + } +} \ No newline at end of file diff --git a/lib/classes/JsonApi/Routes/Courseware/UnitsShow.php b/lib/classes/JsonApi/Routes/Courseware/UnitsShow.php new file mode 100644 index 0000000..37dbe3b --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/UnitsShow.php @@ -0,0 +1,42 @@ +getUser($request), $resource)) { + throw new AuthorizationFailedException(); + } + + return $this->getContentResponse($resource); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/UnitsUpdate.php b/lib/classes/JsonApi/Routes/Courseware/UnitsUpdate.php new file mode 100644 index 0000000..53fccd3 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/UnitsUpdate.php @@ -0,0 +1,95 @@ +validate($request, $resource); + $user = $this->getUser($request); + if (!Authority::canUpdateUnit($user, $resource)) { + throw new AuthorizationFailedException(); + } + $resource = $this->updateUnit($user, $resource, $json); + + return $this->getContentResponse($resource); + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameters) + */ + protected function validateResourceDocument($json, $resource) + { + if (!self::arrayHas($json, 'data')) { + return 'Missing `data` member at document´s top level.'; + } + + if (UnitSchema::TYPE !== self::arrayGet($json, 'data.type')) { + return 'Wrong `type` member of document´s `data`.'; + } + + if (!self::arrayHas($json, 'data.id')) { + return 'Document must have an `id`.'; + } + + if (self::arrayHas($json, 'data.attributes.release-date')) { + $releaseDate = self::arrayGet($json, 'data.attributes.release-date'); + if (!self::isValidTimestamp($releaseDate)) { + return '`release-date` is not an ISO 8601 timestamp.'; + } + } + + if (self::arrayHas($json, 'data.attributes.withdraw-date')) { + $withdrawDate = self::arrayGet($json, 'data.attributes.withdraw-date'); + if (!self::isValidTimestamp($withdrawDate)) { + return '`withdraw-date` is not an ISO 8601 timestamp.'; + } + } + } + + private function updateUnit(\User $user, Unit $resource, array $json): Unit + { + if (self::arrayHas($json, 'data.attributes.public')) { + $resource->public = self::arrayGet($json, 'data.attributes.public'); + } + + if (self::arrayHas($json, 'data.attributes.release-date')) { + $releaseDate = self::arrayGet($json, 'data.attributes.release-date', ''); + $releaseDate = self::fromISO8601($releaseDate); + $resource->release_date = $releaseDate->getTimestamp(); + } + + if (self::arrayHas($json, 'data.attributes.withdraw-date')) { + $withdrawDate = self::arrayGet($json, 'data.attributes.withdraw-date', ''); + $withdrawDate = self::fromISO8601($withdrawDate); + $resource->withdraw_date = $withdrawDate->getTimestamp(); + } + + $resource->store(); + + return $resource; + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/UserProgressesOfUnitsShow.php b/lib/classes/JsonApi/Routes/Courseware/UserProgressesOfUnitsShow.php new file mode 100644 index 0000000..48e7c95 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/UserProgressesOfUnitsShow.php @@ -0,0 +1,191 @@ + + * @license GPL2 or any later version + * + * @since Stud.IP 5.3 + */ + +class UserProgressesOfUnitsShow extends NonJsonApiController +{ + public function __invoke(Request $request, Response $response, array $args) + { + $user = $this->getUser($request); + $unit = Unit::find($args['id']); + if (!$unit) { + throw new RecordNotFoundException(); + } + $root = $unit->structural_element; + if (!$GLOBALS['perm']->have_studip_perm('autor', $root->range_id) || !$unit->canRead($user)) { + throw new AuthorizationFailedException(); + } + $instance = new Instance($root); + $isTeacher = $GLOBALS['perm']->have_studip_perm('tutor', $root->range_id); + + $elements = $this->findElements($instance, $user); + + $progress = $this->computeSelfProgresses($instance, $user, $elements, $isTeacher); + $progress = $this->computeCumulativeProgresses($instance, $elements, $progress); + + $progresses = $this->prepareProgressData($elements, $progress); + + $response = $response->withHeader('Content-Type', 'application/json'); + $response->getBody()->write((string) json_encode($progresses)); + + return $response; + } + + private function findElements(Instance $instance, \User $user): iterable + { + $elements = $instance->getRoot()->findDescendants($user); + $elements[] = $instance->getRoot(); + + return array_combine(array_column($elements, 'id'), $elements); + } + + private function computeSelfProgresses( + Instance $instance, + \User $user, + iterable &$elements, + bool $showProgressForAllParticipants + ): iterable + { + $progress = []; + /** @var \Course $course */ + $course = $instance->getRange(); + $allBlockIds = $instance->findAllBlocksGroupedByStructuralElementId(function ($row) { + return $row['id']; + }); + $courseMemberIds = $showProgressForAllParticipants + ? array_column($course->getMembersWithStatus('autor'), 'user_id') + : [$user->getId()]; + + $sql = "SELECT block_id, COUNT(grade) AS count, SUM(grade) AS grade + FROM cw_user_progresses + WHERE block_id IN (?) AND user_id IN (?) + GROUP BY block_id"; + + $userProgresses = \DBManager::get()->fetchGrouped($sql, [$allBlockIds, $courseMemberIds]); + + foreach ($elements as $elementId => $element) { + $selfProgress = $this->getSelfProgresses($allBlockIds, $elementId, $userProgresses, $courseMemberIds); + $progress[$elementId] = [ + 'self' => $selfProgress['counter'] ? $selfProgress['progress'] / $selfProgress['counter'] : 1, + ]; + } + + return $progress; + } + + private function getSelfProgresses( + iterable &$allBlockIds, + string $elementId, + array &$userProgresses, + array &$courseMemberIds + ): array { + $blks = $allBlockIds[$elementId] ?? []; + if (count($blks) === 0) { + return [ + 'counter' => 0, + 'progress' => 1, + ]; + } + + $data = [ + 'counter' => count($blks), + 'progress' => 0, + ]; + + $usersCounter = count($courseMemberIds); + foreach ($blks as $blk) { + $progresses = $userProgresses[$blk]; + $usersProgress = $progresses['count'] ? (float) $progresses['sum'] : 0; + $data['progress'] += $usersProgress / $usersCounter; + } + + return $data; + } + + private function computeCumulativeProgresses(Instance $instance, iterable &$elements, iterable &$progress): iterable + { + $childrenOf = $this->computeChildrenOf($elements); + + // compute `cumulative` of each element + $visitor = function (&$progress, $element) use (&$childrenOf, &$elements, &$visitor) { + $elementId = $element->getId(); + $numberOfNodes = 0; + $cumulative = 0; + + // visit children first + if (isset($childrenOf[$elementId])) { + foreach ($childrenOf[$elementId] as $childId) { + $visitor($progress, $elements[$childId]); + $numberOfNodes += $progress[$childId]['numberOfNodes']; + $cumulative += $progress[$childId]['cumulative']; + } + } + + $progress[$elementId]['cumulative'] = $cumulative + $progress[$elementId]['self']; + $progress[$elementId]['numberOfNodes'] = $numberOfNodes + 1; + + return $progress; + }; + + $visitor($progress, $instance->getRoot()); + + return $progress; + } + + private function computeChildrenOf(iterable &$elements): iterable + { + $childrenOf = []; + foreach ($elements as $elementId => $element) { + if ($element['parent_id']) { + if (!isset($childrenOf[$element['parent_id']])) { + $childrenOf[$element['parent_id']] = []; + } + $childrenOf[$element['parent_id']][] = $elementId; + } + } + + return $childrenOf; + } + + private function prepareProgressData(iterable &$elements, iterable &$progress): iterable + { + $data = []; + foreach ($elements as $elementId => $element) { + $elementProgress = $progress[$elementId]; + $cumulative = $elementProgress['cumulative'] / $elementProgress['numberOfNodes']; + + $data[$elementId] = [ + 'id' => (int) $elementId, + 'parent_id' => (int) $element['parent_id'], + 'name' => $element['title'], + 'progress' => [ + 'cumulative' => round($cumulative, 2) * 100, + 'self' => round($elementProgress['self'], 2) * 100, + ], + ]; + } + + return $data; + } +} \ No newline at end of file diff --git a/lib/classes/JsonApi/Routes/Courseware/UsersUnitsIndex.php b/lib/classes/JsonApi/Routes/Courseware/UsersUnitsIndex.php new file mode 100644 index 0000000..74bcd32 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/UsersUnitsIndex.php @@ -0,0 +1,46 @@ +getUser($request); + if (!Authority::canIndexUnitsOfAUser($request_user, $user)) { + throw new AuthorizationFailedException(); + } + + $resources = Unit::findUsersUnits($user); + $total = count($resources); + [$offset, $limit] = $this->getOffsetAndLimit(); + + return $this->getPaginatedContentResponse(array_slice($resources, $offset, $limit), $total); + } +} diff --git a/lib/classes/JsonApi/SchemaMap.php b/lib/classes/JsonApi/SchemaMap.php index e7168cd..44b12d9 100644 --- a/lib/classes/JsonApi/SchemaMap.php +++ b/lib/classes/JsonApi/SchemaMap.php @@ -59,6 +59,7 @@ class SchemaMap \Courseware\StructuralElement::class => Schemas\Courseware\StructuralElement::class, \Courseware\StructuralElementComment::class => Schemas\Courseware\StructuralElementComment::class, \Courseware\StructuralElementFeedback::class => Schemas\Courseware\StructuralElementFeedback::class, + \Courseware\Unit::class => Schemas\Courseware\Unit::class, \Courseware\UserDataField::class => Schemas\Courseware\UserDataField::class, \Courseware\UserProgress::class => Schemas\Courseware\UserProgress::class, \Courseware\Task::class => Schemas\Courseware\Task::class, diff --git a/lib/classes/JsonApi/Schemas/Courseware/Instance.php b/lib/classes/JsonApi/Schemas/Courseware/Instance.php index 63a4d95..ff10966 100644 --- a/lib/classes/JsonApi/Schemas/Courseware/Instance.php +++ b/lib/classes/JsonApi/Schemas/Courseware/Instance.php @@ -19,8 +19,9 @@ class Instance extends SchemaProvider public function getId($resource): ?string { $root = $resource->getRoot(); + $unit = \Courseware\Unit::findOneBySQL('structural_element_id = ?', [$root->id]); - return join('_', [$root->range_type, $root->range_id]); + return join('_', [$root->range_type, $root->range_id, $unit->id]); } /** @@ -34,11 +35,12 @@ class Instance extends SchemaProvider 'block-types' => array_map([$this, 'mapBlockType'], $resource->getBlockTypes()), 'container-types' => array_map([$this, 'mapContainerType'], $resource->getContainerTypes()), 'favorite-block-types' => $resource->getFavoriteBlockTypes($user), - 'sequential-progression' => (bool) $resource->getSequentialProgression(), + 'sequential-progression' => $resource->getSequentialProgression(), 'editing-permission-level' => $resource->getEditingPermissionLevel(), 'certificate-settings' => $resource->getCertificateSettings(), 'reminder-settings' => $resource->getReminderSettings(), - 'reset-progress-settings' => $resource->getResetProgressSettings() + 'reset-progress-settings' => $resource->getResetProgressSettings(), + 'root-id' => $resource->getRoot()->id ]; } diff --git a/lib/classes/JsonApi/Schemas/Courseware/StructuralElement.php b/lib/classes/JsonApi/Schemas/Courseware/StructuralElement.php index b50b179..4335e89 100644 --- a/lib/classes/JsonApi/Schemas/Courseware/StructuralElement.php +++ b/lib/classes/JsonApi/Schemas/Courseware/StructuralElement.php @@ -23,6 +23,7 @@ class StructuralElement extends SchemaProvider const REL_PARENT = 'parent'; const REL_USER = 'user'; const REL_TASK = 'task'; + const REL_UNIT = 'unit'; /** * {@inheritdoc} @@ -51,6 +52,7 @@ class StructuralElement extends SchemaProvider 'write-approval' => $resource['write_approval']->getIterator(), 'copy-approval' => $resource['copy_approval']->getIterator(), 'can-edit' => $resource->canEdit($user), + 'can-visit' => $resource->canVisit($user), 'is-link' => (int) $resource['is_link'], 'target-id' => (int) $resource['target_id'], 'external-relations' => $resource['external_relations']->getIterator(), @@ -131,6 +133,12 @@ class StructuralElement extends SchemaProvider $this->shouldInclude($context, self::REL_TASK) ); + $relationships = $this->addUnitRelationship( + $relationships, + $resource, + $this->shouldInclude($context, self::REL_UNIT) + ); + return $relationships; } @@ -355,6 +363,22 @@ class StructuralElement extends SchemaProvider return $relationships; } + private function addUnitRelationship(array $relationships, $resource, $includeData): array + { + $relation = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->getRelationshipRelatedLink($resource, self::REL_UNIT), + ], + ]; + + $related = $resource->findUnit(); + $relation[self::RELATIONSHIP_DATA] = $related; + + $relationships[self::REL_UNIT] = $relation; + + return $relationships; + } + private static $memo = []; private function createLinkToCourse($rangeId) diff --git a/lib/classes/JsonApi/Schemas/Courseware/Unit.php b/lib/classes/JsonApi/Schemas/Courseware/Unit.php new file mode 100644 index 0000000..a1c554e --- /dev/null +++ b/lib/classes/JsonApi/Schemas/Courseware/Unit.php @@ -0,0 +1,79 @@ +id; + } + + /** + * {@inheritdoc} + */ + public function getAttributes($resource, ContextInterface $context): iterable + { + return [ + 'content-type' => (string) $resource['content_type'], + 'public' => (int) $resource['public'], + 'release-date' => $resource['release_date'] ? date('c', $resource['release_date']) : null, + 'withdraw-date' => $resource['withdraw_date'] ? date('c', $resource['withdraw_date']) : null, + 'mkdate' => date('c', $resource['mkdate']), + 'chdate' => date('c', $resource['chdate']), + ]; + } + + /** + * {@inheritdoc} + */ + public function getRelationships($resource, ContextInterface $context): iterable + { + $relationships = []; + + $relationships[self::REL_CREATOR] = $resource['creator_id'] + ? [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($resource->creator), + ], + self::RELATIONSHIP_DATA => $resource->creator, + ] + : [self::RELATIONSHIP_DATA => null]; + + $relationships[self::REL_STRUCTURAL_ELEMENT] = $resource['structural_element_id'] + ? [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($resource->structural_element), + ], + self::RELATIONSHIP_DATA => $resource->structural_element, + ] + : [self::RELATIONSHIP_DATA => null]; + + $rangeType = $resource->range_type; + $range = $resource->$rangeType; + + $relationships[self::REL_RANGE] = $range + ? [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($range), + ], + self::RELATIONSHIP_DATA => $range, + ] + : [self::RELATIONSHIP_DATA => null]; + + return $relationships; + } +} \ No newline at end of file diff --git a/lib/models/Courseware/Instance.php b/lib/models/Courseware/Instance.php index 0b388fe..63bd34b 100644 --- a/lib/models/Courseware/Instance.php +++ b/lib/models/Courseware/Instance.php @@ -165,9 +165,10 @@ class Instance public function getSequentialProgression(): bool { $range = $this->getRange(); - $config = $range->getConfiguration()->getValue('COURSEWARE_SEQUENTIAL_PROGRESSION'); + $root = $this->getRoot(); + $sequentialProgression = $range->getConfiguration()->COURSEWARE_SEQUENTIAL_PROGRESSION[$root->id]; - return (bool) $config; + return (bool) $sequentialProgression; } /** @@ -178,7 +179,10 @@ class Instance public function setSequentialProgression(bool $isSequentialProgression): void { $range = $this->getRange(); - $range->getConfiguration()->store('COURSEWARE_SEQUENTIAL_PROGRESSION', $isSequentialProgression); + $root = $this->getRoot(); + $progressions = $range->getConfiguration()->getValue('COURSEWARE_SEQUENTIAL_PROGRESSION'); + $progressions[$root->id] = $isSequentialProgression ? 1 : 0; + $range->getConfiguration()->store('COURSEWARE_SEQUENTIAL_PROGRESSION', $progressions); } const EDITING_PERMISSION_DOZENT = 'dozent'; @@ -192,11 +196,15 @@ class Instance public function getEditingPermissionLevel(): string { $range = $this->getRange(); + $root = $this->getRoot(); /** @var string $editingPermissionLevel */ - $editingPermissionLevel = $range->getConfiguration()->getValue('COURSEWARE_EDITING_PERMISSION'); - $this->validateEditingPermissionLevel($editingPermissionLevel); + $editingPermissionLevel = $range->getConfiguration()->COURSEWARE_EDITING_PERMISSION[$root->id]; + if ($editingPermissionLevel) { + $this->validateEditingPermissionLevel($editingPermissionLevel); + return $editingPermissionLevel; + } - return $editingPermissionLevel; + return self::EDITING_PERMISSION_TUTOR; // tutor is default } /** @@ -209,7 +217,10 @@ class Instance { $this->validateEditingPermissionLevel($editingPermissionLevel); $range = $this->getRange(); - $range->getConfiguration()->store('COURSEWARE_EDITING_PERMISSION', $editingPermissionLevel); + $root = $this->getRoot(); + $permissions = $range->getConfiguration()->getValue('COURSEWARE_EDITING_PERMISSION'); + $permissions[$root->id] = $editingPermissionLevel; + $range->getConfiguration()->store('COURSEWARE_EDITING_PERMISSION', $permissions); } /** @@ -240,9 +251,10 @@ class Instance public function getCertificateSettings(): array { $range = $this->getRange(); + $root = $this->getRoot(); /** @var array $certificateSettings */ $certificateSettings = json_decode( - $range->getConfiguration()->getValue('COURSEWARE_CERTIFICATE_SETTINGS'), + $range->getConfiguration()->COURSEWARE_CERTIFICATE_SETTINGS[$root->id], true )?: []; $this->validateCertificateSettings($certificateSettings); @@ -259,8 +271,10 @@ class Instance { $this->validateCertificateSettings($certificateSettings); $range = $this->getRange(); - $range->getConfiguration()->store('COURSEWARE_CERTIFICATE_SETTINGS', - count($certificateSettings) > 0 ? json_encode($certificateSettings) : null); + $root = $this->getRoot(); + $settings = $range->getConfiguration()->getValue('COURSEWARE_CERTIFICATE_SETTINGS'); + $settings[$root->id] = count($certificateSettings) > 0 ? json_encode($certificateSettings) : null; + $range->getConfiguration()->store('COURSEWARE_CERTIFICATE_SETTINGS', $settings); } /** @@ -290,9 +304,10 @@ class Instance public function getReminderSettings(): array { $range = $this->getRange(); + $root = $this->getRoot(); /** @var int $reminderInterval */ $reminderSettings = json_decode( - $range->getConfiguration()->getValue('COURSEWARE_REMINDER_SETTINGS'), + $range->getConfiguration()->COURSEWARE_REMINDER_SETTINGS[$root->id], true )?: []; $this->validateReminderSettings($reminderSettings); @@ -309,8 +324,10 @@ class Instance { $this->validateReminderSettings($reminderSettings); $range = $this->getRange(); - $range->getConfiguration()->store('COURSEWARE_REMINDER_SETTINGS', - count($reminderSettings) > 0 ? json_encode($reminderSettings) : null); + $root = $this->getRoot(); + $settings = $range->getConfiguration()->getValue('COURSEWARE_REMINDER_SETTINGS'); + $settings[$root->id] = count($reminderSettings) > 0 ? json_encode($reminderSettings) : null; + $range->getConfiguration()->store('COURSEWARE_REMINDER_SETTINGS', $settings); } /** @@ -342,9 +359,10 @@ class Instance public function getResetProgressSettings(): array { $range = $this->getRange(); + $root = $this->getRoot(); /** @var int $reminderInterval */ $resetProgressSettings = json_decode( - $range->getConfiguration()->getValue('COURSEWARE_RESET_PROGRESS_SETTINGS'), + $range->getConfiguration()->COURSEWARE_RESET_PROGRESS_SETTINGS[$root->id], true )?: []; $this->validateResetProgressSettings($resetProgressSettings); @@ -361,8 +379,10 @@ class Instance { $this->validateResetProgressSettings($resetProgressSettings); $range = $this->getRange(); - $range->getConfiguration()->store('COURSEWARE_RESET_PROGRESS_SETTINGS', - count($resetProgressSettings) > 0 ? json_encode($resetProgressSettings) : null); + $root = $this->getRoot(); + $settings = $range->getConfiguration()->getValue('COURSEWARE_RESET_PROGRESS_SETTINGS'); + $settings[$root->id] = count($resetProgressSettings) > 0 ? json_encode($resetProgressSettings) : null; + $range->getConfiguration()->store('COURSEWARE_RESET_PROGRESS_SETTINGS', $settings); } /** diff --git a/lib/models/Courseware/StructuralElement.php b/lib/models/Courseware/StructuralElement.php index 7206199..9aa4f90 100644 --- a/lib/models/Courseware/StructuralElement.php +++ b/lib/models/Courseware/StructuralElement.php @@ -375,7 +375,7 @@ class StructuralElement extends \SimpleORMap { return $GLOBALS['perm']->have_perm('root', $user->id) || $GLOBALS['perm']->have_studip_perm( - \CourseConfig::get($this->range_id)->COURSEWARE_EDITING_PERMISSION, + \CourseConfig::get($this->range_id)->COURSEWARE_EDITING_PERMISSION[$this->getCoursewareCourse($this->range_id)->id], $this->range_id, $user->id ); @@ -653,6 +653,17 @@ class StructuralElement extends \SimpleORMap return $ancestors; } + public function findUnit() + { + if ($this->isRootNode()) { + $root = $this; + } else { + $root = $this->findAncestors()[0]; + } + + return Unit::findOneBySQL('structural_element_id = ?', [$root->id]); + } + /** * Returns the list of all descendants of this instance in depth-first search order. * @@ -761,6 +772,42 @@ SQL; } /** + * Copies this instance into another course oder users contents. + * + * @param User $user this user will be the owner of the copy + * @param Range $parent the target where to copy this instance + * + * @return StructuralElement the copy of this instance + */ + public function copyToRange(User $user, string $rangeId, string $rangeType, string $purpose = ''): StructuralElement + { + $element = self::build([ + 'parent_id' => null, + 'range_id' => $rangeId, + 'range_type' => $rangeType, + 'owner_id' => $user->id, + 'editor_id' => $user->id, + 'edit_blocker_id' => null, + 'title' => $this->title, + 'purpose' => $purpose ?: $this->purpose, + 'position' => 0, + 'payload' => $this->payload, + ]); + + $element->store(); + + $file_ref_id = $this->copyImage($user, $element); + $element->image_id = $file_ref_id; + $element->store(); + + $this->copyContainers($user, $element); + + $this->copyChildren($user, $element, $purpose); + + return $element; + } + + /** * Copies this instance as a child into another structural element. * * @param User $user this user will be the owner of the copy diff --git a/lib/models/Courseware/Unit.php b/lib/models/Courseware/Unit.php new file mode 100644 index 0000000..acf2a93 --- /dev/null +++ b/lib/models/Courseware/Unit.php @@ -0,0 +1,112 @@ + + * @license GPL2 or any later version + * + * @since Stud.IP 5.3 + * + * @property int $id database column + * @property string $range_id database column + * @property string $range_type database column + * @property int $structural_element_id database column + * @property string $content_type database column + * @property int $public database column + * @property string $creator_id database column + * @property int $release_date database column + * @property int $withdraw_date database column + * @property int $mkdate database column + * @property int $chdate database column + * @property \User $creator belongs_to User + * @property \Courseware\StructuralElement $structural_element belongs_to Courseware\StructuralElement + * + * @SuppressWarnings(PHPMD.TooManyPublicMethods) + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) + */ + +class Unit extends \SimpleORMap +{ + protected static function configure($config = []) + { + $config['db_table'] = 'cw_units'; + + $config['has_one']['structural_element'] = [ + 'class_name' => StructuralElement::class, + 'foreign_key' => 'structural_element_id', + 'on_delete' => 'delete', + ]; + $config['belongs_to']['course'] = [ + 'class_name' => \Course::class, + 'foreign_key' => 'range_id', + 'assoc_foreign_key' => 'seminar_id', + ]; + $config['belongs_to']['user'] = [ + 'class_name' => User::class, + 'foreign_key' => 'range_id', + 'assoc_foreign_key' => 'user_id', + ]; + $config['belongs_to']['creator'] = [ + 'class_name' => User::class, + 'foreign_key' => 'creator_id', + ]; + + parent::configure($config); + } + + public static function findCoursesUnits(\Course $course): array + { + return self::findBySQL('range_id = ? AND range_type = ?', [$course->id, 'course']); + } + + public static function findUsersUnits(\User $user): array + { + return self::findBySQL('range_id = ? AND range_type = ?', [$user->id, 'user']); + } + + public function canRead(\User $user): bool + { + return $this->structural_element->canRead($user); + } + + public function canEdit(\User $user): bool + { + return $this->structural_element->canEdit($user);; + } + + public function copy(\User $user, string $rangeId, string $rangeType, array $modified = null): Unit + { + $sourceUnitElement = $this->structural_element; + + $newElement = $sourceUnitElement->copyToRange($user, $rangeId, $rangeType); + + if ($modified !== null) { + $newElement->title = $modified['title'] ?? $newElement->title; + $newElement->payload['color'] = $modified['color'] ?? 'studip-blue'; + $newElement->payload['description'] = $modified['description'] ?? $newElement->payload['description']; + $newElement->store(); + } + + $newUnit = \Courseware\Unit::build([ + 'range_id' => $rangeId, + 'range_type' => $rangeType, + 'structural_element_id' => $newElement->id, + 'content_type' => 'courseware', + 'creator_id' => $user->id, + 'public' => '', + 'release_date' => '', + 'withdraw_date' => '', + ]); + + $newUnit->store(); + + return $newUnit; + } +} diff --git a/lib/modules/CoursewareModule.class.php b/lib/modules/CoursewareModule.class.php index e07d5d2..76280e6 100644 --- a/lib/modules/CoursewareModule.class.php +++ b/lib/modules/CoursewareModule.class.php @@ -41,31 +41,21 @@ class CoursewareModule extends CorePlugin implements SystemPlugin, StudipModule, ); $navigation->setImage(Icon::create('courseware', Icon::ROLE_INFO_ALT)); $navigation->addSubNavigation( - 'content', - new Navigation(_('Inhalt'), 'dispatch.php/course/courseware/?cid='.$courseId) + 'shelf', + new Navigation(_('Lernmaterialien'), 'dispatch.php/course/courseware/?cid=' . $courseId) ); $navigation->addSubNavigation( - 'dashboard', - new Navigation(_('Übersicht'), 'dispatch.php/course/courseware/dashboard?cid='.$courseId) + 'unit', + new Navigation(_('Inhalt'), 'dispatch.php/course/courseware/courseware?cid=' . $courseId) + ); + $navigation->addSubNavigation( + 'activities', + new Navigation(_('Aktivitäten'), 'dispatch.php/course/courseware/activities?cid=' . $courseId) + ); + $navigation->addSubNavigation( + 'tasks', + new Navigation(_('Aufgaben'), 'dispatch.php/course/courseware/tasks?cid=' . $courseId) ); - - if ($GLOBALS['perm']->have_studip_perm('dozent', $courseId)) { - $navigation->addSubNavigation( - 'manager', - new Navigation(_('Verwaltung'), 'dispatch.php/course/courseware/manager?cid='.$courseId) - ); - } else { - $element = StructuralElement::getCoursewareCourse($courseId); - if ($element !== null) { - $instance = new Instance($element); - if ($GLOBALS['perm']->have_studip_perm($instance->getEditingPermissionLevel(), $courseId)) { - $navigation->addSubNavigation( - 'manager', - new Navigation(_('Verwaltung'), 'dispatch.php/course/courseware/manager?cid='.$courseId) - ); - } - } - } return ['courseware' => $navigation]; } @@ -129,10 +119,10 @@ class CoursewareModule extends CorePlugin implements SystemPlugin, StudipModule, { return [ 'summary' => _('Lerninhalte erstellen, verteilen und erleben'), - 'description' => _('Mit Courseware können Sie interaktive multimediale Lerninhalte erstellen und nutzen. ' + 'description' => _('Mit Courseware können Sie interaktive, multimediale Lerninhalte erstellen und nutzen. ' . 'Die Lerninhalte lassen sich hierarchisch unterteilen und können aus Texten, ' . 'Videosequenzen, Aufgaben, Kommunikationselementen und einer Vielzahl weiterer ' - . 'Elementen bestehen. Fertige Lerninhalte können exportiert und in andere Kurse oder ' + . 'Elemente bestehen. Fertige Lerninhalte können exportiert und in andere Kurse oder ' . 'andere Installationen importiert werden. Courseware ist nicht nur für digitale ' . 'Formate geeignet, sondern kann auch genutzt werden, um klassische ' . 'Präsenzveranstaltungen mit Online-Anteilen zu ergänzen. Formate wie integriertes ' diff --git a/lib/navigation/ContentsNavigation.php b/lib/navigation/ContentsNavigation.php index 965f598..119de6a 100644 --- a/lib/navigation/ContentsNavigation.php +++ b/lib/navigation/ContentsNavigation.php @@ -52,16 +52,12 @@ class ContentsNavigation extends Navigation $courseware->setImage(Icon::create('courseware')); $courseware->addSubNavigation( - 'overview', + 'shelf', new Navigation(_('Übersicht'), 'dispatch.php/contents/courseware/index') ); $courseware->addSubNavigation( 'courseware', - new Navigation(_('Persönliche Lernmaterialien'), 'dispatch.php/contents/courseware/courseware') - ); - $courseware->addSubNavigation( - 'courseware_manager', - new Navigation(_('Verwaltung persönlicher Lernmaterialien'), 'dispatch.php/contents/courseware/courseware_manager') + new Navigation(_('Inhalt'), 'dispatch.php/contents/courseware/courseware') ); $courseware->addSubNavigation( 'releases', diff --git a/package-lock.json b/package-lock.json index bcca55a..1e7c027 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5087,9 +5087,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001341", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001341.tgz", - "integrity": "sha512-2SodVrFFtvGENGCv0ChVJIDQ0KPaS1cg7/qtfMaICgeMolDdo/Z2OD32F0Aq9yl6F4YFwGPBS5AaPqNYiW4PoA==", + "version": "1.0.30001429", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001429.tgz", + "integrity": "sha512-511ThLu1hF+5RRRt0zYCf2U2yRr9GPF6m5y90SBCWsvSoYoW7yAGlv/elyPaNfvGCkp6kj/KFZWU0BMA69Prsg==", "dev": true, "funding": [ { @@ -17974,9 +17974,9 @@ } }, "caniuse-lite": { - "version": "1.0.30001341", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001341.tgz", - "integrity": "sha512-2SodVrFFtvGENGCv0ChVJIDQ0KPaS1cg7/qtfMaICgeMolDdo/Z2OD32F0Aq9yl6F4YFwGPBS5AaPqNYiW4PoA==", + "version": "1.0.30001429", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001429.tgz", + "integrity": "sha512-511ThLu1hF+5RRRt0zYCf2U2yRr9GPF6m5y90SBCWsvSoYoW7yAGlv/elyPaNfvGCkp6kj/KFZWU0BMA69Prsg==", "dev": true }, "chalk": { diff --git a/resources/assets/javascripts/bootstrap/courseware.js b/resources/assets/javascripts/bootstrap/courseware.js index 7124ac9..503ae89 100644 --- a/resources/assets/javascripts/bootstrap/courseware.js +++ b/resources/assets/javascripts/bootstrap/courseware.js @@ -1,4 +1,15 @@ STUDIP.domReady(() => { + if (document.getElementById('courseware-shelf-app')) { + STUDIP.Vue.load().then(({ createApp }) => { + import( + /* webpackChunkName: "courseware-shelf-app" */ + '@/vue/courseware-shelf-app.js' + ).then(({ default: mountApp }) => { + return mountApp(STUDIP, createApp, '#courseware-shelf-app'); + }); + }); + } + if (document.getElementById('courseware-index-app')) { STUDIP.Vue.load().then(({ createApp }) => { import( @@ -10,35 +21,35 @@ STUDIP.domReady(() => { }); } - if (document.getElementById('courseware-dashboard-app')) { + if (document.getElementById('courseware-activities-app')) { STUDIP.Vue.load().then(({ createApp }) => { import( - /* webpackChunkName: "courseware-dashboard-app" */ - '@/vue/courseware-dashboard-app.js' + /* webpackChunkName: "courseware-activities-app" */ + '@/vue/courseware-activities-app.js' ).then(({ default: mountApp }) => { - return mountApp(STUDIP, createApp, '#courseware-dashboard-app'); + return mountApp(STUDIP, createApp, '#courseware-activities-app'); }); }); } - if (document.getElementById('courseware-manager-app')) { + if (document.getElementById('courseware-tasks-app')) { STUDIP.Vue.load().then(({ createApp }) => { import( - /* webpackChunkName: "courseware-manager-app" */ - '@/vue/courseware-manager-app.js' + /* webpackChunkName: "courseware-tasks-app" */ + '@/vue/courseware-tasks-app.js' ).then(({ default: mountApp }) => { - return mountApp(STUDIP, createApp, '#courseware-manager-app'); + return mountApp(STUDIP, createApp, '#courseware-tasks-app'); }); }); } - if (document.getElementById('courseware-content-overview-app')) { + if (document.getElementById('courseware-manager-app')) { STUDIP.Vue.load().then(({ createApp }) => { import( - /* webpackChunkName: "courseware-content-overview-app" */ - '@/vue/courseware-content-overview-app.js' + /* webpackChunkName: "courseware-manager-app" */ + '@/vue/courseware-manager-app.js' ).then(({ default: mountApp }) => { - return mountApp(STUDIP, createApp, '#courseware-content-overview-app'); + return mountApp(STUDIP, createApp, '#courseware-manager-app'); }); }); } diff --git a/resources/assets/stylesheets/scss/buttons.scss b/resources/assets/stylesheets/scss/buttons.scss index 05f2a26..ef81981 100644 --- a/resources/assets/stylesheets/scss/buttons.scss +++ b/resources/assets/stylesheets/scss/buttons.scss @@ -106,6 +106,16 @@ button.button { .button.search { @include button-with-icon(search, clickable, info_alt); } +.button.arr_left { + @include button-with-icon(arr_1left, clickable, info_alt); +} +.button.arr_right { + @include button-with-icon(arr_1right, clickable, info_alt); + &::before { + float: right; + margin: 1px -8px 0 5px; + } +} /* Grouped Buttons */ .button-group { diff --git a/resources/assets/stylesheets/scss/courseware.scss b/resources/assets/stylesheets/scss/courseware.scss index b1fc6be..8041651 100644 --- a/resources/assets/stylesheets/scss/courseware.scss +++ b/resources/assets/stylesheets/scss/courseware.scss @@ -7,7 +7,8 @@ $companion-types: ( alert: alert, sad: sad, happy: happy, - pointing: pointing-right + pointing: pointing-right, + curious: curious ); $element-icons: ( @@ -246,9 +247,9 @@ c o n t e n t s e n d r i b b o n * * * * * */ $consum_ribbon_width: calc(100% - 58px); -#course-courseware-index, +#course-courseware-courseware, #contents-courseware-courseware, -#contents-courseware-shared_content_courseware { +#contents-courseware-shared_content_courseware { &.consume { overflow: hidden; } @@ -621,8 +622,8 @@ ribbon end scrollbar-color: $base-color #f5f5f5; } - .cw-wellcome-screen { - .cw-wellcome-screen-keyvisual { + .cw-welcome-screen { + .cw-welcome-screen-keyvisual { margin: 14px 0 42px 0; width: 100%; height: 400px; @@ -635,7 +636,7 @@ ribbon end text-align: center; font-size: 2.25em; } - .cw-wellcome-screen-actions { + .cw-welcome-screen-actions { display: flex; flex-wrap: wrap; justify-content: center; @@ -799,6 +800,8 @@ ribbon end } } + + /* * * * * * * * * * * * structual element end * * * * * * * * * * * */ @@ -2084,6 +2087,17 @@ v i e w w i d g e t @include background-icon(oer-campus, clickable); } } +.cw-import-widget { + .cw-import-widget-archive{ + @include background-icon(file-archive, clickable); + } + .cw-import-widget-copy{ + @include background-icon(files, clickable); + } + .cw-import-widget-import{ + @include background-icon(import, clickable); + } +} /* * * * * * * * * * * * * * v i e w w i d g e t e n d @@ -2218,118 +2232,6 @@ textarea.studip-wysiwyg { w y s i w y g e n d * * * * * * * * * * */ -/* * * * * * -d i a l o g -* * * * * */ - -.studip-dialog-backdrop { - position: fixed; - top: 0; - bottom: 0; - left: 0; - right: 0; - background-color: fade-out($base-color, 0.5); - display: flex; - justify-content: center; - align-items: center; - z-index: 3001; -} -.studip-dialog-body { - position: absolute; - background: $white; - box-shadow: 0 0 8px fade-out($black, 0.5); - overflow-x: auto; - display: flex; - flex-direction: column; - padding: 3px; - margin: 3px; - max-height: 98vh; - - .studip-dialog-header, - .studip-dialog-footer { - padding: 7px; - display: flex; - } - .studip-dialog-header { - background: $base-color none repeat scroll 0 0; - border-bottom: 1px solid $dark-gray-color-10; - color: $white; - justify-content: space-between; - font-size: 1.3em; - padding: 0.5em 1em; - cursor: grab; - - &.drag-active { - cursor: grabbing; - } - } - .studip-dialog-close-button { - @include background-icon(decline, info-alt); - background-repeat: no-repeat; - background-position-y: center; - background-color: transparent; - border: none; - - width: 22px; - height: 22px; - margin-right: -10px; - margin-left: 2em; - cursor: pointer; - } - .studip-dialog-content { - color: $black; - position: relative; - padding: 15px; - overflow-y: auto; - min-width: 100%; - // resize: both; - box-sizing: border-box; - } - .studip-dialog-footer { - border-top: 1px solid $dark-gray-color-10; - justify-content: center; - } - - &.studip-dialog-warning, - &.studip-dialog-alert { - .studip-dialog-content { - padding: 15px 15px 15px 62px; - background-position: 12px center; - background-repeat: no-repeat; - box-sizing: border-box; - display: flex; - align-items: center; - } - } - - &.studip-dialog-alert { - .studip-dialog-header { - background: $active-color none repeat scroll 0 0; - } - .studip-dialog-content { - @include background-icon(question-circle-full, attention, 32); - } - } - &.studip-dialog-warning { - .studip-dialog-header { - color: $black; - background: $activity-color none repeat scroll 0 0; - } - .studip-dialog-close-button { - @include background-icon(decline, clickable); - border: none; - background-color: transparent; - } - .studip-dialog-content { - @include background-icon(question-circle-full, status-yellow, 32); - } - } - -} -/* * * * * * * * * -d i a l o g e n d -* * * * * * * * */ - /* * * * * * * * * d a s h b o a r d * * * * * * * * */ @@ -2362,56 +2264,7 @@ d a s h b o a r d justify-content: center; } - .cw-dashboard-progress { - - .cw-dashboard-progress-breadcrumb { - padding: 10px; - span { - color: $base-color; - cursor: pointer; - - &:hover { - color: $active-color; - } - } - } - - .cw-dashboard-progress-chapter { - text-align: center; - margin-bottom: -3.5em; - - h1 { - border: none; - margin: 0; - padding: 0; - } - - .cw-progress-circle { - font-size: 18px; - margin: 1em auto; - - &.cw-dashboard-progress-current { - font-size: 12px; - top: -4.5em; - left: -2.5em; - } - } - } - - .cw-dashboard-progress-subchapter-list { - border-top: solid thin $content-color-40; - height: 349px; - overflow-y: scroll; - overflow-x: hidden; - padding: 0 1em 0 1em; - scrollbar-width: thin; - scrollbar-color: $base-color $dark-gray-color-5; - .cw-dashboard-empty-info { - margin-top: 10px; - } - } - } &.cw-dashboard-task-view { display: unset; max-width: unset; @@ -2422,12 +2275,6 @@ d a s h b o a r d max-height: unset; } } - &.cw-dashboard-activity-view { - .cw-dashboard-activities { - max-height: 760px; - } - - } } #course-courseware-dashboard { @@ -2450,60 +2297,16 @@ d a s h b o a r d } } -.cw-dashboard-progress-item { - display: block; - border-bottom: solid thin $content-color-40; - padding: 10px 0; - - &:hover{ - background-color: hsla(217,6%,45%,.2); - } - - &:last-child { - border: none; - } - - .cw-dashboard-progress-item-value, - .cw-dashboard-progress-item-description { - display: inline-block; - vertical-align: top; - } +.cw-activities-wrapper { + max-width: 1095px; - .cw-dashboard-progress-item-value { - width: 70px; - color: $base-color; - font-size: xx-large; - - .cw-progress-circle { - font-size: 12px; - margin: 4px; - } - } - .cw-dashboard-progress-item-description { - color: $base-color; - padding-left: 14px; - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - padding: 0.5em 0 0 1em; - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - } -} -.cw-dashboard-activities-wrapper { .cw-companion-box { margin: 10px; } - .cw-dashboard-activities { - max-height: 525px; + .cw-activities { list-style: none; padding: 0; - scrollbar-width: thin; - scrollbar-color:$base-color #f5f5f5; - overflow-y: auto; - overflow-x: hidden; .cw-activity-item { border-bottom: solid thin $content-color-40; @@ -2519,12 +2322,8 @@ d a s h b o a r d padding-right: 0.5em; vertical-align: text-bottom; } - &.cw-activity-item-text { - padding-left: 23px; - } } } - } } @@ -2537,10 +2336,6 @@ d a s h b o a r d .cw-dashboard-tasks-wrapper, .cw-dashboard-students-wrapper { - overflow-x: auto; - scrollbar-width: thin; - scrollbar-color:$base-color #f5f5f5; - max-height: 280px; table.default { margin: 0; @@ -2576,6 +2371,99 @@ d a s h b o a r d d a s h b o a r d e n d * * * * * * * * * * * */ +/* * * * * * * * * * * * + p r o g r e s s +* * * * * * * * * * * */ +.cw-unit-progress { + .cw-unit-progress-breadcrumb { + padding: 10px; + span { + color: $base-color; + cursor: pointer; + + &:hover { + color: $active-color; + } + } + } + + .cw-unit-progress-chapter { + text-align: center; + margin-bottom: -3.5em; + + h1 { + border: none; + margin: 0; + padding: 0; + } + + .cw-progress-circle { + font-size: 18px; + margin: 1em auto; + + &.cw-unit-progress-current { + font-size: 12px; + top: -4.5em; + left: -2.5em; + } + } + } + + .cw-unit-progress-subchapter-list { + border-top: solid thin $content-color-40; + padding: 0 1em 0 1em; + + .cw-dashboard-empty-info { + margin-top: 10px; + } + } +} + +.cw-unit-progress-item { + display: block; + border-bottom: solid thin $content-color-40; + padding: 10px 0; + + &:hover{ + background-color: hsla(217,6%,45%,.2); + } + + &:last-child { + border: none; + } + + .cw-unit-progress-item-value, + .cw-unit-progress-item-description { + display: inline-block; + vertical-align: top; + } + + .cw-unit-progress-item-value { + width: 70px; + color: $base-color; + font-size: xx-large; + + .cw-progress-circle { + font-size: 12px; + margin: 4px; + } + } + .cw-unit-progress-item-description { + color: $base-color; + padding-left: 14px; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + padding: 0.5em 0 0 1em; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } +} +/* * * * * * * * * * * * + p r o g r e s s e n d +* * * * * * * * * * * */ + /* * * * * * o b l o n g * * * * * */ @@ -5178,102 +5066,156 @@ cw tiles padding-left: 0; row-gap: 5px; column-gap: 5px; +} +.cw-tiles .tile, +.cw-tile { + height: 420px; + width: 270px; + margin: 0; + background-color: $base-color; + &:last-child { + margin-right: 0; + } - .tile { - height: 420px; - width: 270px; - margin: 0; - background-color: $base-color; - cursor: pointer; - &:last-child { - margin-right: 0; + @each $name, $color in $tile-colors { + &.#{"" + $name} { + background-color: $color; } + }; +} - @each $name, $color in $tile-colors { - &.#{"" + $name} { - background-color: $color; - } - }; +.preview-image { + height: 180px; + width: 100%; + background-size: auto 180px; + background-repeat: no-repeat; + background-color: $content-color-20; + background-position: center; + &.default-image { + @include background-icon(courseware, clickable, 128); } - .preview-image { - height: 180px; - width: 100%; - background-size: auto 180px; - background-repeat: no-repeat; - background-color: $content-color-20; - background-position: center; - &.default-image { - @include background-icon(courseware, clickable, 128); - } + .overlay-text { + padding: 6px 7px; + margin: 4px; + background-color: rgba(255,255,255,0.8); + width: fit-content; + max-width: 100%; + height: 1.25em; + overflow: hidden; + text-overflow: ellipsis; + float: right; + text-align: right; + } - .overlay-text { - padding: 0.25em; - margin: 0.25em; - background-color: rgba(255,255,255,0.8); - width: fit-content; - max-width: 100%; - height: 1.25em; - overflow: hidden; - text-overflow: ellipsis; - float: right; - text-align: right; + .overlay-action-menu { + padding: 0; + margin: 0.25em; + background-color: rgba(255,255,255,0.8); + width: fit-content; + max-width: 100%; + overflow: hidden; + float: right; + text-align: right; + .action-menu { + margin: 5px; } } +} - .description { - height: 220px; - padding: 14px; - color: $white; - position: relative; +.description { + height: 220px; + padding: 14px; + color: $white; + position: relative; + display: block; - header { - font-size: 20px; - line-height: 22px; - color: $white; - border: none; - margin-bottom: 0.75em; - width: 240px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - background-repeat: no-repeat; - background-position: 0 0; + header { + font-size: 20px; + line-height: 22px; + color: $white; + border: none; + width: 240px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + background-repeat: no-repeat; + background-position: 0 0; - @each $type, $icon in $element-icons { - &.description-icon-#{$type} { - width: 212px; - padding-left: 28px; - @include background-icon(#{$icon}, info_alt, 22); - } + @each $type, $icon in $element-icons { + &.description-icon-#{$type} { + width: 212px; + padding-left: 28px; + @include background-icon(#{$icon}, info_alt, 22); } } + } - .description-text-wrapper { - overflow: hidden; - height: 10em; - display: -webkit-box; - margin-bottom: 1em; - -webkit-line-clamp: 7; - -webkit-box-orient: vertical; - p { - text-align: left; + .progress-wrapper { + width: 100%; + padding: 1em 0; + border: none; + background: none; + + progress { + display: block; + width: 100%; + height: 3px; + margin: 0; + border: none; + background: rgba(0,0,0,0.3); + &:-webkit-progress-bar { + background: rgba(0,0,0,0.3); + } + &::-webkit-progress-value { + background: white; + } + &::-moz-progress-bar { + background: white; } } + } - footer { - width: 242px; - text-align: right; - color: $white; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - img { - vertical-align: text-bottom; - } + + .description-text-wrapper { + overflow: hidden; + height: 10em; + margin-top: 0.5em; + display: -webkit-box; + margin-bottom: 1em; + -webkit-line-clamp: 7; + -webkit-box-orient: vertical; + p { + text-align: left; } } + + footer { + width: 242px; + text-align: right; + color: $white; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + img { + vertical-align: text-bottom; + } + } +} + +a[href].description { + transition: unset; +} + +a.description, +a.description:link, +a.description:visited, +a.description:hover { + height: 210px; + color: $white; + text-decoration: unset; } /* @@ -5357,7 +5299,7 @@ cw tiles end .cw-file-input { width: stretch; border: solid thin $base-color; - font-size: 13px; + font-size: 14px; cursor: pointer; &::file-selector-button { @@ -5374,6 +5316,20 @@ cw tiles end } } } + .cw-file-input-change { + border: solid thin $base-color; + + button.button { + padding: 0.5em 1.5em; + margin: 0 0 0 -1px; + line-height: 100%; + border: none; + border-right: solid thin $base-color; + } + span { + padding: 0.5em 1.5em 0.5em 0.5em; + } + } /* * * * * * * * * * * * * i n p u t f i l e e n d * * * * * * * * * * * * * */ @@ -5412,3 +5368,31 @@ a s s i s t i v e /* * * * * * * * * * * * * * * e n d a s s i s t i v e * * * * * * * * * * * * * * */ + +/* * * * * * * * * * * * * * * +w i z a r d e l e m e n t s +* * * * * * * * * * * * * * */ +.cw-element-selector-list { + list-style: none; + padding: 0; + + .cw-element-selector-item { + display: block; + width: 100%; + border: solid thin $content-color-40; + padding: 0.5em; + margin-bottom: 5px; + background-color: $white; + color: $base-color; + text-align: left; + cursor: pointer; + + &:hover { + color: $white; + background-color: $base-color; + } + } +} +/* * * * * * * * * * * * * * * * * * +w i z a r d e l e m e n t s e n d +* * * * * * * * * * * * * * * * * */ diff --git a/resources/assets/stylesheets/scss/dialog.scss b/resources/assets/stylesheets/scss/dialog.scss index d273ccb..eaa4e3b 100644 --- a/resources/assets/stylesheets/scss/dialog.scss +++ b/resources/assets/stylesheets/scss/dialog.scss @@ -307,3 +307,114 @@ h2.dialog-subtitle { margin-top: 0.25em; margin-bottom: 0.25em; } + +/* * * * * * * * * +v u e d i a l o g +* * * * * * * * */ + +.studip-dialog-backdrop { + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + background-color: fade-out($base-color, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 3001; +} +.studip-dialog-body { + position: absolute; + background: $white; + box-shadow: 0 0 8px fade-out($black, 0.5); + overflow-x: auto; + display: flex; + flex-direction: column; + padding: 3px; + margin: 3px; + max-height: 98vh; + + .studip-dialog-header, + .studip-dialog-footer { + padding: 7px; + display: flex; + } + .studip-dialog-header { + background: $base-color none repeat scroll 0 0; + border-bottom: 1px solid $dark-gray-color-10; + color: $white; + justify-content: space-between; + font-size: 1.3em; + padding: 0.5em 1em; + cursor: grab; + + &.drag-active { + cursor: grabbing; + } + } + .studip-dialog-close-button { + @include background-icon(decline, info-alt); + background-repeat: no-repeat; + background-position-y: center; + background-color: transparent; + border: none; + + width: 22px; + height: 22px; + margin-right: -10px; + margin-left: 2em; + cursor: pointer; + } + .studip-dialog-content { + color: $black; + position: relative; + padding: 15px; + overflow-y: auto; + min-width: 100%; + box-sizing: border-box; + } + .studip-dialog-footer { + border-top: 1px solid $dark-gray-color-10; + justify-content: space-between; + } + + &.studip-dialog-warning, + &.studip-dialog-alert { + .studip-dialog-content { + padding: 15px 15px 15px 62px; + background-position: 12px center; + background-repeat: no-repeat; + box-sizing: border-box; + display: flex; + align-items: center; + } + } + + &.studip-dialog-alert { + .studip-dialog-header { + background: $active-color none repeat scroll 0 0; + } + .studip-dialog-content { + @include background-icon(question-circle-full, attention, 32); + } + } + &.studip-dialog-warning { + .studip-dialog-header { + color: $black; + background: $activity-color none repeat scroll 0 0; + } + .studip-dialog-close-button { + @include background-icon(decline, clickable); + border: none; + background-color: transparent; + } + .studip-dialog-content { + @include background-icon(question-circle-full, status-yellow, 32); + } + } + +} +/* * * * * * * * * * * * * +v u e d i a l o g e n d +* * * * * * * * * * * * */ \ No newline at end of file diff --git a/resources/assets/stylesheets/scss/wizard.scss b/resources/assets/stylesheets/scss/wizard.scss new file mode 100644 index 0000000..3ad3650 --- /dev/null +++ b/resources/assets/stylesheets/scss/wizard.scss @@ -0,0 +1,214 @@ +@import '../mixins'; +.wizard-wrapper { + display: flex; + + .wizard-meta { + width: 270px; + min-height: 440px; + margin-top: 38px; + + img { + margin: auto; + display: block; + } + + p { + margin: 15px; + } + .wizard-requirements { + span { + font-weight: 700; + } + ul { + padding: 4px 0; + li { + list-style: none; + button { + padding: 2px 0; + background-color: transparent; + border: none; + color: $base-color; + cursor: pointer; + &:hover { + color: $red; + } + } + img { + padding-right: 4px; + display: inline-block; + vertical-align: sub; + } + } + } + } + } + .wizard-content-wrapper { + flex-grow: 2; + margin-left: 15px; + + h2 span.required { + color: $red; + } + + .wizard-progress { + list-style: none; + padding: 0; + margin: 1.5em 0 2.5em 0; + + li { + display: inline-block; + position: relative; + margin-right: 60px; + border: solid 2px $base-color; + button { + padding: 6px 0; + height: 36px; + width: 36px; + cursor: pointer; + background: no-repeat; + border: none; + } + &.valid { + background-color: $base-color; + } + &.invalid { + background-color: white; + } + &.optional { + border: dashed thin $base-color; + } + &::before { + position: absolute; + content: ""; + width: 62px; + border: solid thin $base-color; + top: 50%; + transform: translateY(-50%); + -o-transform: translateY(-50%); + -ms-transform: translateY(-50%); + -moz-transform: translateY(-50%); + -webkit-transform: translateY(-50%); + left: 100%; + } + &.active::after { + position: absolute; + content: ""; + width: 38px; + height: 3px; + background: $base-color; + top: 44px; + left: -1px; + } + } + li:last-child { + margin-right: 0; + &::before { + display: none; + } + } + + } + + .wizard-list { + list-style: none; + padding: 0; + .wizard-item { + .wizard-content { + max-width: 555px; + max-height: 475px; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: $base-color #f5f5f5; + + .wizard-required { + color: $red; + } + + textarea { + resize: vertical; + } + + input[type="text"]::placeholder, + textarea::placeholder { + color: $dark-gray-color-60; + } + } + } + } + } +} + + +form.default fieldset.radiobutton-set { + > legend { + margin: 0px; + width: 100%; + } + border: none; + padding: 0px; + margin-left: 0px; + margin-right: 0px; + + > input[type=radio] { + opacity: 0; + position: absolute; + &:focus + label { + outline: auto; + } + } + > label { + cursor: pointer; + border: 1px solid $content-color-40; + transition: background-color 200ms; + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px; + padding-bottom: 2px; + margin-bottom: 0; + border-top: none; + :not(.undecorated) { + text-indent: 0; + } + > .text { + width: 100%; + margin-left: 10px; + } + > .unchecked { + margin-right: 0; + } + > .check { + display: none; + } + } + > label:first-of-type { + border-top: 1px solid $content-color-40; + } + > label:last-child::after { + content: none; + } + > div { + border: 1px solid $content-color-40; + border-top: none; + display: none; + padding: 10px; + + } + > input[type=radio]:checked + label { + background-color: $content-color-20; + transition: background-color 200ms; + > .unchecked { + display: none; + } + > .check { + display: inline-block; + } + } + > input[type=radio]:checked + label + div { + display: block; + .description { + animation-duration: 400ms; + animation-name: terms_of_use_fadein; + } + } +} \ No newline at end of file diff --git a/resources/assets/stylesheets/studip.scss b/resources/assets/stylesheets/studip.scss index 804a422..4cbfbd3 100644 --- a/resources/assets/stylesheets/studip.scss +++ b/resources/assets/stylesheets/studip.scss @@ -80,6 +80,10 @@ @import "scss/responsive"; @import "scss/resources"; @import "scss/sidebar"; +@import "scss/tooltip"; +@import "scss/table_of_contents"; +@import "scss/wiki"; +@import "scss/wizard"; @import "scss/select"; @import "scss/selects"; @import "scss/search"; @@ -92,14 +96,11 @@ @import "scss/studygroup"; @import "scss/studip-overlay"; @import "scss/studip-selection"; -@import "scss/table_of_contents"; @import "scss/tabs"; -@import "scss/tooltip"; @import "scss/tfa"; @import "scss/tour"; @import "scss/typography"; @import "scss/user-administration"; -@import "scss/wiki"; @import "scss/multi_person_search"; diff --git a/resources/vue/components/StudipDialog.vue b/resources/vue/components/StudipDialog.vue index 603b8b0..8d88518 100644 --- a/resources/vue/components/StudipDialog.vue +++ b/resources/vue/components/StudipDialog.vue @@ -61,28 +61,37 @@
{{ alert }}
- - - + + +
@@ -106,11 +115,25 @@ export default { VueResizeable, }, props: { - height: {type: String, default: '300'}, - width: {type: String, default: '450'}, + height: { + type: String, + default: '300' + }, + width: { + type: String, + default: '450' + }, title: String, confirmText: String, closeText: String, + confirmShow: { + type: Boolean, + default: true + }, + confirmDisabled: { + type: Boolean, + default: false + }, confirmClass: String, closeClass: String, question: String, @@ -148,10 +171,11 @@ export default { button.text = this.$gettext('Ja'); button.class = 'accept'; } - if (this.confirmText) { + if (this.confirmText && this.confirmShow) { button = {}; button.text = this.confirmText; button.class = this.confirmClass; + button.disabled = this.confirmDisabled } return button; diff --git a/resources/vue/components/StudipWizardDialog.vue b/resources/vue/components/StudipWizardDialog.vue new file mode 100644 index 0000000..7f73c1e --- /dev/null +++ b/resources/vue/components/StudipWizardDialog.vue @@ -0,0 +1,254 @@ + + + diff --git a/resources/vue/components/courseware/ActivitiesApp.vue b/resources/vue/components/courseware/ActivitiesApp.vue new file mode 100644 index 0000000..7bf8e23 --- /dev/null +++ b/resources/vue/components/courseware/ActivitiesApp.vue @@ -0,0 +1,31 @@ + + + \ No newline at end of file diff --git a/resources/vue/components/courseware/AdminApp.vue b/resources/vue/components/courseware/AdminApp.vue index 01bde38..d210558 100644 --- a/resources/vue/components/courseware/AdminApp.vue +++ b/resources/vue/components/courseware/AdminApp.vue @@ -15,6 +15,8 @@ import CoursewareAdminActionWidget from './CoursewareAdminActionWidget.vue'; import CoursewareAdminTemplates from './CoursewareAdminTemplates.vue'; import CoursewareAdminViewWidget from './CoursewareAdminViewWidget.vue'; +import { mapGetters, mapActions } from 'vuex'; + export default { components: { CoursewareAdminActionWidget, @@ -22,9 +24,9 @@ export default { CoursewareAdminViewWidget }, computed: { - adminViewMode() { - return this.$store.getters.adminViewMode; - }, + ...mapGetters({ + adminViewMode: 'adminViewMode' + }), templatesView() { return this.adminViewMode === 'templates'; }, diff --git a/resources/vue/components/courseware/ContentOverviewApp.vue b/resources/vue/components/courseware/ContentOverviewApp.vue deleted file mode 100644 index 831a2e7..0000000 --- a/resources/vue/components/courseware/ContentOverviewApp.vue +++ /dev/null @@ -1,25 +0,0 @@ - - - diff --git a/resources/vue/components/courseware/CoursewareActionWidget.vue b/resources/vue/components/courseware/CoursewareActionWidget.vue index 8dbb7e4..172d2ae 100644 --- a/resources/vue/components/courseware/CoursewareActionWidget.vue +++ b/resources/vue/components/courseware/CoursewareActionWidget.vue @@ -2,54 +2,14 @@ @@ -18,6 +18,8 @@ diff --git a/resources/vue/components/courseware/CoursewareAdminActionWidget.vue b/resources/vue/components/courseware/CoursewareAdminActionWidget.vue index 9a8ce61..6c02699 100644 --- a/resources/vue/components/courseware/CoursewareAdminActionWidget.vue +++ b/resources/vue/components/courseware/CoursewareAdminActionWidget.vue @@ -9,23 +9,25 @@ diff --git a/resources/vue/components/courseware/CoursewareBlockAdderArea.vue b/resources/vue/components/courseware/CoursewareBlockAdderArea.vue index 7bc2513..fc9b41f 100644 --- a/resources/vue/components/courseware/CoursewareBlockAdderArea.vue +++ b/resources/vue/components/courseware/CoursewareBlockAdderArea.vue @@ -14,6 +14,8 @@ diff --git a/resources/vue/components/courseware/CoursewareContentOverviewElements.vue b/resources/vue/components/courseware/CoursewareContentOverviewElements.vue deleted file mode 100644 index 0e6a87f..0000000 --- a/resources/vue/components/courseware/CoursewareContentOverviewElements.vue +++ /dev/null @@ -1,622 +0,0 @@ - - - diff --git a/resources/vue/components/courseware/CoursewareContentOverviewFilterWidget.vue b/resources/vue/components/courseware/CoursewareContentOverviewFilterWidget.vue deleted file mode 100644 index 43fbd1e..0000000 --- a/resources/vue/components/courseware/CoursewareContentOverviewFilterWidget.vue +++ /dev/null @@ -1,99 +0,0 @@ - - - diff --git a/resources/vue/components/courseware/CoursewareCourseDashboard.vue b/resources/vue/components/courseware/CoursewareCourseDashboard.vue deleted file mode 100644 index 5f3b7eb..0000000 --- a/resources/vue/components/courseware/CoursewareCourseDashboard.vue +++ /dev/null @@ -1,93 +0,0 @@ - - - diff --git a/resources/vue/components/courseware/CoursewareDashboardActivities.vue b/resources/vue/components/courseware/CoursewareDashboardActivities.vue deleted file mode 100644 index d068d3a..0000000 --- a/resources/vue/components/courseware/CoursewareDashboardActivities.vue +++ /dev/null @@ -1,110 +0,0 @@ - - - diff --git a/resources/vue/components/courseware/CoursewareDashboardProgress.vue b/resources/vue/components/courseware/CoursewareDashboardProgress.vue deleted file mode 100644 index 6594318..0000000 --- a/resources/vue/components/courseware/CoursewareDashboardProgress.vue +++ /dev/null @@ -1,113 +0,0 @@ - - - diff --git a/resources/vue/components/courseware/CoursewareDashboardProgressItem.vue b/resources/vue/components/courseware/CoursewareDashboardProgressItem.vue deleted file mode 100644 index e5ec0d2..0000000 --- a/resources/vue/components/courseware/CoursewareDashboardProgressItem.vue +++ /dev/null @@ -1,31 +0,0 @@ - - - diff --git a/resources/vue/components/courseware/CoursewareDashboardStudents.vue b/resources/vue/components/courseware/CoursewareDashboardStudents.vue index d0a15ce..6ad27bf 100644 --- a/resources/vue/components/courseware/CoursewareDashboardStudents.vue +++ b/resources/vue/components/courseware/CoursewareDashboardStudents.vue @@ -46,7 +46,7 @@ {{ element.attributes.title }} - {{ task.attributes.progress.toFixed(2) }}% + {{ task.attributes?.progress?.toFixed(2) || '-.--' }}% {{ getReadableDate(task.attributes['submission-date']) }} @@ -110,14 +110,8 @@
-
+ @@ -214,9 +209,11 @@ import StudipIcon from './../StudipIcon.vue'; import StudipDialog from './../StudipDialog.vue'; import CoursewareCompanionBox from './CoursewareCompanionBox.vue'; import CoursewareDateInput from './CoursewareDateInput.vue'; +import CoursewareTasksDialogDistribute from './CoursewareTasksDialogDistribute.vue'; import taskHelperMixin from '../../mixins/courseware/task-helper.js'; import { mapActions, mapGetters } from 'vuex'; + export default { name: 'courseware-dashboard-students', mixins: [taskHelperMixin], @@ -225,6 +222,7 @@ export default { CoursewareDateInput, StudipIcon, StudipDialog, + CoursewareTasksDialogDistribute, }, data() { return { @@ -261,6 +259,7 @@ export default { getElementById: 'courseware-structural-elements/byId', getFeedbackById: 'courseware-task-feedback/byId', relatedTaskGroups: 'courseware-task-groups/related', + showTasksDistributeDialog: 'showTasksDistributeDialog' }), tasks() { return this.allTasks.map((task) => { diff --git a/resources/vue/components/courseware/CoursewareDashboardViewWidget.vue b/resources/vue/components/courseware/CoursewareDashboardViewWidget.vue deleted file mode 100644 index e084827..0000000 --- a/resources/vue/components/courseware/CoursewareDashboardViewWidget.vue +++ /dev/null @@ -1,56 +0,0 @@ - - - diff --git a/resources/vue/components/courseware/CoursewareDefaultContainer.vue b/resources/vue/components/courseware/CoursewareDefaultContainer.vue index b1eef06..fd89439 100644 --- a/resources/vue/components/courseware/CoursewareDefaultContainer.vue +++ b/resources/vue/components/courseware/CoursewareDefaultContainer.vue @@ -105,6 +105,7 @@ export default { }, computed: { ...mapGetters({ + blockAdder: 'blockAdder', userId: 'userId', userById: 'users/byId', viewMode: 'viewMode' @@ -146,6 +147,7 @@ export default { deleteContainer: 'deleteContainer', lockObject: 'lockObject', unlockObject: 'unlockObject', + coursewareBlockAdder: 'coursewareBlockAdder' }), async displayEditDialog() { await this.loadContainer({ id: this.container.id, options: { include: 'edit-blocker' } }); @@ -227,8 +229,8 @@ export default { containerId: this.container.id, structuralElementId: this.container.relationships['structural-element'].data.id, }); - if(Object.keys(this.$store.getters.blockAdder).length !== 0 && this.$store.getters.blockAdder.container.id === this.container.id) { - this.$store.dispatch('coursewareBlockAdder', {}); + if(Object.keys(this.blockAdder).length !== 0 && this.blockAdder.container.id === this.container.id) { + this.coursewareBlockAdder({}); } this.showDeleteDialog = false; }, diff --git a/resources/vue/components/courseware/CoursewareDownloadBlock.vue b/resources/vue/components/courseware/CoursewareDownloadBlock.vue index d5c08d2..403de4b 100644 --- a/resources/vue/components/courseware/CoursewareDownloadBlock.vue +++ b/resources/vue/components/courseware/CoursewareDownloadBlock.vue @@ -148,6 +148,7 @@ export default { ...mapActions({ loadFileRef: 'file-refs/loadById', updateBlock: 'updateBlockInContainer', + updateUserDataFields: 'courseware-user-data-fields/update' }), initCurrentData() { this.currentTitle = this.title; @@ -261,7 +262,7 @@ export default { } } }; - this.$store.dispatch('courseware-user-data-fields/update', data); + this.updateUserDataFields(data); this.userProgress = 1; }, }, diff --git a/resources/vue/components/courseware/CoursewareEmptyElementBox.vue b/resources/vue/components/courseware/CoursewareEmptyElementBox.vue index b64413d..4934e05 100644 --- a/resources/vue/components/courseware/CoursewareEmptyElementBox.vue +++ b/resources/vue/components/courseware/CoursewareEmptyElementBox.vue @@ -1,5 +1,5 @@