diff --git a/.npmrc b/.npmrc index b6f27f135954640c8cc5bfd7b8c9922ca6eb2aad..d5831dd5188814e064c8245b2eab9a5635826522 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,2 @@ engine-strict=true +legacy-peer-deps=true diff --git a/.storybook/main.js b/.storybook/main.js new file mode 100644 index 0000000000000000000000000000000000000000..a42b6252d7fc9ea070931a450172f3a3cce3780e --- /dev/null +++ b/.storybook/main.js @@ -0,0 +1,23 @@ +const path = require('path'); + +module.exports = { + stories: ['../resources/stories/**/*.stories.mdx', '../resources/stories/**/*.stories.@(js|jsx|ts|tsx)'], + addons: ['@storybook/addon-links', '@storybook/addon-essentials', '@storybook/addon-interactions'], + framework: '@storybook/vue', + core: { + builder: '@storybook/builder-webpack5', + options: { + lazyCompilation: true, + fsCache: true, + }, + }, + webpackFinal: async (config, { configType }) => { + config.module.rules.push({ + test: /\.scss$/, + use: ['style-loader', 'css-loader', 'sass-loader'], + include: path.resolve(__dirname, '../'), + }); + + return config; + }, +}; diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html new file mode 100644 index 0000000000000000000000000000000000000000..dfa65689c8911971d2f2c29d6cbed503f3f4555c --- /dev/null +++ b/.storybook/preview-head.html @@ -0,0 +1,38 @@ +<link + media="screen" + rel="stylesheet" + href="%STORYBOOK_STUDIP_ASSETS_URL%/stylesheets/studip-base.css?v=5.2.alpha-svn" +/> +<script> + var STUDIP = { + ASSETS_URL: '%STORYBOOK_STUDIP_ASSETS_URL%/', + ABSOLUTE_URI_STUDIP: '%STORYBOOK_ABSOLUTE_URI_STUDIP%/', + + INSTALLED_LANGUAGES: { + de_DE: { + path: 'de', + picture: 'lang_de.gif', + name: 'Deutsch', + selected: false, + }, + en_GB: { + path: 'en', + picture: 'lang_en.gif', + name: 'English', + selected: true, + }, + }, + STUDIP_SHORT_NAME: 'Stud.IP', + URLHelper: { + base_url: '%STORYBOOK_ABSOLUTE_URI_STUDIP%/', + parameters: {}, + }, + config: { + ACTIONMENU_THRESHOLD: 1, + ENTRIES_PER_PAGE: 20, + OPENGRAPH_ENABLE: false, + }, + jsupdate_enable: false, + wysiwyg_enabled: true, + }; +</script> diff --git a/.storybook/preview.js b/.storybook/preview.js new file mode 100644 index 0000000000000000000000000000000000000000..3a6003bfe076d45416281218bc0f567f066da392 --- /dev/null +++ b/.storybook/preview.js @@ -0,0 +1,43 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import GetTextPlugin from 'vue-gettext'; +import jquery from 'jquery'; +import lodash from 'lodash'; +import eventBus from '../resources/assets/javascripts/lib/event-bus.ts'; +import { ready } from '../resources/assets/javascripts/lib/ready.js'; +import { getLocale, getVueConfig } from '../resources/assets/javascripts/lib/gettext.js'; +import { createURLHelper } from '../resources/assets/javascripts/lib/url_helper.ts'; +import StudipStore from '../resources/vue/store/StudipStore.js'; +import '../resources/assets/javascripts/jquery-bundle.js'; + +Vue.use(GetTextPlugin, getVueConfig()); +Vue.use(Vuex); + +const store = new Vuex.Store({}); +store.registerModule('studip', StudipStore); +Vue.mixin({ + methods: { + getStudipConfig: store.getters['studip/getConfig'], + }, +}); + +globalThis.$ = jquery; +globalThis._ = lodash; + +const URLHelper = createURLHelper({ base_url: process.env.STORYBOOK_ABSOLUTE_URI_STUDIP, parameters: {} }); +globalThis.STUDIP = { + ...globalThis.STUDIP, + eventBus, + ready, + URLHelper, +}; + +export const parameters = { + actions: { argTypesRegex: '^on[A-Z].*' }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/, + }, + }, +}; diff --git a/app/controllers/course/courseware.php b/app/controllers/course/courseware.php index 967d993b763336bea06677fba9fc3f272e9deda0..718f84389d6abf395e2e4abfa7af215189656031 100644 --- a/app/controllers/course/courseware.php +++ b/app/controllers/course/courseware.php @@ -70,11 +70,23 @@ class Course_CoursewareController extends CoursewareController } } - public function tasks_action(): void + public function tasks_action($route = null): void { - global $perm, $user; - $this->is_teacher = $perm->have_studip_perm('tutor', Context::getId(), $user->id); - Navigation::activateItem('course/courseware/tasks'); + $this->is_teacher = $GLOBALS['perm']->have_studip_perm( + 'tutor', + Context::getId(), + $GLOBALS['user']->id + ); + switch ($route) { + case 'peer-review-processes': + Navigation::activateItem('course/courseware/peer-review'); + PageLayout::setTitle(_('Courseware: Peer-Review-Prozesse')); + break; + default: + Navigation::activateItem('course/courseware/tasks'); + PageLayout::setTitle(_('Courseware: Aufgaben')); + break; + } $this->setTasksSidebar(); } diff --git a/db/migrations/5.5.8_add_dates_to_cw_task_groups.php b/db/migrations/5.5.8_add_dates_to_cw_task_groups.php new file mode 100644 index 0000000000000000000000000000000000000000..6ac165ec27bcd0626c9ecf061876017f92ba3ba9 --- /dev/null +++ b/db/migrations/5.5.8_add_dates_to_cw_task_groups.php @@ -0,0 +1,35 @@ +<?php +class AddDatesToCwTaskGroups extends Migration +{ + public function description() + { + return 'TODO'; + } + + public function up() + { + $dbm = \DBManager::get(); + $dbm->exec( + "ALTER TABLE `cw_task_groups` + ADD `start_date` INT NOT NULL AFTER `title`, + ADD `end_date` INT NOT NULL AFTER `start_date`" + ); + $dbm->exec('UPDATE `cw_task_groups` SET `start_date`=`mkdate`'); + $dbm->exec( + 'UPDATE `cw_task_groups` AS tg SET tg.`end_date` = ( SELECT MAX(t.`submission_date`) FROM `cw_tasks` t WHERE t.`task_group_id` = tg.`id` )' + ); + $dbm->exec('ALTER TABLE `cw_tasks` DROP `submission_date`'); + } + + public function down() + { + $dbm = \DBManager::get(); + $dbm->exec("ALTER TABLE `cw_tasks` ADD `submission_date` int(11) NOT NULL AFTER `solver_type`"); + $dbm->exec('UPDATE `cw_tasks` AS t INNER JOIN cw_task_groups tg ON t.`task_group_id` = tg.`id` SET t.`submission_date` = tg.`end_date`'); + $dbm->exec( + 'ALTER TABLE `cw_task_groups` + DROP `start_date`, + DROP `end_date`' + ); + } +} diff --git a/db/migrations/5.5.9_add_peer_review_tables.php b/db/migrations/5.5.9_add_peer_review_tables.php new file mode 100644 index 0000000000000000000000000000000000000000..61c13a585c0a938081ee2811f7197f20144d4eb0 --- /dev/null +++ b/db/migrations/5.5.9_add_peer_review_tables.php @@ -0,0 +1,648 @@ +<?php +class AddPeerReviewTables extends Migration +{ + public function description() + { + return "TODO"; + } + + public function up() + { + $db = \DBManager::get(); + + $db->exec( + "CREATE TABLE `cw_peer_review_processes`( + `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + `task_group_id` INT(11) NOT NULL, + `owner_id` CHAR(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL, + `configuration` MEDIUMTEXT NOT NULL, + `review_start` INT(11) NOT NULL, + `review_end` INT(11) NOT NULL, + `pairingdate` INT(11) NULL, + `mkdate` INT(11) NOT NULL, + `chdate` INT(11) NOT NULL, + PRIMARY KEY(`id`), + INDEX index_task_group_id(`task_group_id`), + INDEX index_owner_id(`owner_id`) + )" + ); + + $db->exec( + "CREATE TABLE `cw_peer_reviews`( + `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + `process_id` INT(11) NOT NULL, + `task_id` INT(11) NOT NULL, + `submitter_id` CHAR(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL, + `reviewer_id` CHAR(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL, + `reviewer_type` ENUM('autor', 'group') COLLATE latin1_bin, + `assessment` TEXT, + `mkdate` INT(11) NOT NULL, + `chdate` INT(11) NOT NULL, + PRIMARY KEY(`id`), + INDEX index_process_id(`process_id`), + INDEX index_task_id(`task_id`), + INDEX index_submitter_id(`submitter_id`), + INDEX index_reviewer_id(`reviewer_id`) + )" + ); + } + + public function down() + { + $db = \DBManager::get(); + $db->exec('DROP TABLE IF EXISTS `cw_peer_reviews`'); + $db->exec('DROP TABLE IF EXISTS `cw_peer_review_processes`'); + } + + + + + /* +-- phpMyAdmin SQL Dump +-- version 5.2.0 +-- https://www.phpmyadmin.net/ +-- +-- Host: mysql +-- Generation Time: Jun 16, 2023 at 08:53 AM +-- Server version: 5.7.38 +-- PHP Version: 8.0.19 + +SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO"; +START TRANSACTION; +SET time_zone = "+00:00"; + +-- +-- Database: `studip` +-- + +-- -------------------------------------------------------- + +-- +-- Table structure for table `clk_answers` +-- + +CREATE TABLE `clk_answers` ( + `answer_id` int(10) UNSIGNED NOT NULL, + `exercise_id` int(10) UNSIGNED NOT NULL, + `answer_user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL, + `working_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL, + `answer_text` text CHARACTER SET utf8mb4, + `chdate_answer` int(10) UNSIGNED DEFAULT NULL, + `notes` text CHARACTER SET utf8mb4, + `chdate_notes` int(10) UNSIGNED DEFAULT NULL, + `notes_user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL, + `feedback` text CHARACTER SET utf8mb4, + `feedback_user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL, + `chdate_feedback` int(10) UNSIGNED DEFAULT NULL, + `rating` tinyint(4) DEFAULT NULL, + `condition_reached` tinyint(4) DEFAULT NULL, + `mkdate` int(10) UNSIGNED NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `clk_exercises` +-- + +CREATE TABLE `clk_exercises` ( + `exercise_id` int(10) UNSIGNED NOT NULL, + `topic_id` int(10) UNSIGNED NOT NULL, + `user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL, + `exercise_title` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `description` text CHARACTER SET utf8mb4, + `mkdate` int(10) UNSIGNED NOT NULL, + `chdate` int(10) UNSIGNED DEFAULT NULL, + `visible` int(10) UNSIGNED DEFAULT NULL, + `deadline` int(10) UNSIGNED DEFAULT NULL, + `sort` smallint(6) DEFAULT NULL, + `review_end` int(10) UNSIGNED DEFAULT NULL, + `peer_review` tinyint(4) UNSIGNED DEFAULT NULL, + `dependency` varchar(32) CHARACTER SET utf8mb4 DEFAULT 'none', + `sample_solution` text CHARACTER SET utf8mb4, + `task` text CHARACTER SET utf8mb4, + `evaluation_id` int(10) UNSIGNED DEFAULT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `clk_extensions` +-- + +CREATE TABLE `clk_extensions` ( + `extension_id` int(10) UNSIGNED NOT NULL, + `exercise_id` int(10) UNSIGNED NOT NULL, + `user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL, + `working_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL, + `extension_date` int(10) UNSIGNED DEFAULT NULL, + `extension_type` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT 'request', + `reason` text CHARACTER SET utf8mb4, + `mkdate` int(10) DEFAULT NULL, + `chdate` int(10) DEFAULT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `clk_file_object` +-- + +CREATE TABLE `clk_file_object` ( + `file_id` int(10) UNSIGNED NOT NULL, + `object_id` int(10) UNSIGNED NOT NULL, + `user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL, + `working_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL, + `object_type` varchar(45) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `file_title` varchar(45) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `description` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `file_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `file_location` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `mime_type` varchar(45) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `file_size` int(11) DEFAULT NULL, + `mkdate` int(10) UNSIGNED DEFAULT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `clk_group_excluded` +-- + +CREATE TABLE `clk_group_excluded` ( + `course_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL, + `group_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `clk_options` +-- + +CREATE TABLE `clk_options` ( + `option_id` int(10) UNSIGNED NOT NULL, + `course_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL, + `description` text CHARACTER SET utf8mb4, + `topic_prefix` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `exercise_prefix` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `display_title` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT 'CloCked', + `groups_only` tinyint(4) DEFAULT NULL, + `peer_review_enabled` tinyint(4) DEFAULT NULL, + `speedgrader_enabled` tinyint(4) DEFAULT NULL, + `motivatoring_active` tinyint(4) DEFAULT NULL, + `rating_enabled` tinyint(4) DEFAULT '1', + `extensions_active` tinyint(4) DEFAULT '1', + `extensions_notification` char(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `feedback_salutation` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `feedback_greets` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `clk_peer_options` +-- + +CREATE TABLE `clk_peer_options` ( + `option_id` int(10) UNSIGNED NOT NULL, + `relation_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL, + `relation_spec` varchar(45) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `peer_anonym` tinyint(4) DEFAULT '1', + `peer_review_type` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `peer_review_period` int(10) UNSIGNED DEFAULT NULL, + `peer_review_form` text COLLATE utf8mb4_unicode_ci, + `peer_tutorial` tinyint(4) DEFAULT '1' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `clk_peer_pairing` +-- + +CREATE TABLE `clk_peer_pairing` ( + `pairing_id` int(10) UNSIGNED NOT NULL, + `relation_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL, + `author_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL, + `reviewer_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL, + `relation_spec` varchar(45) COLLATE utf8mb4_unicode_ci DEFAULT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `clk_peer_review` +-- + +CREATE TABLE `clk_peer_review` ( + `review_id` int(10) UNSIGNED NOT NULL, + `answer_id` int(10) UNSIGNED NOT NULL, + `working_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL, + `review_user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL, + `mkdate` int(10) UNSIGNED DEFAULT NULL, + `chdate` int(10) UNSIGNED DEFAULT NULL, + `review_end` int(10) UNSIGNED DEFAULT NULL, + `review_start` int(10) UNSIGNED DEFAULT NULL, + `peer_review` text CHARACTER SET utf8mb4, + `review_feedback` text COLLATE utf8mb4_unicode_ci, + `review_form` varchar(45) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `publish` tinyint(4) DEFAULT NULL, + `review_published` tinyint(2) DEFAULT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `clk_rating_text` +-- + +CREATE TABLE `clk_rating_text` ( + `rating` int(11) NOT NULL, + `rating_text` tinytext COLLATE utf8mb4_unicode_ci, + `course_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `clk_sg_criteria` +-- + +CREATE TABLE `clk_sg_criteria` ( + `criterium_id` int(10) UNSIGNED NOT NULL, + `evaluation_id` int(10) UNSIGNED NOT NULL, + `user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL, + `description` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `details` text CHARACTER SET utf8mb4, + `mkdate` int(10) UNSIGNED NOT NULL, + `chdate` int(10) UNSIGNED DEFAULT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `clk_sg_evaluations` +-- + +CREATE TABLE `clk_sg_evaluations` ( + `evaluation_id` int(10) UNSIGNED NOT NULL, + `topic_id` int(10) UNSIGNED NOT NULL, + `user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL, + `evaluation_title` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `course_id` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `exercise_id` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `visibility` tinyint(3) DEFAULT '0', + `mkdate` int(10) UNSIGNED NOT NULL, + `chdate` int(10) UNSIGNED DEFAULT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `clk_sg_ratings` +-- + +CREATE TABLE `clk_sg_ratings` ( + `rating_id` int(10) UNSIGNED NOT NULL, + `criterium_id` int(10) UNSIGNED NOT NULL, + `classification` decimal(9,2) DEFAULT NULL, + `description` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `details` text CHARACTER SET utf8mb4, + `mkdate` int(10) UNSIGNED NOT NULL, + `chdate` int(10) UNSIGNED DEFAULT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `clk_sg_userratings` +-- + +CREATE TABLE `clk_sg_userratings` ( + `userratings_id` int(10) UNSIGNED NOT NULL, + `user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL, + `evaluation_id` int(10) UNSIGNED DEFAULT NULL, + `exercise_id` int(10) UNSIGNED DEFAULT NULL, + `answer_id` int(10) UNSIGNED DEFAULT NULL, + `criterium_id` int(10) UNSIGNED DEFAULT NULL, + `classification` decimal(9,2) DEFAULT NULL, + `details` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `mkdate` int(10) UNSIGNED NOT NULL, + `chdate` int(10) UNSIGNED DEFAULT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `clk_tan` +-- + +CREATE TABLE `clk_tan` ( + `tan_id` int(10) UNSIGNED NOT NULL, + `tan` int(10) UNSIGNED DEFAULT NULL, + `relation_id` int(10) UNSIGNED DEFAULT NULL, + `relation` varchar(45) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `tan_working_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `clk_topics` +-- + +CREATE TABLE `clk_topics` ( + `topic_id` int(10) UNSIGNED NOT NULL, + `user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL, + `course_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL, + `topic_title` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `description` text CHARACTER SET utf8mb4, + `mkdate` int(10) UNSIGNED NOT NULL, + `chdate` int(10) UNSIGNED DEFAULT NULL, + `visible` int(10) UNSIGNED DEFAULT NULL, + `sort` smallint(6) DEFAULT NULL, + `evaluation_id` int(10) UNSIGNED DEFAULT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `clk_topic_excluded` +-- + +CREATE TABLE `clk_topic_excluded` ( + `topic_id` int(10) NOT NULL, + `working_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `clk_user_options` +-- + +CREATE TABLE `clk_user_options` ( + `option_id` int(10) UNSIGNED NOT NULL, + `user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL, + `course_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL, + `show_progress` tinyint(4) DEFAULT NULL, + `deadline_notify` tinyint(4) DEFAULT NULL, + `show_result_overview` tinyint(4) DEFAULT NULL, + `result_list_type` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT 'items' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `clk_visits` +-- + +CREATE TABLE `clk_visits` ( + `object_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL, + `user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL, + `visit_type` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL, + `last_visitdate` int(10) UNSIGNED NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; + +-- +-- Indexes for dumped tables +-- + +-- +-- Indexes for table `clk_answers` +-- +ALTER TABLE `clk_answers` + ADD PRIMARY KEY (`answer_id`), + ADD KEY `ix_chdate_answer` (`chdate_answer`), + ADD KEY `ix_exercise_id` (`exercise_id`), + ADD KEY `ix_chdate_feedback` (`chdate_feedback`), + ADD KEY `ix_working_id` (`working_id`); + +-- +-- Indexes for table `clk_exercises` +-- +ALTER TABLE `clk_exercises` + ADD PRIMARY KEY (`exercise_id`), + ADD KEY `ix_visible` (`visible`), + ADD KEY `ix_last_edit` (`deadline`), + ADD KEY `ix_user_id` (`user_id`); + +-- +-- Indexes for table `clk_extensions` +-- +ALTER TABLE `clk_extensions` + ADD PRIMARY KEY (`extension_id`), + ADD KEY `IX_EXERCISE_ID` (`exercise_id`), + ADD KEY `IX_USER_ID` (`user_id`), + ADD KEY `IX_WORKING_ID` (`working_id`); + +-- +-- Indexes for table `clk_file_object` +-- +ALTER TABLE `clk_file_object` + ADD PRIMARY KEY (`file_id`), + ADD KEY `ix_user_id` (`user_id`), + ADD KEY `ix_object_Id` (`object_id`), + ADD KEY `ix_working_id` (`working_id`); + +-- +-- Indexes for table `clk_group_excluded` +-- +ALTER TABLE `clk_group_excluded` + ADD PRIMARY KEY (`course_id`,`group_id`); + +-- +-- Indexes for table `clk_options` +-- +ALTER TABLE `clk_options` + ADD PRIMARY KEY (`option_id`), + ADD KEY `ix_course_id` (`course_id`); + +-- +-- Indexes for table `clk_peer_options` +-- +ALTER TABLE `clk_peer_options` + ADD PRIMARY KEY (`option_id`), + ADD KEY `ix_relation_id` (`relation_id`); + +-- +-- Indexes for table `clk_peer_pairing` +-- +ALTER TABLE `clk_peer_pairing` + ADD PRIMARY KEY (`pairing_id`), + ADD KEY `IX_COURSE_ID` (`relation_id`); + +-- +-- Indexes for table `clk_peer_review` +-- +ALTER TABLE `clk_peer_review` + ADD PRIMARY KEY (`review_id`), + ADD KEY `IX_ANSWER_ID` (`answer_id`), + ADD KEY `IX_WORKING_ID` (`working_id`); + +-- +-- Indexes for table `clk_rating_text` +-- +ALTER TABLE `clk_rating_text` + ADD PRIMARY KEY (`rating`,`course_id`); + +-- +-- Indexes for table `clk_sg_criteria` +-- +ALTER TABLE `clk_sg_criteria` + ADD PRIMARY KEY (`criterium_id`); + +-- +-- Indexes for table `clk_sg_evaluations` +-- +ALTER TABLE `clk_sg_evaluations` + ADD PRIMARY KEY (`evaluation_id`); + +-- +-- Indexes for table `clk_sg_ratings` +-- +ALTER TABLE `clk_sg_ratings` + ADD PRIMARY KEY (`rating_id`); + +-- +-- Indexes for table `clk_sg_userratings` +-- +ALTER TABLE `clk_sg_userratings` + ADD PRIMARY KEY (`userratings_id`); + +-- +-- Indexes for table `clk_tan` +-- +ALTER TABLE `clk_tan` + ADD PRIMARY KEY (`tan_id`), + ADD KEY `ix_relation_id` (`relation_id`); + +-- +-- Indexes for table `clk_topics` +-- +ALTER TABLE `clk_topics` + ADD PRIMARY KEY (`topic_id`), + ADD KEY `ix_user_id` (`user_id`), + ADD KEY `ix_course_id` (`course_id`), + ADD KEY `ix_visible_at` (`visible`); + +-- +-- Indexes for table `clk_topic_excluded` +-- +ALTER TABLE `clk_topic_excluded` + ADD PRIMARY KEY (`topic_id`,`working_id`); + +-- +-- Indexes for table `clk_user_options` +-- +ALTER TABLE `clk_user_options` + ADD PRIMARY KEY (`option_id`), + ADD KEY `ix_user_id` (`user_id`), + ADD KEY `ix_course_id` (`course_id`); + +-- +-- Indexes for table `clk_visits` +-- +ALTER TABLE `clk_visits` + ADD PRIMARY KEY (`object_id`,`user_id`,`visit_type`), + ADD KEY `ix_last_visit` (`last_visitdate`), + ADD KEY `ix_user_id` (`user_id`); + +-- +-- AUTO_INCREMENT for dumped tables +-- + +-- +-- AUTO_INCREMENT for table `clk_answers` +-- +ALTER TABLE `clk_answers` + MODIFY `answer_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT; + +-- +-- AUTO_INCREMENT for table `clk_exercises` +-- +ALTER TABLE `clk_exercises` + MODIFY `exercise_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT; + +-- +-- AUTO_INCREMENT for table `clk_extensions` +-- +ALTER TABLE `clk_extensions` + MODIFY `extension_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT; + +-- +-- AUTO_INCREMENT for table `clk_file_object` +-- +ALTER TABLE `clk_file_object` + MODIFY `file_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT; + +-- +-- AUTO_INCREMENT for table `clk_options` +-- +ALTER TABLE `clk_options` + MODIFY `option_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT; + +-- +-- AUTO_INCREMENT for table `clk_peer_options` +-- +ALTER TABLE `clk_peer_options` + MODIFY `option_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT; + +-- +-- AUTO_INCREMENT for table `clk_peer_pairing` +-- +ALTER TABLE `clk_peer_pairing` + MODIFY `pairing_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT; + +-- +-- AUTO_INCREMENT for table `clk_peer_review` +-- +ALTER TABLE `clk_peer_review` + MODIFY `review_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT; + +-- +-- AUTO_INCREMENT for table `clk_sg_criteria` +-- +ALTER TABLE `clk_sg_criteria` + MODIFY `criterium_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT; + +-- +-- AUTO_INCREMENT for table `clk_sg_evaluations` +-- +ALTER TABLE `clk_sg_evaluations` + MODIFY `evaluation_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT; + +-- +-- AUTO_INCREMENT for table `clk_sg_ratings` +-- +ALTER TABLE `clk_sg_ratings` + MODIFY `rating_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT; + +-- +-- AUTO_INCREMENT for table `clk_sg_userratings` +-- +ALTER TABLE `clk_sg_userratings` + MODIFY `userratings_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT; + +-- +-- AUTO_INCREMENT for table `clk_tan` +-- +ALTER TABLE `clk_tan` + MODIFY `tan_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT; + +-- +-- AUTO_INCREMENT for table `clk_topics` +-- +ALTER TABLE `clk_topics` + MODIFY `topic_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT; + +-- +-- AUTO_INCREMENT for table `clk_user_options` +-- +ALTER TABLE `clk_user_options` + MODIFY `option_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT; +COMMIT; + + */ +} diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php index 2eb33a815e2869bf776046c189f7cc93f0b47e91..c5a3cf981f103d777a3a2910b270269b80ec5f2e 100644 --- a/lib/classes/JsonApi/RouteMap.php +++ b/lib/classes/JsonApi/RouteMap.php @@ -489,6 +489,8 @@ class RouteMap $group->get('/courseware-task-groups/{id}', Routes\Courseware\TaskGroupsShow::class); $group->post('/courseware-task-groups', Routes\Courseware\TaskGroupsCreate::class); + $group->patch('/courseware-task-groups/{id}', Routes\Courseware\TaskGroupsUpdate::class); + $group->delete('/courseware-task-groups/{id}', Routes\Courseware\TaskGroupsDelete::class); $group->get('/courseware-task-feedback/{id}', Routes\Courseware\TaskFeedbackShow::class); $group->post('/courseware-task-feedback', Routes\Courseware\TaskFeedbackCreate::class); @@ -525,6 +527,22 @@ class RouteMap $group->delete('/courseware-clipboards/{id}', Routes\Courseware\ClipboardsDelete::class); $group->post('/courseware-clipboards/{id}/insert', Routes\Courseware\ClipboardsInsert::class); + + $group->get('/courseware-peer-review-processes', Routes\Courseware\PeerReview\ProcessesIndex::class); + $group->get('/courseware-peer-review-processes/{id}', Routes\Courseware\PeerReview\ProcessesShow::class); + $group->get('/courseware-peer-review-processes/{id}/peer-reviews', Routes\Courseware\PeerReview\ReviewsOfProcessesIndex::class); + + $group->patch('/courseware-peer-review-processes/{id}', Routes\Courseware\PeerReview\ProcessesUpdate::class); + $group->delete('/courseware-peer-review-processes/{id}', Routes\Courseware\PeerReview\ProcessesDelete::class); + + $group->post('/courseware-peer-review-processes', Routes\Courseware\PeerReview\ProcessesCreate::class); + + $group->get('/courses/{id}/courseware-peer-reviews', Routes\Courseware\PeerReview\ReviewsIndex::class); + $group->get('/courseware-tasks/{id}/peer-reviews', Routes\Courseware\PeerReview\ReviewsByTaskIndex::class); + + $group->post('/courseware-peer-reviews', Routes\Courseware\PeerReview\ReviewsCreate::class); + $group->patch('/courseware-peer-reviews/{id}', Routes\Courseware\PeerReview\ReviewsUpdate::class); + $group->delete('/courseware-peer-reviews/{id}', Routes\Courseware\PeerReview\ReviewsDelete::class); } private function addAuthenticatedFilesRoutes(RouteCollectorProxy $group): void diff --git a/lib/classes/JsonApi/Routes/Courseware/Authority.php b/lib/classes/JsonApi/Routes/Courseware/Authority.php index 0f837dee3dabb22ed75126f2f15402554b85a5b4..d6e3a7db77fff67aed9c654310196050a72603b4 100644 --- a/lib/classes/JsonApi/Routes/Courseware/Authority.php +++ b/lib/classes/JsonApi/Routes/Courseware/Authority.php @@ -8,6 +8,8 @@ use Courseware\BlockFeedback; use Courseware\Clipboard; use Courseware\Container; use Courseware\Instance; +use Courseware\PeerReview; +use Courseware\PeerReviewProcess; use Courseware\StructuralElement; use Courseware\StructuralElementComment; use Courseware\StructuralElementFeedback; @@ -23,7 +25,13 @@ use User; use Course; /** + * @SuppressWarnings(PHPMD.CamelCaseParameterName) + * @SuppressWarnings(PHPMD.CamelCaseVariableName) + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) + * @SuppressWarnings(PHPMD.ExcessivePublicCount) + * @SuppressWarnings(PHPMD.StaticAccess) + * @SuppressWarnings(PHPMD.Superglobals) * @SuppressWarnings(PHPMD.TooManyMethods) * @SuppressWarnings(PHPMD.TooManyPublicMethods) */ @@ -306,9 +314,20 @@ class Authority return $resource['lecturer_id'] === $user->id; } + public static function canUpdateTaskGroup(User $user, TaskGroup $resource): bool + { + return self::canCreateTasks($user, $resource->target); + } + + public static function canDeleteTaskGroup(User $user, TaskGroup $resource): bool + { + return self::canUpdateTaskGroup($user, $resource); + } + public static function canShowTask(User $user, Task $resource): bool { - return self::canUpdateTask($user, $resource); + return ($resource->isPeerReviewed() && $resource->isPeerReviewedBy($user)) || + self::canUpdateTask($user, $resource); } public static function canIndexTasks(User $user): bool @@ -332,6 +351,11 @@ class Authority return self::canCreateTasks($user, $resource->structural_element) && !$resource->userIsASolver($user); } + public static function canRenewTask(User $user, Task $resource): bool + { + return self::canDeleteTask($user, $resource); + } + public static function canCreateTaskFeedback(User $user, Task $resource): bool { return self::canCreateTasks($user, $resource->structural_element); @@ -352,7 +376,6 @@ class Authority return self::canCreateTaskFeedback($user, $resource); } - public static function canIndexStructuralElementComments(User $user, StructuralElement $resource) { return self::canShowStructuralElement($user, $resource); @@ -407,7 +430,8 @@ class Authority public static function canShowStructuralElementFeedback(User $user, StructuralElementFeedback $resource) { - return $resource->user_id === $user->id || self::canUpdateStructuralElement($user, $resource->structural_element); + return $resource->user_id === $user->id || + self::canUpdateStructuralElement($user, $resource->structural_element); } public static function canDeleteStructuralElementFeedback(User $user, StructuralElementComment $resource) @@ -415,7 +439,6 @@ class Authority return self::canUpdateStructuralElementFeedback($user, $resource); } - public static function canShowTemplate(User $user, Template $resource) { // templates are for everybody, aren't they? @@ -430,7 +453,7 @@ class Authority public static function canCreateTemplate(User $user) { - return $GLOBALS['perm']->have_perm('admin'); + return $GLOBALS['perm']->have_perm('admin', $user->id); } public static function canUpdateTemplate(User $user, Template $resource) @@ -490,7 +513,7 @@ class Authority if ($user->id === $range->id) { return true; } - return $GLOBALS['perm']->have_studip_perm('tutor', $range->id ,$user->id); + return $GLOBALS['perm']->have_studip_perm('tutor', $range->id, $user->id); } public static function canUpdateUnit(User $user, Unit $resource): bool @@ -513,7 +536,6 @@ class Authority return $request_user->id === $user->id; } - public static function canShowClipboard(User $user, Clipboard $resource): bool { return $resource->user_id === $user->id; @@ -536,7 +558,7 @@ class Authority } else { $structural_element = $resource->getStructuralElement(); } - + return $structural_element->canEdit($user); } @@ -560,4 +582,89 @@ class Authority return $resource->user_id === $user->id; } + public static function canIndexPeerReviewProcesses(User $user): bool + { + return (bool) $user; + } + + public static function canShowPeerReviewProcess(User $user, PeerReviewProcess $process): bool + { + return $GLOBALS['perm']->have_studip_perm('user', $process->task_group['seminar_id'], $user->getId()); + } + + public static function canCreatePeerReviewProcesses(User $user, TaskGroup $taskGroup): bool + { + return $GLOBALS['perm']->have_studip_perm('tutor', $taskGroup['seminar_id'], $user->getId()); + } + + public static function canUpdatePeerReviewProcess(User $user, PeerReviewProcess $process): bool + { + return self::canCreatePeerReviewProcesses($user, $process->task_group); + } + + public static function canDeletePeerReviewProcess(User $user, PeerReviewProcess $process): bool + { + return self::canCreatePeerReviewProcess($user, $process->task_group); + } + + public static function canIndexPeerReviews(User $user) + { + // TODO: Reicht das? Werden die in der Route gefiltert? Brauchen das nur Lehrende? + return (bool) $user; + } + + public static function canShowPeerReview(User $user, PeerReview $review): bool + { + $cid = $review->process->task_group['seminar_id']; + if ($GLOBALS['perm']->have_studip_perm('tutor', $cid, $user->getId())) { + return true; + } + + return $review->isReviewer($user); + } + + public static function canShowPeerReviewSubmitter(User $user, PeerReview $review): bool + { + $cid = $review->process->task_group['seminar_id']; + if ($GLOBALS['perm']->have_studip_perm('tutor', $cid, $user->getId())) { + return true; + } + + return $review->isReviewer($user) && !$review->isAnonymous(); + } + + public static function canShowPeerReviewAssessment(User $user, PeerReview $review): bool + { + if ($review->isReviewer($user)) { + return true; + } + + $isTutor = $GLOBALS['perm']->have_studip_perm( + 'tutor', + $review->process->task_group['seminar_id'], + $user->getId() + ); + + return $isTutor && $review->process->getCurrentState() === PeerReviewProcess::STATE_AFTER; + } + + public static function canIndexReviewsOfProcesses(User $user, PeerReviewProcess $process): bool + { + return self::canShowPeerReviewProcess($user, $process); + } + + public static function canUpdatePeerReview(User $user, PeerReview $review): bool + { + return $review->process->getCurrentState() === PeerReviewProcess::STATE_ACTIVE && $review->isReviewer($user); + } + + public static function canCreatePeerReviews(User $user, PeerReviewProcess $process): bool + { + return self::canCreatePeerReviewProcesses($user, $process->task_group); + } + + public static function canDeletePeerReview(User $user, PeerReview $review): bool + { + return self::canCreatePeerReviews($user, $review->process); + } } diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesCreate.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesCreate.php new file mode 100644 index 0000000000000000000000000000000000000000..8084711d4294ac4c4ffaf8a86a9a53300f227685 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesCreate.php @@ -0,0 +1,126 @@ +<?php + +namespace JsonApi\Routes\Courseware\PeerReview; + +use Courseware\PeerReviewProcess; +use Courseware\TaskGroup; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\JsonApiController; +use JsonApi\Routes\Courseware\Authority; +use JsonApi\Routes\TimestampTrait; +use JsonApi\Routes\ValidationTrait; +use JsonApi\Schemas\Courseware\PeerReviewProcess as PeerReviewProcessSchema; +use JsonApi\Schemas\Courseware\TaskGroup as TaskGroupSchema; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** + * Create a PeerReviewProcess. + * + * @SuppressWarnings(PHPMD.StaticAccess) + */ +class ProcessesCreate extends JsonApiController +{ + use TimestampTrait; + use ValidationTrait; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * + * @param array $args + * + * @return Response + */ + public function __invoke(Request $request, Response $response, $args) + { + $json = $this->validate($request); + $taskGroup = $this->getTaskGroupFromJson($json); + $user = $this->getUser($request); + + if (!Authority::canCreatePeerReviewProcesses($user, $taskGroup)) { + throw new AuthorizationFailedException(); + } + + $process = $this->create($user, $json); + + return $this->getCreatedResponse($process); + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameters) + * + * @param array $json + * @param mixed $data + * + * @return string|void + */ + protected function validateResourceDocument($json, $data) + { + if (!self::arrayHas($json, 'data')) { + return 'Missing `data` member at document´s top level.'; + } + if (PeerReviewProcessSchema::TYPE !== self::arrayGet($json, 'data.type')) { + return 'Invalid `type` of document´s `data`.'; + } + if (self::arrayHas($json, 'data.id')) { + return 'New document must not have an `id`.'; + } + + if (!self::arrayHas($json, 'data.attributes.configuration')) { + return 'Missing `configuration` attribute.'; + } + // TODO: validate configuration + + if (!self::arrayHas($json, 'data.attributes.review-start')) { + return 'Missing `review-start` attribute.'; + } + $startDate = self::arrayGet($json, 'data.attributes.review-start'); + if (!self::isValidTimestamp($startDate)) { + return '`review-start` is not an ISO 8601 timestamp.'; + } + + if (!self::arrayHas($json, 'data.attributes.review-end')) { + return 'Missing `review-end` attribute.'; + } + $endDate = self::arrayGet($json, 'data.attributes.review-end'); + if (!self::isValidTimestamp($endDate)) { + return '`review-end` is not an ISO 8601 timestamp.'; + } + + if (!self::arrayHas($json, 'data.relationships.task-group')) { + return 'Missing `task-group` relationship.'; + } + if (!$this->getTaskGroupFromJson($json)) { + return 'Invalid `task-group` relationship.'; + } + } + + private function getTaskGroupFromJson(array $json): ?TaskGroup + { + if (!$this->validateResourceObject($json, 'data.relationships.task-group', TaskGroupSchema::TYPE)) { + return null; + } + $resourceId = self::arrayGet($json, 'data.relationships.task-group.data.id'); + + return TaskGroup::find($resourceId); + } + + private function create(\User $user, array $json): PeerReviewProcess + { + $taskGroup = $this->getTaskGroupFromJson($json); + $startDate = self::fromISO8601(self::arrayGet($json, 'data.attributes.review-start')); + $endDate = self::fromISO8601(self::arrayGet($json, 'data.attributes.review-end')); + $configuration = self::arrayGet($json, 'data.attributes.configuration'); + + /** @var PeerReviewProcess $process */ + $process = PeerReviewProcess::create([ + 'task_group_id' => $taskGroup->getId(), + 'owner_id' => $user->getId(), + 'configuration' => $configuration, + 'review_start' => $startDate->getTimestamp(), + 'review_end' => $endDate->getTimestamp(), + ]); + + return $process; + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesDelete.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesDelete.php new file mode 100644 index 0000000000000000000000000000000000000000..b9ba42eb3ed4f6d54555765862b542ea50595863 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesDelete.php @@ -0,0 +1,39 @@ +<?php + +namespace JsonApi\Routes\Courseware\PeerReview; + +use Courseware\PeerPreviewProcess; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\JsonApiController; +use JsonApi\Routes\Courseware\Authority; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** + * Delete one PeerPreviewProcess. + */ +class ProcessesDelete extends JsonApiController +{ + /** + * @param array $args + * @return Response + * + * @SuppressWarnings(PHPMD.StaticAccess) + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + /** @var ?PeerPreviewProcess $resource */ + $resource = PeerPreviewProcess::find($args['id']); + if (!$resource) { + throw new RecordNotFoundException(); + } + if (!Authority::canDeletePeerReviewProcess($this->getUser($request), $resource)) { + throw new AuthorizationFailedException(); + } + $resource->delete(); + + return $this->getCodeResponse(204); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesIndex.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesIndex.php new file mode 100644 index 0000000000000000000000000000000000000000..42bee8a01385f30416b611629adfc682a8e58c6a --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesIndex.php @@ -0,0 +1,108 @@ +<?php + +namespace JsonApi\Routes\Courseware\PeerReview; + +use Course; +use Courseware\PeerReviewProcess; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\BadRequestException; +use JsonApi\JsonApiController; +use JsonApi\Routes\Courses\Authority as CoursesAuthority; +use JsonApi\Routes\Courseware\Authority; +use JsonApi\Schemas\Courseware\PeerReviewProcess as ProcessSchema; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use User; + +/** + * Displays all visible PeerReviewProcesses. + * + * @SuppressWarnings(PHPMD.LongVariable) + * @SuppressWarnings(PHPMD.StaticAccess) + */ +class ProcessesIndex extends JsonApiController +{ + protected $allowedFilteringParameters = ['cid']; + + protected $allowedIncludePaths = [ + ProcessSchema::REL_COURSE, + ProcessSchema::REL_OWNER, + ProcessSchema::REL_TASK_GROUP, + ]; + + protected $allowedPagingParameters = ['offset', 'limit']; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * + * @param array $args + * + * @return Response + */ + public function __invoke(Request $request, Response $response, $args) + { + $user = $this->getUser($request); + $filtering = $this->getQueryParameters()->getFilteringParameters() ?: []; + + $this->validateFilters($filtering); + $this->authorize($user, $filtering); + + $resources = empty($filtering) ? $this->findAllProcesses($user) : $this->filterProcesses($user, $filtering); + + return $this->getPaginatedContentResponse( + array_slice($resources, ...$this->getOffsetAndLimit()), + count($resources) + ); + } + + /** + * @throws BadRequestException + */ + private function validateFilters(array $filtering): void + { + if (isset($filtering['cid']) && !Course::exists($filtering['cid'])) { + throw new BadRequestException('Could not find a course matching this `filter[cid]`.'); + } + } + + /** + * @throws AuthorizationFailedException + */ + private function authorize(User $user, array $filtering): void + { + if (!Authority::canIndexPeerReviewProcesses($user)) { + throw new AuthorizationFailedException(); + } + + if (isset($filtering['cid'])) { + if ( + !CoursesAuthority::canShowCourse( + $user, + Course::find($filtering['cid']), + CoursesAuthority::SCOPE_EXTENDED + ) + ) { + throw new AuthorizationFailedException(); + } + } + } + + private function findAllProcesses(User $user): iterable + { + return PeerReviewProcess::findByUser($user); + } + + private function filterProcesses(User $user, array $filtering): iterable + { + if (isset($filtering['cid'])) { + /** @var ?\Course $course */ + $course = \Course::find($filtering['cid']); + + return array_filter(PeerReviewProcess::findByCourse($course), function ($process) use ($user) { + return Authority::canShowPeerReviewProcess($user, $process); + }); + } + + return []; + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesShow.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesShow.php new file mode 100644 index 0000000000000000000000000000000000000000..3d904217c758f9d1eb1891a9afc16384b525bd1f --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesShow.php @@ -0,0 +1,49 @@ +<?php + +namespace JsonApi\Routes\Courseware\PeerReview; + +use Course; +use Courseware\PeerReviewProcess; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\Routes\Courseware\Authority; +use JsonApi\Schemas\Courseware\PeerReviewProcess as ProcessSchema; +use JsonApi\JsonApiController; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use User; + +/** + * Displays one PeerReviewProcess. + * + * @SuppressWarnings(PHPMD.LongVariable) + * @SuppressWarnings(PHPMD.StaticAccess) + */ +class ProcessesShow extends JsonApiController +{ + protected $allowedIncludePaths = [ + ProcessSchema::REL_COURSE, + ProcessSchema::REL_OWNER, + ProcessSchema::REL_TASK_GROUP, + ]; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @param array $args + * @return Response + */ + public function __invoke(Request $request, Response $response, $args) + { + /** @var ?\Courseware\PeerReviewProcess $resource */ + $resource = PeerReviewProcess::find($args['id']); + if (!$resource) { + throw new RecordNotFoundException(); + } + + if (!Authority::canShowPeerReviewProcess($this->getUser($request), $resource)) { + throw new AuthorizationFailedException(); + } + + return $this->getContentResponse($resource); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesUpdate.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesUpdate.php new file mode 100644 index 0000000000000000000000000000000000000000..75ed1b8c47fefcaa59039738d0cfc9e9b69ea327 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesUpdate.php @@ -0,0 +1,123 @@ +<?php + +namespace JsonApi\Routes\Courseware\PeerReview; + +use Courseware\PeerReviewProcess; +use Courseware\TaskGroup; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\Routes\Courseware\Authority; +use JsonApi\Schemas\Courseware\PeerReviewProcess as ProcessSchema; +use JsonApi\JsonApiController; +use JsonApi\Routes\TimestampTrait; +use JsonApi\Routes\ValidationTrait; +use JsonApi\Schemas\Courseware\PeerReviewProcess as PeerReviewProcessSchema; +use JsonApi\Schemas\Courseware\TaskGroup as TaskGroupSchema; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use User; + +/** + * Updates one PeerReviewProcess. + * + * @SuppressWarnings(PHPMD.LongVariable) + * @SuppressWarnings(PHPMD.StaticAccess) + */ +class ProcessesUpdate extends JsonApiController +{ + use TimestampTrait; + use ValidationTrait; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @param array $args + * @return Response + */ + public function __invoke(Request $request, Response $response, $args) + { + /** @var ?\Courseware\PeerReviewProcess $resource */ + $resource = PeerReviewProcess::find($args['id']); + if (!$resource) { + throw new RecordNotFoundException(); + } + $json = $this->validate($request, $resource); + $user = $this->getUser($request); + if (!Authority::canUpdatePeerReviewProcess($user, $resource)) { + throw new AuthorizationFailedException(); + } + + $process = $this->update($user, $resource, $json); + + return $this->getContentResponse($process); + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameters) + * + * @param array $json + * @param mixed $data + * + * @return string|void + */ + protected function validateResourceDocument($json, $data) + { + if (!self::arrayHas($json, 'data')) { + return 'Missing `data` member at document´s top level.'; + } + if (PeerReviewProcessSchema::TYPE !== self::arrayGet($json, 'data.type')) { + return 'Invalid `type` of document´s `data`.'; + } + + if (!self::arrayHas($json, 'data.attributes.configuration')) { + return 'Missing `configuration` attribute.'; + } + // TODO: validate configuration + + if (!self::arrayHas($json, 'data.attributes.review-start')) { + return 'Missing `review-start` attribute.'; + } + $startDate = self::arrayGet($json, 'data.attributes.review-start'); + if (!self::isValidTimestamp($startDate)) { + return '`review-start` is not an ISO 8601 timestamp.'; + } + + if (!self::arrayHas($json, 'data.attributes.review-end')) { + return 'Missing `review-end` attribute.'; + } + $endDate = self::arrayGet($json, 'data.attributes.review-end'); + if (!self::isValidTimestamp($endDate)) { + return '`review-end` is not an ISO 8601 timestamp.'; + } + + if (self::arrayHas($json, 'data.relationships.task-group')) { + if (!$this->getTaskGroupFromJson($json)) { + return 'Invalid `task-group` relationship.'; + } + } + } + + private function getTaskGroupFromJson(array $json): ?TaskGroup + { + if (!$this->validateResourceObject($json, 'data.relationships.task-group', TaskGroupSchema::TYPE)) { + return null; + } + $resourceId = self::arrayGet($json, 'data.relationships.task-group.data.id'); + + return TaskGroup::find($resourceId); + } + + private function update(User $user, PeerReviewProcess $process, array $json): PeerReviewProcess + { + $startDate = self::fromISO8601(self::arrayGet($json, 'data.attributes.review-start')); + $endDate = self::fromISO8601(self::arrayGet($json, 'data.attributes.review-end')); + $configuration = self::arrayGet($json, 'data.attributes.configuration'); + + $process->review_start = $startDate->getTimestamp(); + $process->review_end = $endDate->getTimestamp(); + $process->configuration = $configuration; + + $process->store(); + + return $process; + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsByTaskIndex.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsByTaskIndex.php new file mode 100644 index 0000000000000000000000000000000000000000..4c8441deffa094bd268207220eb77de498a50785 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsByTaskIndex.php @@ -0,0 +1,80 @@ +<?php + +namespace JsonApi\Routes\Courseware\PeerReview; + +use Courseware\PeerReview; +use Courseware\PeerReviewProcess; +use Courseware\Task; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\BadRequestException; +use JsonApi\JsonApiController; +use JsonApi\Routes\Courses\Authority as CoursesAuthority; +use JsonApi\Routes\Courseware\Authority; +use JsonApi\Schemas\Courseware\PeerReview as PeerReviewSchema; +use JsonApi\Schemas\Courseware\Task as TaskSchema; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use User; + +/** + * Displays all PeerReviews of a course. + * + * @SuppressWarnings(PHPMD.LongVariable) + * @SuppressWarnings(PHPMD.StaticAccess) + */ +class ReviewsByTaskIndex extends JsonApiController +{ + protected $allowedIncludePaths = [ + PeerReviewSchema::REL_PROCESS, + PeerReviewSchema::REL_REVIEWER, + // PeerReviewSchema::REL_SUBMITTER, + PeerReviewSchema::REL_TASK, + PeerReviewSchema::REL_TASK . '.' . TaskSchema::REL_STRUCTURAL_ELEMENT, + PeerReviewSchema::REL_TASK . '.' . TaskSchema::REL_TASK_GROUP, + ]; + + protected $allowedPagingParameters = ['offset', 'limit']; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * + * @param array $args + * + * @return Response + */ + public function __invoke(Request $request, Response $response, $args) + { + /** @var ?Task $task */ + $task = Task::find($args['id']); + if (!$task) { + throw new RecordNotFoundException(); + } + + $user = $this->getUser($request); + $this->authorize($user); + + $resources = $this->findPeerReviews($task, $user); + + return $this->getPaginatedContentResponse( + $resources->limit(...$this->getOffsetAndLimit()), + count($resources) + ); + } + + /** + * @throws AuthorizationFailedException + */ + private function authorize(User $user): void + { + if (!Authority::canIndexPeerReviews($user)) { + throw new AuthorizationFailedException(); + } + } + + private function findPeerReviews(Task $task, User $user): iterable + { + return $task->peer_reviews->filter(function ($peerReview) use ($user) { + return Authority::canShowPeerReview($user, $peerReview); + }); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsCreate.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsCreate.php new file mode 100644 index 0000000000000000000000000000000000000000..26e566d2cc7ddd0fb251670e3d6efbca1d4d8ea3 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsCreate.php @@ -0,0 +1,184 @@ +<?php + +namespace JsonApi\Routes\Courseware\PeerReview; + +use Courseware\PeerReview; +use Courseware\PeerReviewProcess; +use Courseware\Task; +use Courseware\TaskGroup; +use InvalidArgumentException; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\JsonApiController; +use JsonApi\Routes\Courseware\Authority; +use JsonApi\Routes\TimestampTrait; +use JsonApi\Routes\ValidationTrait; +use JsonApi\Schemas\Courseware\PeerReview as PeerReviewSchema; +use JsonApi\Schemas\Courseware\PeerReviewProcess as PeerReviewProcessSchema; +use JsonApi\Schemas\StatusGroup as StatusGroupSchema; +use JsonApi\Schemas\User as UserSchema; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use Statusgruppen; +use User; + +/** + * Create a PeerReview. + * + * @SuppressWarnings(PHPMD.StaticAccess) + */ +class ReviewsCreate extends JsonApiController +{ + use ValidationTrait; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * + * @param array $args + * + * @return Response + */ + public function __invoke(Request $request, Response $response, $args) + { + $json = $this->validate($request); + $process = $this->getProcessFromJson($json); + $user = $this->getUser($request); + + if (!Authority::canCreatePeerReviews($user, $process)) { + throw new AuthorizationFailedException(); + } + + $resource = $this->create($json); + + return $this->getCreatedResponse($resource); + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameters) + * + * @param array $json + * @param mixed $data + * + * @return string|void + */ + protected function validateResourceDocument($json, $data) + { + if (!self::arrayHas($json, 'data')) { + return 'Missing `data` member at document´s top level.'; + } + if (PeerReviewSchema::TYPE !== self::arrayGet($json, 'data.type')) { + return 'Invalid `type` of document´s `data`.'; + } + if (self::arrayHas($json, 'data.id')) { + return 'New document must not have an `id`.'; + } + + // process + if (!self::arrayHas($json, 'data.relationships.process')) { + return 'Missing `process` relationship.'; + } + if (!$this->getProcessFromJson($json)) { + return 'Invalid `process` relationship.'; + } + + // submitter + if (!self::arrayHas($json, 'data.relationships.submitter')) { + return 'Missing `submitter` relationship.'; + } + if (!$this->getSubmitterFromJson($json)) { + return 'Invalid `submitter` relationship.'; + } + + // reviewer + if (!self::arrayHas($json, 'data.relationships.reviewer')) { + return 'Missing `reviewer` relationship.'; + } + if (!$this->getReviewerFromJson($json)) { + return 'Invalid `reviewer` relationship.'; + } + } + + private function create(array $json): PeerReview + { + $process = $this->getProcessFromJson($json); + $reviewer = $this->getReviewerFromJson($json); + $submitter = $this->getSubmitterFromJson($json); + + $task = $process['task_group']->findTaskBySolver($submitter); + $reviewerType = $this->getReviewerType($reviewer); + + /** @var PeerReview $review */ + $review = PeerReview::create([ + 'process_id' => $process->id, + 'task_id' => $task->id, + 'submitter_id' => $submitter->id, + 'reviewer_id' => $reviewer->id, + 'reviewer_type' => $reviewerType, + ]); + + return $review; + } + + /** + * @return User|Statusgruppen|null + */ + private function getActorFromJson(array $json, string $relation) + { + $relationship = 'data.relationships.' . $relation; + if ( + !( + $this->validateResourceObject($json, $relationship, UserSchema::TYPE) || + $this->validateResourceObject($json, $relationship, StatusGroupSchema::TYPE) + ) + ) { + return null; + } + $resourceId = self::arrayGet($json, $relationship . '.data.id'); + + switch (self::arrayGet($json, $relationship . '.data.type')) { + case UserSchema::TYPE: + return User::find($resourceId); + case StatusGroupSchema::TYPE: + return Statusgruppen::find($resourceId); + } + + throw new InvalidArgumentException(); + } + + private function getProcessFromJson(array $json): ?PeerReviewProcess + { + if (!$this->validateResourceObject($json, 'data.relationships.process', PeerReviewProcessSchema::TYPE)) { + return null; + } + $resourceId = self::arrayGet($json, 'data.relationships.process.data.id'); + + return PeerReviewProcess::find($resourceId); + } + + /** + * @return User|Statusgruppen|null + */ + private function getReviewerFromJson(array $json) + { + return $this->getActorFromJson($json, 'reviewer'); + } + + private function getReviewerType($reviewer): string + { + if ($reviewer instanceof User) { + return 'autor'; + } + if ($reviewer instanceof Statusgruppen) { + return 'group'; + } + + throw new InvalidArgumentException(); + } + + /** + * @return User|Statusgruppen|null + */ + private function getSubmitterFromJson(array $json) + { + return $this->getActorFromJson($json, 'submitter'); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsDelete.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsDelete.php new file mode 100644 index 0000000000000000000000000000000000000000..2b7edd60d47fd44a69feee22efe91aa0bda168b6 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsDelete.php @@ -0,0 +1,39 @@ +<?php + +namespace JsonApi\Routes\Courseware\PeerReview; + +use Courseware\PeerReview; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\JsonApiController; +use JsonApi\Routes\Courseware\Authority; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** + * Delete one PeerPreview. + */ +class ReviewsDelete extends JsonApiController +{ + /** + * @param array $args + * @return Response + * + * @SuppressWarnings(PHPMD.StaticAccess) + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + /** @var ?PeerReview $resource */ + $resource = PeerReview::find($args['id']); + if (!$resource) { + throw new RecordNotFoundException(); + } + if (!Authority::canDeletePeerReview($this->getUser($request), $resource)) { + throw new AuthorizationFailedException(); + } + $resource->delete(); + + return $this->getCodeResponse(204); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsIndex.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsIndex.php new file mode 100644 index 0000000000000000000000000000000000000000..54fb26baa0933b3c496c56cca30e8ccd6613635c --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsIndex.php @@ -0,0 +1,80 @@ +<?php + +namespace JsonApi\Routes\Courseware\PeerReview; + +use Course; +use Courseware\PeerReview; +use Courseware\PeerReviewProcess; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\BadRequestException; +use JsonApi\JsonApiController; +use JsonApi\Routes\Courses\Authority as CoursesAuthority; +use JsonApi\Routes\Courseware\Authority; +use JsonApi\Schemas\Courseware\PeerReview as PeerReviewSchema; +use JsonApi\Schemas\Courseware\Task as TaskSchema; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use User; + +/** + * Displays all PeerReviews of a course. + * + * @SuppressWarnings(PHPMD.LongVariable) + * @SuppressWarnings(PHPMD.StaticAccess) + */ +class ReviewsIndex extends JsonApiController +{ + protected $allowedIncludePaths = [ + PeerReviewSchema::REL_PROCESS, + PeerReviewSchema::REL_REVIEWER, + // PeerReviewSchema::REL_SUBMITTER, + PeerReviewSchema::REL_TASK, + PeerReviewSchema::REL_TASK . '.' . TaskSchema::REL_STRUCTURAL_ELEMENT, + PeerReviewSchema::REL_TASK . '.' . TaskSchema::REL_TASK_GROUP, + ]; + + protected $allowedPagingParameters = ['offset', 'limit']; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * + * @param array $args + * + * @return Response + */ + public function __invoke(Request $request, Response $response, $args) + { + /** @var ?Course $course */ + $course = Course::find($args['id']); + if (!$course) { + throw new RecordNotFoundException(); + } + + $user = $this->getUser($request); + $this->authorize($user); + + $resources = $this->findPeerReviews($course, $user); + + return $this->getPaginatedContentResponse( + array_slice($resources, ...$this->getOffsetAndLimit()), + count($resources) + ); + } + + /** + * @throws AuthorizationFailedException + */ + private function authorize(User $user): void + { + if (!Authority::canIndexPeerReviews($user)) { + throw new AuthorizationFailedException(); + } + } + + private function findPeerReviews(Course $course, User $user): iterable + { + return array_filter(PeerReview::findByCourse($course), function ($peerReview) use ($user) { + return Authority::canShowPeerReview($user, $peerReview); + }); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsOfProcessesIndex.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsOfProcessesIndex.php new file mode 100644 index 0000000000000000000000000000000000000000..2a0b04d922efb7441fd50ddde1b40cbac76f3bab --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsOfProcessesIndex.php @@ -0,0 +1,77 @@ +<?php + +namespace JsonApi\Routes\Courseware\PeerReview; + +use Course; +use Courseware\PeerReview; +use Courseware\PeerReviewProcess; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\BadRequestException; +use JsonApi\JsonApiController; +use JsonApi\Routes\Courses\Authority as CoursesAuthority; +use JsonApi\Routes\Courseware\Authority; +use JsonApi\Schemas\Courseware\PeerReview as PeerReviewSchema; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use User; + +/** + * Displays all visible PeerReviewProcesses. + * + * @SuppressWarnings(PHPMD.LongVariable) + * @SuppressWarnings(PHPMD.StaticAccess) + */ +class ReviewsOfProcessesIndex extends JsonApiController +{ + protected $allowedIncludePaths = [ + PeerReviewSchema::REL_PROCESS, + PeerReviewSchema::REL_REVIEWER, + PeerReviewSchema::REL_SUBMITTER, + PeerReviewSchema::REL_TASK, + ]; + + protected $allowedPagingParameters = ['offset', 'limit']; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * + * @param array $args + * + * @return Response + */ + public function __invoke(Request $request, Response $response, $args) + { + /** @var ?PeerReviewProcess $process */ + $process = PeerReviewProcess::find($args['id']); + if (!$process) { + throw new RecordNotFoundException(); + } + + $user = $this->getUser($request); + $this->authorize($user, $process); + + $resources = $this->findReviews($user, $process); + + return $this->getPaginatedContentResponse( + $resources->limit(...$this->getOffsetAndLimit()), + count($resources) + ); + } + + /** + * @throws AuthorizationFailedException + */ + private function authorize(User $user, PeerReviewProcess $process): void + { + if (!Authority::canIndexReviewsOfProcesses($user, $process)) { + throw new AuthorizationFailedException(); + } + } + + private function findReviews(User $user, PeerReviewProcess $process): iterable + { + return $process->peer_reviews->filter(function ($peerReview) use ($user) { + return Authority::canShowPeerReview($user, $peerReview); + }); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsUpdate.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsUpdate.php new file mode 100644 index 0000000000000000000000000000000000000000..cf3c6002ee652c575eaeec6e1a3dc28d6a3bb7c0 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsUpdate.php @@ -0,0 +1,82 @@ +<?php + +namespace JsonApi\Routes\Courseware\PeerReview; + +use Courseware\PeerReview; +use Courseware\TaskGroup; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\JsonApiController; +use JsonApi\Routes\Courseware\Authority; +use JsonApi\Routes\TimestampTrait; +use JsonApi\Routes\ValidationTrait; +use JsonApi\Schemas\Courseware\PeerReview as PeerReviewSchema; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use User; + +/** + * Updates one PeerReview. + * + * @SuppressWarnings(PHPMD.LongVariable) + * @SuppressWarnings(PHPMD.StaticAccess) + */ +class ReviewsUpdate extends JsonApiController +{ + use TimestampTrait; + use ValidationTrait; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @param array $args + * @return Response + */ + public function __invoke(Request $request, Response $response, $args) + { + $resource = PeerReview::find($args['id']); + if (!$resource) { + throw new RecordNotFoundException(); + } + $json = $this->validate($request, $resource); + $user = $this->getUser($request); + if (!Authority::canUpdatePeerReview($user, $resource)) { + throw new AuthorizationFailedException(); + } + + $review = $this->update($resource, $json); + + return $this->getContentResponse($review); + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameters) + * + * @param array $json + * @param mixed $data + * + * @return string|void + */ + protected function validateResourceDocument($json, $data) + { + if (!self::arrayHas($json, 'data')) { + return 'Missing `data` member at document´s top level.'; + } + if (PeerReviewSchema::TYPE !== self::arrayGet($json, 'data.type')) { + return 'Invalid `type` of document´s `data`.'; + } + + if (!self::arrayHas($json, 'data.attributes.assessment')) { + return 'Missing `assessment` attribute.'; + } + + // TODO: validate assessment + } + + private function update(PeerReview $review, array $json): PeerReview + { + $review->assessment = self::arrayGet($json, 'data.attributes.assessment'); + $review->store(); + + return $review; + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/TaskGroupsCreate.php b/lib/classes/JsonApi/Routes/Courseware/TaskGroupsCreate.php index 28c4e9ce65cb2da05b5de46309bc491669854237..f7357a43fe4a6d5dc4f0fbe35408aab1943e6a3f 100644 --- a/lib/classes/JsonApi/Routes/Courseware/TaskGroupsCreate.php +++ b/lib/classes/JsonApi/Routes/Courseware/TaskGroupsCreate.php @@ -65,14 +65,20 @@ class TaskGroupsCreate extends JsonApiController if (!self::arrayHas($json, 'data.attributes.title')) { return 'Missing `title` attribute.'; } - if (!self::arrayHas($json, 'data.attributes.submission-date')) { - return 'Missing `submission-date` attribute.'; + if (!self::arrayHas($json, 'data.attributes.start-date')) { + return 'Missing `start-date` attribute.'; } - $submissionDate = self::arrayGet($json, 'data.attributes.submission-date'); - if (!self::isValidTimestamp($submissionDate)) { - return '`submission-date` is not an ISO 8601 timestamp.'; + $startDate = self::arrayGet($json, 'data.attributes.start-date'); + if (!self::isValidTimestamp($startDate)) { + return '`start-date` is not an ISO 8601 timestamp.'; + } + if (!self::arrayHas($json, 'data.attributes.end-date')) { + return 'Missing `end-date` attribute.'; + } + $endDate = self::arrayGet($json, 'data.attributes.end-date'); + if (!self::isValidTimestamp($endDate)) { + return '`end-date` is not an ISO 8601 timestamp.'; } - if (!self::arrayHas($json, 'data.relationships.target')) { return 'Missing `target` relationship.'; } @@ -165,8 +171,8 @@ class TaskGroupsCreate extends JsonApiController $target = $this->getTargetFromJson($json); $solverMayAddBlocks = self::arrayGet($json, 'data.attributes.solver-may-add-blocks', ''); - $submissionDate = self::arrayGet($json, 'data.attributes.submission-date', ''); - $submissionDate = self::fromISO8601($submissionDate); + $startDate = self::fromISO8601(self::arrayGet($json, 'data.attributes.start-date', '')); + $endDate = self::fromISO8601(self::arrayGet($json, 'data.attributes.end-date', '')); $title = self::arrayGet($json, 'data.attributes.title', ''); /** @var TaskGroup $taskGroup */ @@ -177,6 +183,8 @@ class TaskGroupsCreate extends JsonApiController 'task_template_id' => $taskTemplate->getId(), 'solver_may_add_blocks' => $solverMayAddBlocks, 'title' => $title, + 'start_date' => $startDate->getTimestamp(), + 'end_date' => $endDate->getTimestamp(), ]); foreach ($solvers as $solver) { @@ -184,7 +192,6 @@ class TaskGroupsCreate extends JsonApiController 'task_group_id' => $taskGroup->getId(), 'solver_id' => $solver->getId(), 'solver_type' => $this->getSolverType($solver), - 'submission_date' => $submissionDate->getTimestamp(), ]); // copy task template diff --git a/lib/classes/JsonApi/Routes/Courseware/TaskGroupsDelete.php b/lib/classes/JsonApi/Routes/Courseware/TaskGroupsDelete.php new file mode 100644 index 0000000000000000000000000000000000000000..2faf7783bc2c2a6bf9d2bc586ce3230ea747b4b2 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/TaskGroupsDelete.php @@ -0,0 +1,38 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use Courseware\TaskGroup; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\JsonApiController; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** + * Delete one TaskGroup. + */ +class TaskGroupsDelete extends JsonApiController +{ + /** + * @param array $args + * @return Response + * + * @SuppressWarnings(PHPMD.StaticAccess) + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + /** @var ?TaskGroup $resource */ + $resource = TaskGroup::find($args['id']); + if (!$resource) { + throw new RecordNotFoundException(); + } + if (!Authority::canDeleteTaskGroup($this->getUser($request), $resource)) { + throw new AuthorizationFailedException(); + } + $resource->delete(); + + return $this->getCodeResponse(204); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/TaskGroupsShow.php b/lib/classes/JsonApi/Routes/Courseware/TaskGroupsShow.php index c8ebb86e31bcd1c3480111ee3514159ad7efa8cb..ff3fba44ecbd55ccd87a22045a28982b1a15fded 100644 --- a/lib/classes/JsonApi/Routes/Courseware/TaskGroupsShow.php +++ b/lib/classes/JsonApi/Routes/Courseware/TaskGroupsShow.php @@ -18,6 +18,7 @@ class TaskGroupsShow extends JsonApiController protected $allowedIncludePaths = [ TaskGroupSchema::REL_COURSE, TaskGroupSchema::REL_LECTURER, + TaskGroupSchema::REL_PEER_REVIEW_PROCESSES, TaskGroupSchema::REL_SOLVERS, TaskGroupSchema::REL_TARGET, TaskGroupSchema::REL_TASK_TEMPLATE, diff --git a/lib/classes/JsonApi/Routes/Courseware/TaskGroupsUpdate.php b/lib/classes/JsonApi/Routes/Courseware/TaskGroupsUpdate.php new file mode 100644 index 0000000000000000000000000000000000000000..132888e22e4b33520371897523b020d35d5031fb --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/TaskGroupsUpdate.php @@ -0,0 +1,85 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use Courseware\TaskGroup; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\JsonApiController; +use JsonApi\Routes\TimestampTrait; +use JsonApi\Routes\ValidationTrait; +use JsonApi\Schemas\Courseware\TaskGroup as TaskGroupSchema; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use User; + +/** + * Updates one TaskGroup. + * + * @SuppressWarnings(PHPMD.StaticAccess) + */ +class TaskGroupsUpdate extends JsonApiController +{ + use TimestampTrait; + use ValidationTrait; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @param array $args + * @return Response + */ + public function __invoke(Request $request, Response $response, $args) + { + /** @var ?\Courseware\TaskGroup $resource */ + $resource = TaskGroup::find($args['id']); + if (!$resource) { + throw new RecordNotFoundException(); + } + $json = $this->validate($request, $resource); + $user = $this->getUser($request); + if (!Authority::canUpdateTaskGroup($user, $resource)) { + throw new AuthorizationFailedException(); + } + + $process = $this->update($resource, $json); + + return $this->getContentResponse($process); + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameters) + * + * @param array $json + * @param mixed $data + * + * @return string|void + */ + protected function validateResourceDocument($json, $data) + { + if (!self::arrayHas($json, 'data')) { + return 'Missing `data` member at document´s top level.'; + } + if (TaskGroupSchema::TYPE !== self::arrayGet($json, 'data.type')) { + return 'Invalid `type` of document´s `data`.'; + } + + if (!self::arrayHas($json, 'data.attributes.end-date')) { + return 'Missing `review-start` attribute.'; + } + $endDate = self::arrayGet($json, 'data.attributes.end-date'); + if (!self::isValidTimestamp($endDate)) { + return '`end-date` is not an ISO 8601 timestamp.'; + } + } + + private function update(TaskGroup $taskGroup, array $json): TaskGroup + { + $endDate = self::fromISO8601(self::arrayGet($json, 'data.attributes.end-date')); + + $taskGroup->end_date = $endDate->getTimestamp(); + + $taskGroup->store(); + + return $taskGroup; + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/TasksIndex.php b/lib/classes/JsonApi/Routes/Courseware/TasksIndex.php index f0b2ce9a53af8b869e8c04b04e818977808274fe..474722838590fba7b4f7e6252e0fe92f65a04a21 100644 --- a/lib/classes/JsonApi/Routes/Courseware/TasksIndex.php +++ b/lib/classes/JsonApi/Routes/Courseware/TasksIndex.php @@ -25,6 +25,7 @@ class TasksIndex extends JsonApiController TaskSchema::REL_STRUCTURAL_ELEMENT, TaskSchema::REL_TASK_GROUP, TaskSchema::REL_TASK_GROUP . '.' . TaskGroupSchema::REL_LECTURER, + TaskSchema::REL_TASK_GROUP . '.' . TaskGroupSchema::REL_PEER_REVIEW_PROCESSES, ]; /** diff --git a/lib/classes/JsonApi/Routes/Courseware/TasksShow.php b/lib/classes/JsonApi/Routes/Courseware/TasksShow.php index 619e7eab9e7f565def448dc604217e2c508abebc..419f950950541458af9dc7e202845b9ee418e41c 100644 --- a/lib/classes/JsonApi/Routes/Courseware/TasksShow.php +++ b/lib/classes/JsonApi/Routes/Courseware/TasksShow.php @@ -5,6 +5,7 @@ namespace JsonApi\Routes\Courseware; use Courseware\Task; use JsonApi\Errors\AuthorizationFailedException; use JsonApi\Errors\RecordNotFoundException; +use JsonApi\Schemas\Courseware\PeerReview as PeerReviewSchema; use JsonApi\Schemas\Courseware\Task as TaskSchema; use JsonApi\Schemas\Courseware\TaskGroup as TaskGroupSchema; use JsonApi\JsonApiController; @@ -18,10 +19,13 @@ class TasksShow extends JsonApiController { protected $allowedIncludePaths = [ TaskSchema::REL_FEEDBACK, + TaskSchema::REL_PEER_REVIEWS, + TaskSchema::REL_PEER_REVIEWS . '.' . PeerReviewSchema::REL_PROCESS, TaskSchema::REL_SOLVER, TaskSchema::REL_STRUCTURAL_ELEMENT, TaskSchema::REL_TASK_GROUP, TaskSchema::REL_TASK_GROUP . '.' . TaskGroupSchema::REL_LECTURER, + TaskSchema::REL_TASK_GROUP . '.' . TaskGroupSchema::REL_PEER_REVIEW_PROCESSES, ]; /** diff --git a/lib/classes/JsonApi/Routes/Courseware/TasksUpdate.php b/lib/classes/JsonApi/Routes/Courseware/TasksUpdate.php index 3728dba9a6b378712a9fb666b9404e7f042d099b..33b51ad1ae8b50f5ab9f2647b5a027c981f1c542 100644 --- a/lib/classes/JsonApi/Routes/Courseware/TasksUpdate.php +++ b/lib/classes/JsonApi/Routes/Courseware/TasksUpdate.php @@ -13,6 +13,8 @@ use Psr\Http\Message\ServerRequestInterface as Request; /** * Update one Task. + * + * @SuppressWarnings(PHPMD.StaticAccess) */ class TasksUpdate extends JsonApiController { @@ -32,7 +34,8 @@ class TasksUpdate extends JsonApiController throw new RecordNotFoundException(); } $json = $this->validate($request, $resource); - if (!Authority::canUpdateTask($user = $this->getUser($request), $resource)) { + $user = $this->getUser($request); + if (!Authority::canUpdateTask($user, $resource)) { throw new AuthorizationFailedException(); } $resource = $this->updateTask($user, $resource, $json); @@ -66,53 +69,35 @@ class TasksUpdate extends JsonApiController private function updateTask(\User $user, Task $resource, array $json): Task { - if (Authority::canDeleteTask($user, $resource)) { - if (self::arrayHas($json, 'data.attributes.renewal')) { - $newRenewalState = self::arrayGet($json, 'data.attributes.renewal'); - if ('declined' === $newRenewalState) { - $resource->renewal = $newRenewalState; - } - if ('granted' === $newRenewalState && self::arrayHas($json, 'data.attributes.renewal-date')) { - $renewalDate = self::arrayGet($json, 'data.attributes.renewal-date', ''); - $renewalDate = self::fromISO8601($renewalDate); + if (Authority::canRenewTask($user, $resource)) { + return $this->renewTask($resource, $json); + } - $resource->renewal = $newRenewalState; - $resource->renewal_date = $renewalDate->getTimestamp(); - } - } - } else { - if (self::arrayHas($json, 'data.attributes.submitted')) { - $newSubmittedState = self::arrayGet($json, 'data.attributes.submitted'); - if ($this->canSubmit($resource, $newSubmittedState)) { - $resource->submitted = $newSubmittedState; - if ('pending' === $resource->renewal) { - $resource->renewal = ''; - } - } - } - if (self::arrayHas($json, 'data.attributes.renewal')) { - $newRenewalState = self::arrayGet($json, 'data.attributes.renewal'); - if ('pending' === $newRenewalState) { - $resource->renewal = $newRenewalState; - } - } + if (self::arrayGet($json, 'data.attributes.submitted') === true && $resource->canSubmit()) { + $resource->submitTask(); } - $resource->store(); + if (self::arrayGet($json, 'data.attributes.renewal') === 'pending') { + $resource->requestRenewal(); + } return $resource; } - private function canSubmit(Task $resource, string $newSubmittedState): bool + private function renewTask(Task $resource, array $json): Task { - $now = time(); - if (1 === (int) $resource->submitted || !$newSubmittedState) { - return false; - } - if ('granted' === $resource->renewal) { - return $now <= $resource->renewal_date; - } else { - return $now <= $resource->submission_date; + switch (self::arrayGet($json, 'data.attributes.renewal')) { + case 'declined': + $resource->declineRenewalRequest(); + break; + + case 'granted': + $resource->grantRenewalRequest( + self::fromISO8601(self::arrayGet($json, 'data.attributes.renewal-date')) + ); + break; } + + return $resource; } } diff --git a/lib/classes/JsonApi/SchemaMap.php b/lib/classes/JsonApi/SchemaMap.php index dd74bc9bc2a20957c438c5bb3d7832a3d7a5e208..541c7519723fffbe44299029694f71e323935870 100644 --- a/lib/classes/JsonApi/SchemaMap.php +++ b/lib/classes/JsonApi/SchemaMap.php @@ -59,17 +59,19 @@ class SchemaMap \Courseware\Clipboard::class => Schemas\Courseware\Clipboard::class, \Courseware\Container::class => Schemas\Courseware\Container::class, \Courseware\Instance::class => Schemas\Courseware\Instance::class, + \Courseware\PeerReview::class => Schemas\Courseware\PeerReview::class, + \Courseware\PeerReviewProcess::class => Schemas\Courseware\PeerReviewProcess::class, + \Courseware\PublicLink::class => Schemas\Courseware\PublicLink::class, \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, - \Courseware\TaskGroup::class => Schemas\Courseware\TaskGroup::class, \Courseware\TaskFeedback::class => Schemas\Courseware\TaskFeedback::class, + \Courseware\TaskGroup::class => Schemas\Courseware\TaskGroup::class, \Courseware\Template::class => Schemas\Courseware\Template::class, - \Courseware\PublicLink::class => Schemas\Courseware\PublicLink::class, + \Courseware\Unit::class => Schemas\Courseware\Unit::class, + \Courseware\UserDataField::class => Schemas\Courseware\UserDataField::class, + \Courseware\UserProgress::class => Schemas\Courseware\UserProgress::class, ]; } } diff --git a/lib/classes/JsonApi/Schemas/Courseware/PeerReview.php b/lib/classes/JsonApi/Schemas/Courseware/PeerReview.php new file mode 100644 index 0000000000000000000000000000000000000000..d8e903cbb686a5caabbbf97260b794d56bba8f30 --- /dev/null +++ b/lib/classes/JsonApi/Schemas/Courseware/PeerReview.php @@ -0,0 +1,89 @@ +<?php + +namespace JsonApi\Schemas\Courseware; + +use JsonApi\Routes\Courseware\Authority; +use JsonApi\Schemas\SchemaProvider; +use Neomerx\JsonApi\Contracts\Schema\ContextInterface; +use Neomerx\JsonApi\Schema\Link; + +class PeerReview extends SchemaProvider +{ + public const TYPE = 'courseware-peer-reviews'; + + public const REL_PROCESS = 'process'; + public const REL_REVIEWER = 'reviewer'; + public const REL_SUBMITTER = 'submitter'; + public const REL_TASK = 'task'; + + /** + * {@inheritdoc} + */ + public function getId($resource): ?string + { + return $resource->id; + } + + /** + * {@inheritdoc} + * @SuppressWarnings(PHPMD.StaticAccess) + */ + public function getAttributes($resource, ContextInterface $context): iterable + { + $user = $this->currentUser; + $assessment = null; + if ($resource->assessment && Authority::canShowPeerReviewAssessment($user, $resource)) { + $assessment = $resource->assessment->getIterator(); + } + return [ + 'assessment' => $assessment, + 'mkdate' => date('c', $resource['mkdate']), + 'chdate' => date('c', $resource['chdate']), + ]; + } + + /** + * {@inheritdoc} + * @SuppressWarnings(PHPMD.StaticAccess) + */ + public function getRelationships($resource, ContextInterface $context): iterable + { + $relationships = []; + + $relationships[self::REL_PROCESS] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($resource->process), + ], + self::RELATIONSHIP_DATA => $resource->process, + ]; + + $reviewer = $resource->getReviewer(); + $relationships[self::REL_REVIEWER] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($reviewer), + ], + self::RELATIONSHIP_DATA => $reviewer, + ]; + + $user = $this->currentUser; + + if (Authority::canShowPeerReviewSubmitter($user, $resource)) { + $submitter = $resource->getSubmitter(); + $relationships[self::REL_SUBMITTER] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($submitter), + ], + self::RELATIONSHIP_DATA => $submitter, + ]; + } + + $relationships[self::REL_TASK] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($resource->task), + ], + self::RELATIONSHIP_DATA => $resource->task, + ]; + + return $relationships; + } +} diff --git a/lib/classes/JsonApi/Schemas/Courseware/PeerReviewProcess.php b/lib/classes/JsonApi/Schemas/Courseware/PeerReviewProcess.php new file mode 100644 index 0000000000000000000000000000000000000000..0eca67c523cca3d75f5d3f6d0844a5e529c77946 --- /dev/null +++ b/lib/classes/JsonApi/Schemas/Courseware/PeerReviewProcess.php @@ -0,0 +1,77 @@ +<?php + +namespace JsonApi\Schemas\Courseware; + +use JsonApi\Schemas\SchemaProvider; +use Neomerx\JsonApi\Contracts\Schema\ContextInterface; +use Neomerx\JsonApi\Schema\Link; + +class PeerReviewProcess extends SchemaProvider +{ + const TYPE = 'courseware-peer-review-processes'; + + const REL_COURSE = 'course'; + const REL_OWNER = 'owner'; + const REL_PEER_REVIEWS = 'reviews'; + const REL_TASK_GROUP = 'task-group'; + + /** + * {@inheritdoc} + */ + public function getId($resource): ?string + { + return $resource->id; + } + + /** + * {@inheritdoc} + */ + public function getAttributes($resource, ContextInterface $context): iterable + { + return [ + 'configuration' => $resource['configuration']->getIterator(), + 'review-start' => date('c', $resource['review_start']), + 'review-end' => date('c', $resource['review_end']), + 'mkdate' => date('c', $resource['mkdate']), + 'chdate' => date('c', $resource['chdate']), + ]; + } + + /** + * {@inheritdoc} + */ + public function getRelationships($resource, ContextInterface $context): iterable + { + $relationships = []; + + $course = $resource->getCourse(); + $relationships[self::REL_COURSE] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($course), + ], + self::RELATIONSHIP_DATA => $course, + ]; + + $relationships[self::REL_OWNER] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($resource->owner), + ], + self::RELATIONSHIP_DATA => $resource->owner, + ]; + + $relationships[self::REL_PEER_REVIEWS] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->getRelationshipRelatedLink($resource, self::REL_PEER_REVIEWS), + ], + ]; + + $relationships[self::REL_TASK_GROUP] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($resource->task_group), + ], + self::RELATIONSHIP_DATA => $resource->task_group, + ]; + + return $relationships; + } +} diff --git a/lib/classes/JsonApi/Schemas/Courseware/Task.php b/lib/classes/JsonApi/Schemas/Courseware/Task.php index a87d335b64ee4bac500ebd0d58ce0a79d8a54e00..282e6226e148a8fb44b609536b31e6520718bf6e 100644 --- a/lib/classes/JsonApi/Schemas/Courseware/Task.php +++ b/lib/classes/JsonApi/Schemas/Courseware/Task.php @@ -2,6 +2,8 @@ namespace JsonApi\Schemas\Courseware; +use Courseware\Task as TaskModel; +use JsonApi\Routes\Courseware\Authority as CoursewareAuthority; use JsonApi\Schemas\SchemaProvider; use Neomerx\JsonApi\Contracts\Schema\ContextInterface; use Neomerx\JsonApi\Schema\Link; @@ -11,6 +13,7 @@ class Task extends SchemaProvider const TYPE = 'courseware-tasks'; const REL_FEEDBACK = 'task-feedback'; + const REL_PEER_REVIEWS = 'peer-reviews'; const REL_SOLVER = 'solver'; const REL_STRUCTURAL_ELEMENT = 'structural-element'; const REL_TASK_GROUP = 'task-group'; @@ -28,12 +31,15 @@ class Task extends SchemaProvider */ public function getAttributes($resource, ContextInterface $context): iterable { + $user = $this->currentUser; + return [ 'progress' => (float) $resource->getTaskProgress(), 'submission-date' => date('c', $resource['submission_date']), 'submitted' => (bool) $resource['submitted'], 'renewal' => empty($resource['renewal']) ? null : (string) $resource['renewal'], 'renewal-date' => date('c', $resource['renewal_date']), + 'can-peer-review' => $resource->userIsAPeerReviewer($user), 'mkdate' => date('c', $resource['mkdate']), 'chdate' => date('c', $resource['chdate']), ]; @@ -55,6 +61,12 @@ class Task extends SchemaProvider ] : [self::RELATIONSHIP_DATA => null]; + $relationships = $this->addPeerReviews( + $relationships, + $resource, + $this->shouldInclude($context, self::REL_PEER_REVIEWS) + ); + $relationships[self::REL_SOLVER] = $resource['solver_id'] ? [ self::RELATIONSHIP_LINKS => [ @@ -82,4 +94,30 @@ class Task extends SchemaProvider return $relationships; } + + /** + * @SuppressWarnings(PHPMD.StaticAccess) + */ + private function addPeerReviews(array $relationships, TaskModel $resource, bool $includeData): array + { + $relationships[self::REL_PEER_REVIEWS] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->getRelationshipRelatedLink($resource, self::REL_PEER_REVIEWS), + ], + ]; + + if ($includeData) { + $data = []; + $user = $this->currentUser; + if ($resource->isPeerReviewedBy($this->currentUser)) { + $data = $resource->peer_reviews->filter(function ($review) use ($user) { + return CoursewareAuthority::canShowPeerReview($user, $review); + }); + } + + $relationships[self::REL_PEER_REVIEWS][self::RELATIONSHIP_DATA] = $data; + } + + return $relationships; + } } diff --git a/lib/classes/JsonApi/Schemas/Courseware/TaskGroup.php b/lib/classes/JsonApi/Schemas/Courseware/TaskGroup.php index 12dbc6c5855e110437481997261e3fea450e2a6e..68706640cecb02739640d83eebcfd6062b3e65af 100644 --- a/lib/classes/JsonApi/Schemas/Courseware/TaskGroup.php +++ b/lib/classes/JsonApi/Schemas/Courseware/TaskGroup.php @@ -3,6 +3,7 @@ namespace JsonApi\Schemas\Courseware; use Courseware\StructuralElement; +use Courseware\TaskGroup as TaskGroupModel; use JsonApi\Schemas\SchemaProvider; use Neomerx\JsonApi\Contracts\Schema\ContextInterface; use Neomerx\JsonApi\Schema\Identifier; @@ -14,6 +15,7 @@ class TaskGroup extends SchemaProvider const REL_COURSE = 'course'; const REL_LECTURER = 'lecturer'; + const REL_PEER_REVIEW_PROCESSES = 'peer-review-processes'; const REL_SOLVERS = 'solvers'; const REL_TARGET = 'target'; const REL_TASK_TEMPLATE = 'task-template'; @@ -35,6 +37,8 @@ class TaskGroup extends SchemaProvider return [ 'solver-may-add-blocks' => (bool) $resource['solver_may_add_blocks'], 'title' => (string) $resource->title, + 'start-date' => date('c', $resource['start_date']), + 'end-date' => date('c', $resource['end_date']), 'mkdate' => date('c', $resource['mkdate']), 'chdate' => date('c', $resource['chdate']), ]; @@ -65,6 +69,8 @@ class TaskGroup extends SchemaProvider ] : [self::RELATIONSHIP_DATA => null]; + $relationships = $this->addPeerReviewProcessesRelationship($relationships, $resource, $context); + $relationships[self::REL_SOLVERS] = [ self::RELATIONSHIP_DATA => $resource->getSolvers(), ]; @@ -101,4 +107,19 @@ class TaskGroup extends SchemaProvider return $relationships; } + + private function addPeerReviewProcessesRelationship(iterable $relationships, TaskGroupModel $resource, ContextInterface $context): iterable + { + $relationships[self::REL_PEER_REVIEW_PROCESSES] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->getRelationshipRelatedLink($resource, self::REL_PEER_REVIEW_PROCESSES), + ], + ]; + + if ($this->shouldInclude($context, self::REL_PEER_REVIEW_PROCESSES)) { + $relationships[self::REL_PEER_REVIEW_PROCESSES][self::RELATIONSHIP_DATA] = $resource->peer_review_processes; + } + + return $relationships; + } } diff --git a/lib/models/Courseware/PeerReview.php b/lib/models/Courseware/PeerReview.php new file mode 100644 index 0000000000000000000000000000000000000000..40a684b2714da7d004cae63582907639e96c3d0f --- /dev/null +++ b/lib/models/Courseware/PeerReview.php @@ -0,0 +1,128 @@ +<?php + +namespace Courseware; + +use Course; +use DBManager; +use Statusgruppen; +use User; + +/** + * Courseware's peer review instances. + * + * @since Stud.IP 5.5 + */ +class PeerReview extends \SimpleORMap +{ + protected static function configure($config = []) + { + $config['db_table'] = 'cw_peer_reviews'; + + $config['serialized_fields']['assessment'] = 'JSONArrayObject'; + + $config['belongs_to']['process'] = [ + 'class_name' => PeerReviewProcess::class, + 'foreign_key' => 'process_id', + ]; + $config['belongs_to']['task'] = [ + 'class_name' => Task::class, + 'foreign_key' => 'task_id', + ]; + $config['belongs_to']['submitter'] = [ + 'class_name' => \User::class, + 'foreign_key' => 'submitter_id', + ]; + $config['belongs_to']['reviewer'] = [ + 'class_name' => \User::class, + 'foreign_key' => 'reviewer_id', + ]; + + parent::configure($config); + } + + /* + public static function findByUser(User $user): iterable + { + $assignments = DBManager::get()->fetchFirst('SELECT id FROM cw_peer_reviews WHERE reviewer_id = ?', [ + $user->getId(), + ]); + + $createdReviews = DBManager::get()->fetchFirst( + 'SELECT id FROM cw_peer_reviews + WHERE process_id IN ( + SELECT id FROM cw_peer_review_processes + WHERE owner_id = ?)', + [$user->getId()] + ); + + $ids = array_merge($assignments, $createdReviews); + + return self::findMany($ids); + } + */ + + public static function findByCourse(Course $course): iterable + { + return self::findMany(self::findIdsByCourse($course->getId())); + } + + private static function findIdsByCourse(string $courseId): iterable + { + return DBManager::get()->fetchFirst( + 'SELECT id FROM cw_peer_reviews + WHERE process_id in ( + SELECT id FROM cw_peer_review_processes + WHERE task_group_id IN ( + SELECT id FROM cw_task_groups + WHERE cw_task_groups.seminar_id = ?))', + [$courseId] + ); + } + + public function getCourse(): Course + { + return $this->process->getCourse(); + } + + public function isAnonymous(): bool + { + return $this->process->isAnonymous(); + } + + /** + * @SuppressWarnings(PHPMD.StaticAccess) + */ + public function isReviewer(\User $user): bool + { + switch ($this->reviewer_type) { + case 'autor': + return $this->reviewer_id === $user->getId(); + case 'group': + return \Statusgruppen::isMemberOf($this->reviewer_id, $user->getId()); + } + } + + public function getReviewer() + { + switch ($this->reviewer_type) { + case 'autor': + return User::find($this->reviewer_id); + case 'group': + return Statusgruppen::find($this->reviewer_id); + } + } + + /** + * @return User|Statusgruppen + */ + public function getSubmitter() + { + $user = User::find($this->submitter_id); + if ($user) { + return $user; + } + + $statusGroup = Statusgruppen::find($this->submitter_id); + return $statusGroup; + } +} diff --git a/lib/models/Courseware/PeerReviewProcess.php b/lib/models/Courseware/PeerReviewProcess.php new file mode 100644 index 0000000000000000000000000000000000000000..51c3c848aeeb1770f677472239009030370e5d99 --- /dev/null +++ b/lib/models/Courseware/PeerReviewProcess.php @@ -0,0 +1,188 @@ +<?php + +namespace Courseware; + +use Course; +use DBManager; +use SimpleORMapCollection; +use User; + +/** + * A PeerReviewProcess groups a set of PeerReviews. + * + * @SuppressWarnings(PHPMD.StaticAccess) + * + * @since Stud.IP 5.5 + */ +class PeerReviewProcess extends \SimpleORMap +{ + public const DEFAULT_DURATION = 7; + + public const STATE_BEFORE = 'before'; + public const STATE_ACTIVE = 'active'; + public const STATE_AFTER = 'after'; + + protected static function configure($config = []) + { + $config['db_table'] = 'cw_peer_review_processes'; + + $config['serialized_fields']['configuration'] = 'JSONArrayObject'; + + $config['belongs_to']['task_group'] = [ + 'class_name' => TaskGroup::class, + 'foreign_key' => 'task_group_id', + ]; + $config['belongs_to']['owner'] = [ + 'class_name' => User::class, + 'foreign_key' => 'owner_id', + ]; + + $config['additional_fields']['peer_reviews'] = [ + 'get' => 'getPeerReviews', + 'set' => false, + ]; + + $config['has_many']['_peer_reviews'] = [ + 'class_name' => PeerReview::class, + 'assoc_foreign_key' => 'process_id', + 'on_delete' => 'delete', + 'on_store' => 'store', + 'order_by' => 'ORDER BY mkdate', + ]; + + parent::configure($config); + } + + public static function findByCourse(Course $course): iterable + { + return self::findBySQL('task_group_id IN (?) ORDER BY mkdate', [ + DBManager::get()->fetchFirst('SELECT id FROM `cw_task_groups` WHERE seminar_id = ?', [$course->getId()]), + ]); + } + + public static function findByUser(User $user): iterable + { + return self::findMany( + DBManager::get()->fetchFirst( + 'SELECT id FROM cw_peer_review_processes + WHERE task_group_id IN ( + SELECT id FROM cw_task_groups + WHERE cw_task_groups.seminar_id IN ( + SELECT seminar_id FROM seminar_user WHERE user_id = ?))', + [$user->getId()] + ) + ); + } + + public function getCourse(): Course + { + return $this->task_group->course; + } + + public function getPeerReviews(): SimpleORMapCollection + { + $this->checkAutomaticPairing(); + + return SimpleORMapCollection::createFromArray( + PeerReview::findBySql('process_id = ? ORDER BY mkdate', [$this->getId()]) + ); + } + + public function getDuration(): int + { + if (!isset($this->configuration['duration'])) { + return self::DEFAULT_DURATION; + } + + return (int) $this->configuration['duration']; + } + + public function isAnonymous(): bool + { + if (!isset($this->configuration['anonymous'])) { + return true; + } + + return (bool) $this->configuration['automaticPairing']; + } + + public function isAutomaticPairing(): bool + { + if (!isset($this->configuration['automaticPairing'])) { + return true; + } + + return (bool) $this->configuration['automaticPairing']; + } + + public function getCurrentState(int $date = null): string + { + if (is_null($date)) { + $date = time(); + } + + if ($this->review_end < $date) { + return self::STATE_AFTER; + } + + if ($date < $this->review_start) { + return self::STATE_BEFORE; + } + + return self::STATE_ACTIVE; + } + + public function checkAutomaticPairing(): void + { + if ($this->isAutomaticPairing() && !$this->paired_at) { + $now = time(); + if ($now > $this->review_start) { + $this->createAutomaticPairings(); + $this->content['paired_at'] = $now; + $this->content_db['paired_at'] = $now; + $stmt = \DBManager::get()->prepare( + 'UPDATE `' . $this->db_table() . '` SET `paired_at` = ? WHERE id = ?' + ); + $stmt->execute([$now, $this->getId()]); + } + } + } + + public function createAutomaticPairings(): iterable + { + $taskGroup = $this->task_group; + $submitters = $taskGroup->getSubmitters(); + + if (count($submitters) < 2) { + return []; + } + + shuffle($submitters); + $copy = $submitters; + array_push($copy, array_shift($copy)); + $pairings = array_map(null, $submitters, $copy); + + return array_map(function ($pairing) use ($taskGroup) { + list($submitter, $reviewer) = $pairing; + $task = $taskGroup->findTaskBySolver($submitter); + + return PeerReview::create([ + 'process_id' => $this->getId(), + 'task_id' => $task->getId(), + 'submitter_id' => $submitter->getId(), + 'reviewer_id' => $reviewer->getId(), + 'reviewer_type' => $reviewer instanceof User ? 'autor' : 'group', + ]); + }, $pairings); + } + + public function rescheduleTo(int $newStartDate): void + { + $newEndDate = $newStartDate + $this->getDuration() * (24 * 60 * 60); + $this->setData([ + "review_start" => $newStartDate, + "review_end" => $newEndDate, + ]); + $this->store(); + } +} diff --git a/lib/models/Courseware/StructuralElement.php b/lib/models/Courseware/StructuralElement.php index 940547ec65cde61e5cbfbafa625af8185ad80b22..a8b342524841297cae4ff480c78bbc7c58a6ece7 100644 --- a/lib/models/Courseware/StructuralElement.php +++ b/lib/models/Courseware/StructuralElement.php @@ -389,7 +389,7 @@ class StructuralElement extends \SimpleORMap implements \PrivacyObject return true; } - return $task->userIsASolver($user); + return $task->userIsASolver($user) || $task->userIsAPeerReviewer($user); } if ($this->canEdit($user)) { diff --git a/lib/models/Courseware/Task.php b/lib/models/Courseware/Task.php index 3a68d3e4ffef6c0062ef873221d2161e2a4ea2b2..ca4163328240ee9fbf50fa55766a84f148595870 100644 --- a/lib/models/Courseware/Task.php +++ b/lib/models/Courseware/Task.php @@ -31,7 +31,9 @@ use User; * @property \Statusgruppen $group belongs_to \Statusgruppen * @property \Course $course belongs_to \Course * @property TaskFeedback|null $task_feedback belongs_to TaskFeedback - * @property mixed $solver additional field + * @property-read \User|\Statusgruppen|null $solver additional field + * + * @SuppressWarnings(PHPMD.StaticAccess) */ class Task extends \SimpleORMap { @@ -76,10 +78,22 @@ class Task extends \SimpleORMap 'foreign_key' => 'feedback_id', ]; + $config['has_many']['peer_reviews'] = [ + 'class_name' => PeerReview::class, + 'assoc_foreign_key' => 'task_id', + 'on_delete' => 'delete', + 'on_store' => 'store', + 'order_by' => 'ORDER BY mkdate', + ]; + $config['additional_fields']['solver'] = [ 'get' => 'getSolver', 'set' => false, ]; + $config['additional_fields']['submission_date'] = [ + 'get' => 'getSubmissionDate', + 'set' => false, + ]; parent::configure($config); } @@ -155,6 +169,14 @@ class Task extends \SimpleORMap return false; } + /** + * @param \User|\Seminar_User $user + */ + public function userIsAPeerReviewer($user): bool + { + return $this->isPeerReviewed() && $this->isPeerReviewedBy($user); + } + /** * @return \User|\Statusgruppen|null the solver */ @@ -171,6 +193,11 @@ class Task extends \SimpleORMap return null; } + public function getSubmissionDate(): int + { + return $this->task_group['end_date']; + } + public function getTaskProgress(): float { $children = $this->structural_element->findDescendants(); @@ -185,6 +212,73 @@ class Task extends \SimpleORMap return $progress * 100; } + public function canSubmit(): bool + { + return !$this->submitted && + time() <= ('granted' === $this->renewal ? $this->renewal_date : $this->submission_date); + } + + public function submitTask(): void + { + $this->submitted = 1; + if ('pending' === $this->renewal) { + $this->renewal = ''; + } + $this->store(); + + // $this->task_group->notifyTaskDidSubmit($this); + } + + public function isRenewed(): bool + { + return $this->renewal === 'granted'; + } + + public function requestRenewal(): void + { + $this->renewal = 'pending'; + $this->store(); + } + + public function declineRenewalRequest(): void + { + $this->renewal = 'declined'; + $this->store(); + } + + public function grantRenewalRequest(\DateTime $renewalDate): void + { + $this->renewal = 'granted'; + $this->renewal_date = $renewalDate->getTimestamp(); + $this->store(); + } + + public function isPeerReviewed(): bool + { + return PeerReview::countBySql('task_id = ?', [$this->getId()]) !== 0; + } + + /** + * @param \User|\Seminar_User $user + */ + public function isPeerReviewedBy($user): bool + { + $userId = $user->getId(); + $sql = 'task_id = ? AND reviewer_id = ? AND reviewer_type = "autor"'; + if (PeerReview::countBySql($sql, [$this->getId(), $userId]) !== 0) { + return true; + } + + $sql = 'SELECT reviewer_id FROM cw_peer_reviews WHERE task_id = ? AND reviewer_type = "group"'; + foreach (\DBManager::get()->fetchFirst($sql, [$this->getId()]) as $reviewerId) { + if (\Statusgruppen::isMemberOf($reviewerId, $userId)) { + return true; + } + } + + return false; + } + private function getStructuralElementProgress(StructuralElement $structural_element): float { $containers = Container::findBySQL('structural_element_id = ?', [intval($structural_element->id)]); diff --git a/lib/models/Courseware/TaskGroup.php b/lib/models/Courseware/TaskGroup.php index 092edf644a44583d8a84798a92b7211ff0322429..19f91ed9084cc7c54902dd4469475b6c48f0af94 100644 --- a/lib/models/Courseware/TaskGroup.php +++ b/lib/models/Courseware/TaskGroup.php @@ -2,6 +2,7 @@ namespace Courseware; +use Statusgruppen; use User; /** @@ -24,6 +25,11 @@ use User; * @property \SimpleORMapCollection|Task[] $tasks has_many Task * @property \User $lecturer belongs_to \User * @property \Course $course belongs_to \Course + * @property \Courseware\StructuralElement $target belongs_to Courseware\StructuralElement + * @property \SimpleORMapCollection $tasks has_many Courseware\Task + * @property \SimpleORMapCollection $peer_review_processes has_many Courseware\PeerReviewProcess + * + * @SuppressWarnings(PHPMD.StaticAccess) */ class TaskGroup extends \SimpleORMap implements \PrivacyObject { @@ -41,6 +47,11 @@ class TaskGroup extends \SimpleORMap implements \PrivacyObject 'foreign_key' => 'seminar_id', ]; + $config['belongs_to']['target'] = [ + 'class_name' => StructuralElement::class, + 'foreign_key' => 'target_id', + ]; + $config['has_many']['tasks'] = [ 'class_name' => Task::class, 'assoc_foreign_key' => 'task_group_id', @@ -49,9 +60,35 @@ class TaskGroup extends \SimpleORMap implements \PrivacyObject 'order_by' => 'ORDER BY mkdate', ]; + $config['has_many']['peer_review_processes'] = [ + 'class_name' => PeerReviewProcess::class, + 'assoc_foreign_key' => 'task_group_id', + 'on_delete' => 'delete', + 'on_store' => 'store', + 'order_by' => 'ORDER BY mkdate', + ]; + + $config['registered_callbacks']['after_store'][] = 'cbAfterStore'; + parent::configure($config); } + /** + * Export available data of a given user into a storage object + * (an instance of the StoredUserData class) for that user. + * + * @param StoredUserData $storage object to store data into + */ + public static function exportUserData(\StoredUserData $storage) + { + $task_groups = \DBManager::get()->fetchAll('SELECT * FROM cw_task_groups WHERE lecturer_id = ?', [ + $storage->user_id, + ]); + if ($task_groups) { + $storage->addTabularData(_('Courseware Aufgaben'), 'cw_task_groups', $task_groups); + } + } + public function getSolvers(): iterable { $solvers = $this->tasks->pluck('solver'); @@ -59,21 +96,68 @@ class TaskGroup extends \SimpleORMap implements \PrivacyObject return $solvers; } + public function getSubmitters(): iterable + { + return \DBManager::get()->fetchAll( + 'SELECT solver_id, solver_type FROM cw_tasks WHERE task_group_id = ? AND submitted = 1', + [$this->getId()], + function ($row) { + switch ($row['solver_type']) { + case 'autor': + return \User::find($row['solver_id']); + case 'group': + return \Statusgruppen::find($row['solver_id']); + } + } + ); + } + + public function hasPeerReviewProcesses(): bool + { + return PeerReviewProcess::countBySql('task_group_id = ?', [$this->getId()]) > 0; + } + + // public function notifyTaskDidSubmit(Task $task): void + // { + // if ($this->hasPeerReviewProcesses()) { + // foreach ($this->peer_review_processes as $process) { + // $process->handleTaskSubmitted($task); + // } + // } + // } + /** - * Export available data of a given user into a storage object - * (an instance of the StoredUserData class) for that user. + * @param User|Statusgruppen $solver * - * @param StoredUserData $storage object to store data into + * @return Task|null */ - public static function exportUserData(\StoredUserData $storage) + public function findTaskBySolver($solver) { - $task_groups = \DBManager::get()->fetchAll( - 'SELECT * FROM cw_task_groups WHERE lecturer_id = ?', - [$storage->user_id] + $row = \DBManager::get()->fetchOne( + 'SELECT id FROM cw_tasks WHERE task_group_id = ? AND solver_id = ? AND solver_type = ?', + [ + $this->getId(), + $solver->getId(), + $solver instanceof User ? 'autor' : 'group', + ] ); - if ($task_groups) { - $storage->addTabularData(_('Courseware Aufgaben'), 'cw_task_groups', $task_groups); + + return empty($row) ? null : Task::find($row['id']); + } + + public function cbAfterStore(): void + { + if ($this->isFieldDirty('end_date')) { + $this->reschedulePeerReviewProcesses(); + } + } + + private function reschedulePeerReviewProcesses(): void + { + if ($this->hasPeerReviewProcesses()) { + foreach ($this->peer_review_processes as $process) { + $process->rescheduleTo($this->end_date); + } } - } } diff --git a/lib/models/Statusgruppen.php b/lib/models/Statusgruppen.php index b4a0b69e8558824e08bad0d525aeb13e51c96d81..d726a1e5160eb8f3b07314364a916aecd69409a2 100644 --- a/lib/models/Statusgruppen.php +++ b/lib/models/Statusgruppen.php @@ -728,4 +728,17 @@ class Statusgruppen extends SimpleORMap implements PrivacyObject } } } + + /** + * Checks if a user is a member of a group. + * + * @param string $user_id The user id + * @return boolean <b>true</b> if user is a member of this group + * + * @SuppressWarnings(PHPMD.StaticAccess) + */ + public static function isMemberOf(string $gruppenId, string $userId): bool + { + return StatusgruppeUser::countBySql('statusgruppe_id = ? AND user_id = ?', [$gruppenId, $userId]) !== 0; + } } diff --git a/lib/modules/CoursewareModule.class.php b/lib/modules/CoursewareModule.class.php index 6766f8ce387f4aa4cb0230e1b767c998240c588b..e48f8b991885d761c502d06a8851bc798db15531 100644 --- a/lib/modules/CoursewareModule.class.php +++ b/lib/modules/CoursewareModule.class.php @@ -71,6 +71,10 @@ class CoursewareModule extends CorePlugin implements SystemPlugin, StudipModule 'tasks', new Navigation(_('Aufgaben'), 'dispatch.php/course/courseware/tasks?cid=' . $courseId) ); + $navigation->addSubNavigation( + 'peer-review', + new Navigation(_('Peer-Reviews'), 'dispatch.php/course/courseware/tasks/peer-review-processes?cid=' . $courseId) + ); $navigation->addSubNavigation( 'comments', new Navigation(_('Kommentare und Feedback'), 'dispatch.php/course/courseware/comments_overview?cid=' . $courseId) diff --git a/package.json b/package.json index 3e8939b1a144039470149380c267b46fda7d6282..b99f9e58aee5ec775b3a3b47a147dceaa0e72ab0 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "webpack-dev": "webpack --config webpack.dev.js --mode development", "webpack-prod": "webpack --config webpack.prod.js --mode production", "webpack-watch": "webpack --config webpack.dev.js --mode development --watch", - "test": "jest tests/jest/" + "test": "jest tests/jest/", + "storybook": "start-storybook -p 6006", + "build-storybook": "build-storybook" }, "author": "", "license": "GPL-2.0", @@ -67,6 +69,14 @@ "@johmun/vue-tags-input": "^2.1.0", "@playwright/test": "^1.33.0", "@popperjs/core": "^2.11.2", + "@storybook/addon-actions": "^6.5.13", + "@storybook/addon-essentials": "^6.5.13", + "@storybook/addon-interactions": "^6.5.13", + "@storybook/addon-links": "^6.5.13", + "@storybook/builder-webpack5": "^6.5.13", + "@storybook/manager-webpack5": "^6.5.13", + "@storybook/testing-library": "^0.0.13", + "@storybook/vue": "^6.5.13", "@types/jquery": "^3.5.16", "@types/jqueryui": "^1.12.16", "@types/lodash": "^4.14.191", @@ -87,6 +97,7 @@ "easygettext": "^2.17.0", "es6-promise": "4.2.8", "eslint": "^7.32.0", + "eslint-plugin-storybook": "^0.6.6", "eslint-plugin-vue": "^9.10.0", "eslint-webpack-plugin": "^3.1.1", "expose-loader": "1.0.1", @@ -135,7 +146,7 @@ "vue": "^2.6.12", "vue-dragscroll": "^3.0.1", "vue-gettext": "^2.1.12", - "vue-loader": "^15.9.8", + "vue-loader": "^15.10.1", "vue-router": "^3.5.1", "vue-select": "^3.11.2", "vue-template-babel-compiler": "^1.2.0", diff --git a/resources/assets/javascripts/lib/gettext.ts b/resources/assets/javascripts/lib/gettext.ts new file mode 100644 index 0000000000000000000000000000000000000000..23daaaa075e371bd14b74cf7d4fb048792539b90 --- /dev/null +++ b/resources/assets/javascripts/lib/gettext.ts @@ -0,0 +1,114 @@ +import { translate } from 'vue-gettext'; +import * as defaultTranslations from '../../../../locale/de/LC_MESSAGES/js-resources.json'; +import eventBus from './event-bus'; + +interface StringDict { + [key: string]: string; +} + +interface InstalledLanguage { + name: string; + selected: boolean; +} + +interface InstalledLanguages { + [key: string]: InstalledLanguage; +} + +type TranslationDict = StringDict; + +interface TranslationDicts { + [key: string]: TranslationDict | null; +} + +const DEFAULT_LANG = 'de_DE'; +const DEFAULT_LANG_NAME = 'Deutsch'; + +const state = getInitialState(); + +const $gettext = translate.gettext.bind(translate); +const $ngettext = translate.ngettext.bind(translate); +const $gettextInterpolate = translate.gettextInterpolate.bind(translate); + +export { $gettext, $ngettext, $gettextInterpolate, translate, getLocale, setLocale, getVueConfig }; + +function getLocale() { + return state.locale; +} + +async function setLocale(locale = getInitialLocale()) { + if (!(locale in getInstalledLanguages())) { + throw new Error('Invalid locale: ' + locale); + } + + state.locale = locale; + if (state.translations[state.locale] === null) { + const translations: TranslationDict = await getTranslations(state.locale); + state.translations[state.locale] = translations; + } + + translate.initTranslations(state.translations, { + getTextPluginMuteLanguages: [DEFAULT_LANG], + getTextPluginSilent: false, + language: state.locale, + silent: false, + }); + + eventBus.emit('studip:set-locale', state.locale); +} + +function getVueConfig() { + const availableLanguages = Object.entries(getInstalledLanguages()).reduce((memo, [lang, { name }]) => { + memo[lang] = name; + + return memo; + }, {} as StringDict); + + return { + availableLanguages, + defaultLanguage: DEFAULT_LANG, + muteLanguages: [DEFAULT_LANG], + silent: false, + translations: state.translations, + }; +} + +function getInitialState() { + const translations: TranslationDicts = Object.entries(getInstalledLanguages()).reduce((memo, [lang]) => { + memo[lang] = lang === DEFAULT_LANG ? defaultTranslations : null; + + return memo; + }, {} as TranslationDicts); + + return { + locale: DEFAULT_LANG, + translations, + }; +} + +function getInitialLocale() { + for (const [lang, { selected }] of Object.entries(getInstalledLanguages())) { + if (selected) { + return lang; + } + } + + return DEFAULT_LANG; +} + +function getInstalledLanguages(): InstalledLanguages { + return window?.STUDIP?.INSTALLED_LANGUAGES ?? { [DEFAULT_LANG]: { name: DEFAULT_LANG_NAME, selected: true } }; +} + +async function getTranslations(locale: string): Promise<TranslationDict> { + try { + const language = locale.split(/[_-]/)[0]; + const translation = await import(`../../../../locale/${language}/LC_MESSAGES/js-resources.json`); + + return translation; + } catch (exception) { + console.error('Could not load locale: "' + locale + '"', exception); + + return {}; + } +} diff --git a/resources/stories/ActionMenu.stories.js b/resources/stories/ActionMenu.stories.js new file mode 100644 index 0000000000000000000000000000000000000000..c1f2f64b2f8dead6c95df2792fdd55dfc1d27806 --- /dev/null +++ b/resources/stories/ActionMenu.stories.js @@ -0,0 +1,34 @@ +import StudipActionMenu from '../vue/components/StudipActionMenu.vue'; + +export default { + title: 'Stud.IP/ActionMenu', + component: StudipActionMenu, + argTypes: { + }, +}; + +const ActionMenuStory = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { StudipActionMenu }, + template: `<StudipActionMenu v-bind="$props"></StudipActionMenu>`, +}); + +export const SimpleActionMenuStory = ActionMenuStory.bind({}); +SimpleActionMenuStory.args = { + items: [ + { + id: 1, + attributes: {}, + classes: '', + disabled: false, + emit: false, + emitArguments: [], + icon: 'progress', + label: 'label', + name: null, + type: 'link', + url: '#', + }, + ], +}; +SimpleActionMenuStory.storyName = 'Exceptions'; diff --git a/resources/stories/ContentBox.stories.js b/resources/stories/ContentBox.stories.js new file mode 100644 index 0000000000000000000000000000000000000000..02f747b2f6f28ed21566d6aa4ec61683e3e9ca62 --- /dev/null +++ b/resources/stories/ContentBox.stories.js @@ -0,0 +1,30 @@ +// StudipContentBox.vue +import StudipContentBox from '../vue/components/StudipContentBox.vue'; + +export default { + title: 'Stud.IP/ContentBox', + component: StudipContentBox, + argTypes: { + type: {}, + }, +}; + +const ContentBoxStory = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { StudipContentBox }, + template: `<StudipContentBox v-bind="$props" :details="details">Hallo Welt</StudipContentBox>`, +}); + +export const SimpleBox = ContentBoxStory.bind({}); +SimpleBox.args = { + icon: 'group2', + items: [ + { + icon: 'accept', + id: 1, + label: 'title', + }, + ], + title: 'Simple ContentBox', +}; +SimpleBox.storyName = 'Simple ContentBox'; diff --git a/resources/stories/Courseware/Tasks/PeerReviewEditorForm.stories.js b/resources/stories/Courseware/Tasks/PeerReviewEditorForm.stories.js new file mode 100644 index 0000000000000000000000000000000000000000..15f2070b8b9bcece93798565c6aec7aed4f5db8a --- /dev/null +++ b/resources/stories/Courseware/Tasks/PeerReviewEditorForm.stories.js @@ -0,0 +1,25 @@ +import EditorForm from '../../../vue/components/courseware/tasks/peer-review/EditorForm.vue'; + +export default { + title: 'Stud.IP/Courseware/Aufgaben/PeerReview/Editor', + component: EditorForm, + argTypes: { + onCancel: { action: 'cancelled' }, + onSave: { action: 'saved' }, + }, +}; + +const Template = (args, { argTypes }) => ({ + components: { EditorForm }, + props: Object.keys(argTypes), + template: '<EditorForm v-bind="$props" @save="onSave" @cancel="onCancel" />', +}); + +export const EditorFormStory = Template.bind({}); +EditorFormStory.args = { + criteria: [ + { text: 'foo', description: 'bar' }, + { text: 'baz', description: 'bam' }, + ], +}; +EditorFormStory.storyName = 'Peer-Review Editor für Formularformat'; diff --git a/resources/stories/Courseware/Tasks/PeerReviewEditorTable.stories.js b/resources/stories/Courseware/Tasks/PeerReviewEditorTable.stories.js new file mode 100644 index 0000000000000000000000000000000000000000..7128f8ff49debcedab4ec22742248de1fba765f3 --- /dev/null +++ b/resources/stories/Courseware/Tasks/PeerReviewEditorTable.stories.js @@ -0,0 +1,25 @@ +import EditorTable from '../../../vue/components/courseware/tasks/peer-review/EditorTable.vue'; + +export default { + title: 'Stud.IP/Courseware/Aufgaben/PeerReview/Editor', + component: EditorTable, + argTypes: { + onCancel: { action: 'cancelled' }, + onSave: { action: 'saved' }, + }, +}; + +const Template = (args, { argTypes }) => ({ + components: { EditorTable }, + props: Object.keys(argTypes), + template: '<EditorTable v-bind="$props" @save="onSave" @cancel="onCancel" />', +}); + +export const EditorTableStory = Template.bind({}); +EditorTableStory.args = { + criteria: [ + { text: 'foo' }, + { text: 'bar' }, + ], +}; +EditorTableStory.storyName = 'Peer-Review Editor für Tabellenformat'; diff --git a/resources/stories/Courseware/Tasks/PeerReviewProcessCreateDialog.stories.js b/resources/stories/Courseware/Tasks/PeerReviewProcessCreateDialog.stories.js new file mode 100644 index 0000000000000000000000000000000000000000..32329fe25f9bd7181b298fdead3a065fb28cc53a --- /dev/null +++ b/resources/stories/Courseware/Tasks/PeerReviewProcessCreateDialog.stories.js @@ -0,0 +1,99 @@ +import PeerReviewProcessCreateDialog from '../../../vue/components/courseware/tasks/peer-review/ProcessCreateDialog.vue'; + +export default { + title: 'Stud.IP/Courseware/Aufgaben/PeerReview', + component: PeerReviewProcessCreateDialog, + argTypes: { + type: {}, + }, +}; + +const PeerReviewProcessCreateDialogStory = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { PeerReviewProcessCreateDialog }, + template: `<PeerReviewProcessCreateDialog v-bind="$props"></PeerReviewProcessCreateDialog>`, +}); + +export const Dialog = PeerReviewProcessCreateDialogStory.bind({}); +Dialog.args = { + taskGroup: getTaskGroup(), +}; +Dialog.storyName = 'Peer-Review-Prozess-Dialog'; + +function getTaskGroup() { + return { + type: 'courseware-task-groups', + id: '1', + attributes: { + 'solver-may-add-blocks': true, + title: 'Aufgabe a', + 'start-date': '2023-06-14T06:40:16+02:00', + 'end-date': '2023-07-21T20:30:40+02:00', + mkdate: '2023-06-14T06:40:16+02:00', + chdate: '2023-06-14T06:40:16+02:00', + }, + relationships: { + course: { + links: { + related: '/jsonapi.php/v1/courses/a07535cf2f8a72df33c12ddfa4b53dde', + }, + data: { + type: 'courses', + id: 'a07535cf2f8a72df33c12ddfa4b53dde', + }, + }, + lecturer: { + links: { + related: '/jsonapi.php/v1/users/205f3efb7997a0fc9755da2b535038da', + }, + data: { + type: 'users', + id: '205f3efb7997a0fc9755da2b535038da', + }, + }, + 'peer-review-processes': { + links: { + related: '/jsonapi.php/v1/courseware-task-groups/1/peer-review-processes', + }, + data: [], + }, + solvers: { + data: [ + { + type: 'users', + id: 'e7a0a84b161f3e8c09b4a0a2e8a58147', + }, + ], + }, + target: { + links: { + related: '/jsonapi.php/v1/courseware-structural-elements/1', + }, + data: { + type: 'courseware-structural-elements', + id: '1', + }, + }, + 'task-template': { + links: { + related: '/jsonapi.php/v1/courseware-structural-elements/5', + }, + data: { + type: 'courseware-structural-elements', + id: '5', + }, + }, + tasks: { + data: [ + { + type: 'courseware-tasks', + id: '1', + }, + ], + }, + }, + links: { + self: '/jsonapi.php/v1/courseware-task-groups/1', + }, + }; +} diff --git a/resources/stories/DatePicker.stories.js b/resources/stories/DatePicker.stories.js new file mode 100644 index 0000000000000000000000000000000000000000..d120de75b5f0f959cbc580ea4516b0c4e433b6f3 --- /dev/null +++ b/resources/stories/DatePicker.stories.js @@ -0,0 +1,28 @@ +import DatePicker from '../vue/components/DatePicker.vue'; + +export default { + title: 'Stud.IP/DatePicker', + component: DatePicker, + argTypes: {}, +}; + +const DatePickerStory = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { DatePicker }, + template: `<DatePicker v-bind="$props"></DatePicker>`, +}); + +function tonight() { + const tonight = new Date(); + tonight.setHours(0); + tonight.setMinutes(0); + return tonight; +} + +export const SimpleDatePickerStory = DatePickerStory.bind({}); +SimpleDatePickerStory.args = { + name: 'SimpleDatePicker', + value: new Date() / 1000, + mindate: tonight(), +}; +SimpleDatePickerStory.storyName = 'Date-Picker'; diff --git a/resources/stories/DateTime.stories.js b/resources/stories/DateTime.stories.js new file mode 100644 index 0000000000000000000000000000000000000000..b46e680b369028a99a8977b8fe08d1a0dbba5ba9 --- /dev/null +++ b/resources/stories/DateTime.stories.js @@ -0,0 +1,27 @@ +import StudipDateTime from '../vue/components/StudipDateTime.vue'; + +export default { + title: 'Stud.IP/DateTime', + component: StudipDateTime, + argTypes: { + }, +}; + +const DateTimeStory = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { StudipDateTime }, + template: `<StudipDateTime v-bind="$props"></StudipDateTime>`, +}); + +export const SimpleDateTimeStory = DateTimeStory.bind({}); +SimpleDateTimeStory.args = { + timestamp: Math.floor((new Date()) / 1000), +}; +SimpleDateTimeStory.storyName = 'Einfache Zeit'; + +export const RelativeDateTimeStory = DateTimeStory.bind({}); +RelativeDateTimeStory.args = { + relative: true, + timestamp: Math.floor((new Date()) / 1000), +}; +RelativeDateTimeStory.storyName = 'Relative Zeit'; diff --git a/resources/stories/Icon.stories.js b/resources/stories/Icon.stories.js new file mode 100644 index 0000000000000000000000000000000000000000..85d1dd3401f28ea5333287ccc1cfb4667f214a38 --- /dev/null +++ b/resources/stories/Icon.stories.js @@ -0,0 +1,27 @@ +import StudipIcon from '../vue/components/StudipIcon.vue'; + +export default { + title: 'Stud.IP/Icon', + component: StudipIcon, + argTypes: { + }, + parameters: { + docs: { + page: null, + }, + }, +}; + +const IconStory = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { StudipIcon }, + template: `<StudipIcon v-bind="$props"></StudipIcon>`, +}); + +export const SimpleIconStory = IconStory.bind({}); +SimpleIconStory.args = { + shape: "progress", + role: "clickable", + size: 32, +}; +SimpleIconStory.storyName = 'Einfaches Icon'; diff --git a/resources/stories/Icons-Documentations.stories.mdx b/resources/stories/Icons-Documentations.stories.mdx new file mode 100644 index 0000000000000000000000000000000000000000..b89cf0a2b05abe0a4ac4318c06d1fce6ec128e23 --- /dev/null +++ b/resources/stories/Icons-Documentations.stories.mdx @@ -0,0 +1,58 @@ +# Replacing DocsPage with custom `MDX` content + +This file is a documentation-only `MDX`file to customize Storybook's [DocsPage](https://storybook.js.org/docs/react/writing-docs/docs-page#replacing-docspage). + +It can be further expanded with your own code snippets and include specific information related to your stories. + +For example: + +import { Story } from "@storybook/addon-docs"; + +## Button + +Button is the primary component. It has four possible states. + +- [Primary](#primary) +- [Secondary](#secondary) +- [Large](#large) +- [Small](#small) + +## With the story title defined + +If you included the title in the story's default export, use this approach. + +### Primary + +<Story id="example-button--primary" /> + +### Secondary + +<Story id="example-button--secondary" /> + +### Large + +<Story id="example-button--large" /> + +### Small + +<Story id="example-button--small" /> + +## Without the story title defined + +If you didn't include the title in the story's default export, use this approach. + +### Primary + +<Story id="your-directory-button--primary"/> + +### Secondary + +<Story id="your-directory-button--secondary"/> + +### Large + +<Story id="your-directory-button--large"/> + +### Small + +<Story id="your-directory-button--small" /> diff --git a/resources/stories/MessageBox.stories.js b/resources/stories/MessageBox.stories.js new file mode 100644 index 0000000000000000000000000000000000000000..b08c3cc39de4b1e081f7f211995d174388cf8488 --- /dev/null +++ b/resources/stories/MessageBox.stories.js @@ -0,0 +1,39 @@ +// StudipMessageBox.vue +import StudipMessageBox from '../vue/components/StudipMessageBox.vue'; + +export default { + title: 'Stud.IP/MessageBox', + component: StudipMessageBox, + argTypes: { + type: { + control: { type: 'select' }, + options: ['exception', 'error', 'warning', 'success', 'info'], + }, + }, +}; + +const MessageBoxStory = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { StudipMessageBox }, + template: `<StudipMessageBox v-bind="$props" :details="details">Hallo Welt</StudipMessageBox>`, +}); + +export const ExceptionBox = MessageBoxStory.bind({}); +ExceptionBox.args = { type: 'exception' }; +ExceptionBox.storyName = 'Exceptions'; + +export const ErrorBox = MessageBoxStory.bind({}); +ErrorBox.args = { type: 'error' }; +ErrorBox.storyName = 'Error'; + +export const WarningBox = MessageBoxStory.bind({}); +WarningBox.args = { type: 'warning' }; +WarningBox.storyName = 'Warning'; + +export const SuccessBox = MessageBoxStory.bind({}); +SuccessBox.args = { type: 'success' }; +SuccessBox.storyName = 'Success'; + +export const InfoBox = MessageBoxStory.bind({}); +InfoBox.args = { type: 'info' }; +InfoBox.storyName = 'Info'; diff --git a/resources/vue-gettext.d.ts b/resources/vue-gettext.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..b3f4c6611cb250c2f9bf0f020cb43ab7439f9ac3 --- /dev/null +++ b/resources/vue-gettext.d.ts @@ -0,0 +1,17 @@ +declare module "vue-gettext" { + import GettextPlugin from 'vue-gettext'; + + declare namespace translate { + function getTranslation(msgid: any, n?: number, context?: any, defaultPlural?: any, language?: string): any; + function gettext(msgid: any, language?: string): any; + function pgettext(context: any, msgid: any, language?: string): any; + function ngettext(msgid: any, plural: any, n: any, language?: string): any; + function npgettext(context: any, msgid: any, plural: any, n: any, language?: string): any; + function initTranslations(translations: any, config: any): void; + const gettextInterpolate: any; + } + + export { translate }; + + export default GettextPlugin; +} diff --git a/resources/vue/components/DatePicker.vue b/resources/vue/components/DatePicker.vue new file mode 100644 index 0000000000000000000000000000000000000000..90a412f59e64680a9798288e8221821c16a2626a --- /dev/null +++ b/resources/vue/components/DatePicker.vue @@ -0,0 +1,78 @@ +<template> + <span> + <input type="hidden" :name="name" :value="value" /> + <input + type="text" + ref="visibleInput" + class="visible_input" + @change="setDate" + v-bind="$attrs" + v-on="$listeners" + /> + </span> +</template> + +<script> +function midnight(tonight = new Date()) { + tonight.setHours(0); + tonight.setMinutes(0); + return tonight; +} + +export default { + inheritAttrs: false, + props: { + name: { + type: String, + required: false, + }, + value: { + type: Date, + required: false, + }, + mindate: { + type: Date, + required: false, + }, + maxdate: { + type: Date, + required: false, + }, + }, + methods: { + setDate() { + const date = $(this.$refs.visibleInput).datepicker('getDate'); + this.$emit('input', date); + }, + }, + mounted() { + if (!this.value) { + this.value = midnight(); + } + const that = this; + $(this.$refs.visibleInput) + .datepicker({ + onSelect: function () { + that.setDate(); + }, + minDate: this.mindate ?? null, + maxDate: this.maxdate ?? null, + }) + .datepicker('setDate', this.value); + }, + beforeDestroy() { + $(this.$refs.visibleInput).datepicker('destroy'); + }, + watch: { + value(newValue) { + $(this.$refs.visibleInput).datepicker('setDate', newValue); + }, + mindate(newValue) { + $(this.$refs.visibleInput).datepicker('option', 'minDate', newValue); + }, + maxdate(newValue) { + $(this.$refs.visibleInput).datepicker('option', 'maxDate', newValue); + }, + }, +}; +</script> diff --git a/resources/vue/components/Datetimepicker.vue b/resources/vue/components/Datetimepicker.vue index 6b12ad78abc2896cf607f85601f1033058ddc4bd..e8b0789da5b6a9d57ea6066445755b3ef659787a 100644 --- a/resources/vue/components/Datetimepicker.vue +++ b/resources/vue/components/Datetimepicker.vue @@ -11,6 +11,10 @@ </template> <script> +function convertDate(date) { + return date instanceof Date ? date : new Date(date * 1000); +} + export default { name: 'datetimepicker', inheritAttrs: false, @@ -66,19 +70,19 @@ export default { } }; if (this.mindate) { - params.minDate = new Date(this.mindate * 1000) + params.minDate = convertDate(this.mindate); } if (this.maxdate) { - params.maxDate = new Date(this.maxdate * 1000) + params.maxDate = convertDate(this.maxdate); } $(this.$refs.visibleInput).datetimepicker(params); }, watch: { mindat (new_data, old_data) { - $(this.$refs.visibleInput).datetimepicker('option', 'minDate', new Date(new_data * 1000)); + $(this.$refs.visibleInput).datetimepicker('option', 'minDate', convertDate(new_data)); }, maxdate (new_data, old_data) { - $(this.$refs.visibleInput).datetimepicker('option', 'maxDate', new Date(new_data * 1000)); + $(this.$refs.visibleInput).datetimepicker('option', 'maxDate', convertDate(new_data)); } } } diff --git a/resources/vue/components/StudipActionMenu.vue b/resources/vue/components/StudipActionMenu.vue index 014a1cacefc1857f4381e6bd5da019c9041039f6..f03becb5502744dbdd68d36c4cc96b6025cd77b5 100644 --- a/resources/vue/components/StudipActionMenu.vue +++ b/resources/vue/components/StudipActionMenu.vue @@ -17,13 +17,28 @@ <span v-else class="action-menu-no-icon"></span> {{ item.label }} </a> - <label v-else-if="item.icon" class="undecorated" v-bind="linkAttributes(item)" v-on="linkEvents(item)"> - <studip-icon :shape="item.icon.shape" :role="item.icon.role" :name="item.name" :title="item.label" v-bind="item.attributes ?? {}"></studip-icon> + <label + v-else-if="item.icon" + class="undecorated" + v-bind="linkAttributes(item)" + v-on="linkEvents(item)" + > + <StudipIcon + :shape="item.icon.shape" + :role="item.icon.role" + :name="item.name" + :title="item.label" + v-bind="item.attributes ?? {}" + ></StudipIcon> {{ item.label }} </label> <template v-else> <span class="action-menu-no-icon"></span> - <button :name="item.name" v-bind="Object.assign(item.attributes ?? {}, linkAttributes(item))" v-on="linkEvents(item)"> + <button + :name="item.name" + v-bind="Object.assign(item.attributes ?? {}, linkAttributes(item))" + v-on="linkEvents(item)" + > {{ item.label }} </button> </template> @@ -40,8 +55,11 @@ </template> <script> +import StudipIcon from './StudipIcon.vue'; + export default { name: 'studip-action-menu', + components: { StudipIcon }, props: { items: Array, collapseAt: { @@ -49,16 +67,16 @@ export default { }, context: { type: String, - default: '' - } + default: '', + }, }, - data () { + data() { return { - open: false + open: false, }; }, methods: { - linkAttributes (item) { + linkAttributes(item) { let attributes = item.attributes; attributes.class = item.classes; @@ -72,7 +90,7 @@ export default { return attributes; }, - linkEvents (item) { + linkEvents(item) { let events = {}; if (item.emit) { events.click = (e) => { @@ -83,26 +101,28 @@ export default { } return events; }, - close () { - STUDIP.ActionMenu.closeAll(); - } + close() { + window.STUDIP.ActionMenu?.closeAll(); + }, }, computed: { - navigationItems () { + navigationItems() { return this.items.map((item) => { let classes = item.classes ?? ''; if (item.disabled) { - classes += " action-menu-item-disabled"; + classes += ' action-menu-item-disabled'; } return { label: item.label, url: item.url || '#', emit: item.emit || false, emitArguments: item.emitArguments || [], - icon: item.icon ? { - shape: item.icon, - role: item.disabled ? 'inactive' : 'clickable' - } : false, + icon: item.icon + ? { + shape: item.icon, + role: item.disabled ? 'inactive' : 'clickable', + } + : false, type: item.type || 'link', name: item.name ?? null, classes: classes.trim(), @@ -122,9 +142,11 @@ export default { } return Number.parseInt(collapseAt) <= this.items.filter((item) => item.type !== 'separator').length; }, - title () { - return this.context ? this.$gettextInterpolate(this.$gettext('Aktionsmenü für %{context}'), {context: this.context}) : this.$gettext('Aktionsmenü'); - } - } -} + title() { + return this.context + ? this.$gettextInterpolate(this.$gettext('Aktionsmenü für %{context}'), { context: this.context }) + : this.$gettext('Aktionsmenü'); + }, + }, +}; </script> diff --git a/resources/vue/components/StudipArticle.vue b/resources/vue/components/StudipArticle.vue new file mode 100644 index 0000000000000000000000000000000000000000..e91f69d39c4c371611719c52beb0a1e240138fa1 --- /dev/null +++ b/resources/vue/components/StudipArticle.vue @@ -0,0 +1,63 @@ +<template> + <article class="studip" :class="{ collapsable, collapsed }" v-bind="$attrs"> + <header> + <h1 @click="doToggle"> + <template v-if="collapsable"> + <StudipIcon class="studip-articles--icon" shape="arr_1right" v-if="collapsed" /> + <StudipIcon class="studip-articles--icon" shape="arr_1down" v-else /> + </template> + <slot name="title" v-bind="{ isOpen: collapsed }"></slot> + </h1> + <slot v-if="$slots.titleplus" name="titleplus"></slot> + </header> + <section v-if="!collapsed"> + <slot name="body"></slot> + </section> + <footer v-if="$slots.footer"> + <slot name="footer"></slot> + </footer> + </article> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import StudipIcon from './StudipIcon.vue'; + +export default Vue.extend({ + props: { + collapsable: { + type: Boolean, + default: false, + }, + closed: { + type: Boolean, + default: false, + }, + }, + components: { StudipIcon }, + data() { + return { collapsed: this.closed }; + }, + methods: { + doToggle() { + if (this.collapsable) { + this.collapsed = !this.collapsed; + } + }, + }, +}); +</script> +<style scoped> +article.studip.collapsable.collapsed { + padding-block-end: 0; +} +article.studip.collapsable.collapsed > header { + margin-block-end: 0; +} +article.studip.collapsable > header > h1 { + cursor: pointer; +} + +.studip-articles--icon { +} +</style> diff --git a/resources/vue/components/StudipContentBox.vue b/resources/vue/components/StudipContentBox.vue new file mode 100644 index 0000000000000000000000000000000000000000..ab5bf9584c20ff267e6b0cba472940af5a0b1933 --- /dev/null +++ b/resources/vue/components/StudipContentBox.vue @@ -0,0 +1,46 @@ +<template> + <section class="contentbox"> + <header v-if="title || $slots.header"> + <slot name="header"> + <h1> + <StudipIcon v-if="icon" :shape="icon" /> + {{ title }} + </h1> + </slot> + <slot name="header-nav"> + <nav v-if="items"> + <StudipActionMenu :items="items" /> + </nav> + </slot> + </header> + + <slot></slot> + + <footer> + <slot name="footer"></slot> + </footer> + </section> +</template> + +<script> +import StudipActionMenu from './StudipActionMenu.vue'; +import StudipIcon from './StudipIcon.vue'; + +export default { + components: { StudipActionMenu, StudipIcon }, + props: { + icon: { + type: String, + required: false, + }, + items: { + type: Array, + required: false, + }, + title: { + type: String, + required: true, + }, + }, +}; +</script> diff --git a/resources/vue/components/StudipDate.vue b/resources/vue/components/StudipDate.vue new file mode 100644 index 0000000000000000000000000000000000000000..2e30b9d250a5853d6dd23926119a8f7332f98001 --- /dev/null +++ b/resources/vue/components/StudipDate.vue @@ -0,0 +1,27 @@ +<template> + <time :datetime="date.toISOString()">{{ formatted }}</time> +</template> + +<script> +function formatDate(date) { + return pad(date.getDate()) + '.' + pad(date.getMonth() + 1) + '.' + date.getFullYear(); +} + +function pad(what) { + return what.toString().padStart(2, '0'); +} + +export default { + props: { + date: { + type: Date, + required: true, + }, + }, + computed: { + formatted() { + return formatDate(this.date); + }, + }, +}; +</script> diff --git a/resources/vue/components/StudipUserAvatar.vue b/resources/vue/components/StudipUserAvatar.vue new file mode 100644 index 0000000000000000000000000000000000000000..eecd4a7b228afb9746ffa842306c8a2c163978f7 --- /dev/null +++ b/resources/vue/components/StudipUserAvatar.vue @@ -0,0 +1,32 @@ +<template> + <div class="studip-user-avatar"> + <span> + <img :src="avatarUrl" /> + </span> + <span>{{ formattedName }}</span> + </div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: { + avatarUrl: { + type: String, + required: true, + }, + formattedName: { + type: String, + required: true, + }, + }, +}); +</script> + +<style scoped> +.studip-user-avatar { + align-items: center; + display: flex; + gap: 0.25rem; +} +</style> diff --git a/resources/vue/components/courseware/CoursewareDashboardStudents.vue b/resources/vue/components/courseware/CoursewareDashboardStudents.vue deleted file mode 100644 index ff59669729a601d3373bf5705a56475595de1cc1..0000000000000000000000000000000000000000 --- a/resources/vue/components/courseware/CoursewareDashboardStudents.vue +++ /dev/null @@ -1,481 +0,0 @@ -<template> - <div class="cw-dashboard-students-wrapper"> - <table v-if="tasks.length > 0" class="default"> - <colgroup> - <col /> - </colgroup> - <thead> - <tr class="sortable"> - <th>{{ $gettext('Status') }}</th> - <th :class="getSortClass('task-title')" @click="sort('task-title')"> - {{ $gettext('Aufgabentitel') }} - </th> - <th :class="getSortClass('solver-name')" @click="sort('solver-name')"> - {{ $gettext('Teilnehmende/Gruppen') }} - </th> - <th class="responsive-hidden" :class="getSortClass('page-title')" @click="sort('page-title')"> - {{ $gettext('Seite') }} - </th> - <th :class="getSortClass('progress')" @click="sort('progress')"> - {{ $gettext('bearbeitet') }} - </th> - <th :class="getSortClass('submission-date')" @click="sort('submission-date')"> - {{ $gettext('Abgabefrist') }} - </th> - <th>{{ $gettext('Abgabe') }}</th> - <th class="responsive-hidden renewal">{{ $gettext('Verlängerungsanfrage') }}</th> - <th class="responsive-hidden feedback">{{ $gettext('Feedback') }}</th> - </tr> - </thead> - <tbody> - <tr v-for="{ task, taskGroup, status, element, user, group, feedback } in tasks" :key="task.id"> - <td> - <studip-icon - v-if="status.shape !== undefined" - :shape="status.shape" - :role="status.role" - :title="status.description" - aria-hidden="true" - /> - <span class="sr-only">{{ status.description }}</span> - </td> - <td> - {{ taskGroup && taskGroup.attributes.title }} - </td> - <td> - <span v-if="user"> - <studip-icon - shape="person2" - role="info" - aria-hidden="true" - :title="$gettext('Teilnehmende Person')" - /> - <span class="sr-only">{{ $gettext('Teilnehmende Person') }}</span> - {{ user.attributes['formatted-name'] }} - - </span> - <span v-if="group"> - <studip-icon - shape="group2" - role="info" - aria-hidden="true" - :title="$gettext('Gruppe')" - /> - <span class="sr-only">{{ $gettext('Gruppe') }}</span> - {{ group.attributes['name'] }} - - </span> - </td> - <td class="responsive-hidden"> - <a v-if="task.attributes.submitted" :href="getLinkToElement(element)"> - {{ element.attributes.title }} - </a> - <span v-else>{{ element.attributes.title }}</span> - </td> - <td>{{ task.attributes?.progress?.toFixed(2) || '-.--' }}%</td> - <td>{{ getReadableDate(task.attributes['submission-date']) }}</td> - <td> - <studip-icon v-if="task.attributes.submitted" shape="accept" role="status-green" /> - </td> - <td class="responsive-hidden"> - <button - v-show="task.attributes.renewal === 'pending'" - class="button" - @click="solveRenewalRequest(task)" - > - {{ $gettext('Anfrage bearbeiten') }} - </button> - <span v-show="task.attributes.renewal === 'declined'"> - <studip-icon shape="decline" role="status-red" /> - {{ $gettext('Anfrage abgelehnt') }} - </span> - <span v-show="task.attributes.renewal === 'granted'"> - {{ $gettext('verlängert bis') }}: - {{ getReadableDate(task.attributes['renewal-date']) }} - </span> - <studip-icon - v-if="task.attributes.renewal === 'declined' || task.attributes.renewal === 'granted'" - :title="$gettext('Anfrage bearbeiten')" - class="edit" - shape="edit" - role="clickable" - @click="solveRenewalRequest(task)" - /> - </td> - <td class="responsive-hidden"> - <span - v-if="feedback" - :title=" - $gettext('Feedback geschrieben am:') + - ' ' + - getReadableDate(feedback.attributes['chdate']) - " - > - <studip-icon shape="accept" role="status-green" /> - {{ $gettext('Feedback gegeben') }} - <studip-icon - :title="$gettext('Feedback bearbeiten')" - class="edit" - shape="edit" - role="clickable" - @click="editFeedback(feedback)" - /> - </span> - - <button - v-show="!feedback && task.attributes.submitted" - class="button" - @click="addFeedback(task)" - > - {{ $gettext('Feedback geben') }} - </button> - </td> - </tr> - </tbody> - </table> - <div v-else> - <courseware-companion-box - mood="pointing" - :msgCompanion="$gettext('Es wurden bisher keine Aufgaben gestellt.')" - > - </courseware-companion-box> - </div> - <studip-dialog - v-if="showRenewalDialog" - :title="text.renewalDialog.title" - :confirmText="text.renewalDialog.confirm" - confirmClass="accept" - :closeText="text.renewalDialog.close" - closeClass="cancel" - height="350" - @close=" - showRenewalDialog = false; - currentDialogTask = {}; - " - @confirm="updateRenewal" - > - <template v-slot:dialogContent> - <form class="default" @submit.prevent=""> - <label> - {{ $gettext('Fristverlängerung') }} - <select v-model="currentDialogTask.attributes.renewal"> - <option value="declined"> - {{ $gettext('ablehnen') }} - </option> - <option value="granted"> - {{ $gettext('gewähren') }} - </option> - </select> - </label> - <label v-if="currentDialogTask.attributes.renewal === 'granted'"> - {{ $gettext('neue Frist') }} - <courseware-date-input v-model="currentDialogTask.attributes['renewal-date']" class="size-l" /> - </label> - </form> - </template> - </studip-dialog> - <studip-dialog - v-if="showEditFeedbackDialog" - :title="text.editFeedbackDialog.title" - :confirmText="text.editFeedbackDialog.confirm" - confirmClass="accept" - :closeText="text.editFeedbackDialog.close" - closeClass="cancel" - height="420" - @close=" - showEditFeedbackDialog = false; - currentDialogFeedback = {}; - " - @confirm="updateFeedback" - > - <template v-slot:dialogContent> - <courseware-companion-box - v-if="currentDialogFeedback.attributes.content === ''" - mood="pointing" - :msgCompanion=" - $gettext('Sie haben kein Feedback geschrieben, beim Speichern wird dieses Feedback gelöscht!') - " - /> - <form class="default" @submit.prevent=""> - <label> - {{ $gettext('Feedback') }} - <textarea v-model="currentDialogFeedback.attributes.content" /> - </label> - </form> - </template> - </studip-dialog> - <studip-dialog - v-if="showAddFeedbackDialog" - :title="text.addFeedbackDialog.title" - :confirmText="text.addFeedbackDialog.confirm" - confirmClass="accept" - :closeText="text.addFeedbackDialog.close" - closeClass="cancel" - @close=" - showAddFeedbackDialog = false; - currentDialogFeedback = {}; - " - @confirm="createFeedback" - > - <template v-slot:dialogContent> - <form class="default" @submit.prevent=""> - <label> - {{ $gettext('Feedback') }} - <textarea v-model="currentDialogFeedback.attributes.content" /> - </label> - </form> - </template> - </studip-dialog> - <courseware-tasks-dialog-distribute v-if="showTasksDistributeDialog" @newtask="reloadTasks"/> - </div> -</template> - -<script> -import StudipIcon from './../StudipIcon.vue'; -import StudipDialog from './../StudipDialog.vue'; -import CoursewareCompanionBox from './layouts/CoursewareCompanionBox.vue'; -import CoursewareDateInput from './layouts/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], - components: { - CoursewareCompanionBox, - CoursewareDateInput, - StudipIcon, - StudipDialog, - CoursewareTasksDialogDistribute, - }, - data() { - return { - showRenewalDialog: false, - showAddFeedbackDialog: false, - showEditFeedbackDialog: false, - currentDialogTask: {}, - currentDialogFeedback: {}, - text: { - renewalDialog: { - title: this.$gettext('Verlängerungsanfrage bearbeiten'), - confirm: this.$gettext('Speichern'), - close: this.$gettext('Schließen'), - }, - editFeedbackDialog: { - title: this.$gettext('Feedback zur Aufgabe ändern'), - confirm: this.$gettext('Speichern'), - close: this.$gettext('Schließen'), - }, - addFeedbackDialog: { - title: this.$gettext('Feedback zur Aufgabe geben'), - confirm: this.$gettext('Speichern'), - close: this.$gettext('Schließen'), - }, - }, - sortBy: 'task-title', - sortASC: true, - }; - }, - computed: { - ...mapGetters({ - context: 'context', - allTasks: 'courseware-tasks/all', - userById: 'users/byId', - statusGroupById: 'status-groups/byId', - getElementById: 'courseware-structural-elements/byId', - getFeedbackById: 'courseware-task-feedback/byId', - relatedTaskGroups: 'courseware-task-groups/related', - showTasksDistributeDialog: 'showTasksDistributeDialog' - }), - tasks() { - const tasks = this.allTasks.map((task) => { - const result = { - task, - taskGroup: this.relatedTaskGroups({ parent: task, relationship: 'task-group' }), - status: this.getStatus(task), - element: this.getElementById({ id: task.relationships['structural-element'].data.id }), - user: null, - group: null, - feedback: null, - solverName: null - }; - let solver = task.relationships.solver.data; - if (solver.type === 'users') { - result.user = this.userById({ id: solver.id }); - result.solverName = result.user.attributes['formatted-name']; - } - if (solver.type === 'status-groups') { - result.group = this.statusGroupById({ id: solver.id }); - result.solverName = result.group.attributes['name']; - } - - const feedbackId = task.relationships['task-feedback'].data?.id; - if (feedbackId) { - result.feedback = this.getFeedbackById({ id: feedbackId }); - } - - return result; - }); - - return this.sortTasks(tasks); - }, - managerUrl() { - return STUDIP.URLHelper.getURL('dispatch.php/course/courseware/manager', {cid: this.context.id}); - } - }, - methods: { - ...mapActions({ - updateTask: 'updateTask', - createTaskFeedback: 'createTaskFeedback', - updateTaskFeedback: 'updateTaskFeedback', - deleteTaskFeedback: 'deleteTaskFeedback', - loadRemoteCoursewareStructure: 'loadRemoteCoursewareStructure', - copyStructuralElement: 'copyStructuralElement', - companionSuccess: 'companionSuccess', - companionError: 'companionError', - loadAllTasks: 'courseware-tasks/loadAll' - }), - addFeedback(task) { - this.currentDialogFeedback.attributes = {}; - this.currentDialogFeedback.attributes.content = ''; - this.currentDialogFeedback.relationships = {}; - this.currentDialogFeedback.relationships.task = {}; - this.currentDialogFeedback.relationships.task.data = {}; - this.currentDialogFeedback.relationships.task.data.id = task.id; - this.currentDialogFeedback.relationships.task.data.type = task.type; - this.showAddFeedbackDialog = true; - }, - createFeedback() { - if (this.currentDialogFeedback.attributes.content === '') { - this.companionError({ - info: this.$gettext('Bitte schreiben Sie ein Feedback.'), - }); - return false; - } - this.showAddFeedbackDialog = false; - this.createTaskFeedback({ - taskFeedback: this.currentDialogFeedback, - }); - this.currentDialogFeedback = {}; - }, - editFeedback(feedback) { - this.currentDialogFeedback = _.cloneDeep(feedback); - this.showEditFeedbackDialog = true; - }, - async updateFeedback() { - this.showEditFeedbackDialog = false; - let attributes = {}; - attributes.content = this.currentDialogFeedback.attributes.content; - if (attributes.content === '') { - await this.deleteTaskFeedback({ - taskFeedbackId: this.currentDialogFeedback.id, - }); - this.companionSuccess({ - info: this.$gettext('Feedback wurde gelöscht.'), - }); - } else { - await this.updateTaskFeedback({ - attributes: attributes, - taskFeedbackId: this.currentDialogFeedback.id, - }); - this.companionSuccess({ - info: this.$gettext('Feedback wurde gespeichert.'), - }); - } - - this.currentDialogFeedback = {}; - }, - solveRenewalRequest(task) { - this.currentDialogTask = _.cloneDeep(task); - this.currentDialogTask.attributes['renewal-date'] = new Date().toISOString(); - this.showRenewalDialog = true; - }, - updateRenewal() { - this.showRenewalDialog = false; - let attributes = {}; - attributes.renewal = this.currentDialogTask.attributes.renewal; - if (attributes.renewal === 'granted') { - attributes['renewal-date'] = new Date(this.currentDialogTask.attributes['renewal-date'] || Date.now()).toISOString(); - } - - this.updateTask({ - attributes: attributes, - taskId: this.currentDialogTask.id, - }); - this.currentDialogTask = {}; - }, - reloadTasks() { - this.loadAllTasks({ - options: { - 'filter[cid]': this.context.id, - include: 'solver, structural-element, task-feedback, task-group, task-group.lecturer' - } - }); - }, - getSortClass(col) { - if (col === this.sortBy) { - return this.sortASC ? 'sortasc' : 'sortdesc'; - } - }, - sort(sortBy) { - if (this.sortBy === sortBy) { - this.sortASC = !this.sortASC; - } else { - this.sortBy = sortBy; - } - }, - sortTasks(tasks) { - switch (this.sortBy) { - case 'task-title': - tasks = tasks.sort((a, b) => { - if (this.sortASC) { - return a.taskGroup.attributes.title < b.taskGroup.attributes.title ? -1 : 1; - } else { - return a.taskGroup.attributes.title > b.taskGroup.attributes.title ? -1 : 1; - } - }); - break; - case 'solver-name': - tasks = tasks.sort((a, b) => { - if (this.sortASC) { - return a.solverName < b.solverName ? -1 : 1; - } else { - return a.solverName > b.solverName ? -1 : 1; - } - }); - break; - case 'page-title': - tasks = tasks.sort((a, b) => { - if (this.sortASC) { - return a.element.attributes.title < b.element.attributes.title ? -1 : 1; - } else { - return a.element.attributes.title > b.element.attributes.title ? -1 : 1; - } - }); - break; - case 'progress': - tasks = tasks.sort((a, b) => { - if (this.sortASC) { - return a.task.attributes.progress < b.task.attributes.progress ? -1 : 1; - } else { - return a.task.attributes.progress > b.task.attributes.progress ? -1 : 1; - } - }); - break; - case 'submission-date': - tasks = tasks.sort((a, b) => { - if (this.sortASC) { - return new Date(a.task.attributes['submission-date']) - new Date(b.task.attributes['submission-date']); - } else { - return new Date(b.task.attributes['submission-date']) - new Date(a.task.attributes['submission-date']); - } - }); - break; - } - return tasks; - }, - }, -}; -</script> diff --git a/resources/vue/components/courseware/layouts/CoursewareCollapsibleBox.vue b/resources/vue/components/courseware/layouts/CoursewareCollapsibleBox.vue index 7415478383d85682fd5300a9116491ff36c28a02..d81aa2952d324f6ec7b79414340f414faf4afeff 100644 --- a/resources/vue/components/courseware/layouts/CoursewareCollapsibleBox.vue +++ b/resources/vue/components/courseware/layouts/CoursewareCollapsibleBox.vue @@ -2,11 +2,12 @@ <div class="cw-collapsible" :class="{ 'cw-collapsible-open': isOpen }"> <a href="#" :aria-expanded="isOpen" @click.prevent="isOpen = !isOpen"> <header :class="{ 'cw-collapsible-open': isOpen }" class="cw-collapsible-title"> - <studip-icon v-if="icon" :shape="icon" /> {{ title }} + <studip-icon v-if="icon" :shape="icon" /> + <slot name="title" :is-open="isOpen">{{ title }}</slot> </header> </a> <div class="cw-collapsible-content" :class="{ 'cw-collapsible-content-open': isOpen }"> - <slot></slot> + <slot :isOpen="isOpen"></slot> </div> </div> </template> @@ -54,4 +55,4 @@ export default { } } }; -</script> \ No newline at end of file +</script> diff --git a/resources/vue/components/courseware/structural-element/CoursewareStructuralElement.vue b/resources/vue/components/courseware/structural-element/CoursewareStructuralElement.vue index dba1917eea9e0c1fc28b7caf02621e196b2c7f0d..1244134fe3bd004c96e5ad826e525088718e8522 100644 --- a/resources/vue/components/courseware/structural-element/CoursewareStructuralElement.vue +++ b/resources/vue/components/courseware/structural-element/CoursewareStructuralElement.vue @@ -34,8 +34,10 @@ :title="structuralElement.attributes.title" > <span>{{ structuralElement.attributes.title || "–" }}</span> - <span v-if="isTask">[ {{ solverName }} ]</span> - <template v-if="!userIsTeacher && inCourse"> + <span v-if="task"> + [ {{ (userIsReviewer && isPeerReviewAnonymous) ? $gettext('anonym') : solverName }} ] + </span> + <template v-if="inCourse && !(userIsTeacher || userIsReviewer)"> <studip-icon v-if="complete" shape="accept" @@ -102,6 +104,18 @@ </button> </template> </courseware-companion-box> + <courseware-companion-box + v-for="peerReview in peerReviews" + :key="peerReview.id" + mood="pointing" + :msgCompanion="$gettext('Sie begutachten diese Seite im Rahmen eines Peer-Reviews.')" + > + <template #companionActions> + <button class="button" @click="openPeerReviewForm(peerReview)"> + {{ $gettext('Gutachten öffnen') }} + </button> + </template> + </courseware-companion-box> <courseware-empty-element-box v-if="showEmptyElementBox" :canEdit="canEdit" @@ -630,6 +644,11 @@ <courseware-structural-element-dialog-import v-if="showImportDialog"/> <courseware-structural-element-dialog-copy v-if="showCopyDialog" /> <courseware-structural-element-dialog-link v-if="showLinkDialog"/> + <PeerReviewAssessmentDialog + v-model="showPeerReviewForm" + v-if="selectedPeerReview" + :review="selectedPeerReview" + /> </div> <div v-else> <courseware-companion-box @@ -659,6 +678,7 @@ import CoursewareStructuralElementDiscussion from './CoursewareStructuralElement import CoursewareStructuralElementPermissions from './CoursewareStructuralElementPermissions.vue'; import CoursewareContentPermissions from '../CoursewareContentPermissions.vue'; import CoursewareWelcomeScreen from './CoursewareWelcomeScreen.vue'; +import PeerReviewAssessmentDialog from '../tasks/peer-review/AssessmentDialog.vue'; import CoursewareExport from '@/vue/mixins/courseware/export.js'; import CoursewareOerMessage from '@/vue/mixins/courseware/oermessage.js'; import colorMixin from '@/vue/mixins/courseware/colors.js'; @@ -681,6 +701,9 @@ export default { CoursewareContentPermissions, CoursewareWelcomeScreen, CoursewareDateInput, + FocusTrap, + IsoDate, + PeerReviewAssessmentDialog, StockImageSelector, StudipDialog, draggable, @@ -754,6 +777,8 @@ export default { uploadImageURL: null, showStockImageSelector: false, selectedStockImage: null, + showPeerReviewForm: false, + selectedPeerReview: null, }; }, @@ -764,6 +789,8 @@ export default { consumeMode: 'consumeMode', containerById: 'courseware-containers/byId', relatedContainers: 'courseware-containers/related', + relatedPeerReviewProcesses: 'courseware-peer-review-processes/related', + relatedPeerReviews: 'courseware-peer-reviews/related', relatedStructuralElements: 'courseware-structural-elements/related', relatedTaskGroups: 'courseware-task-groups/related', relatedUsers: 'users/related', @@ -881,7 +908,7 @@ export default { return true; } } - + return false; }, @@ -1229,7 +1256,28 @@ export default { 'dispatch.php/course/courseware/courseware/' + this.context.unit, {cid: this.context.id} ); - } + }, + userIsReviewer() { + return this.task ? this.task.attributes['can-peer-review'] : false; + }, + peerReviews() { + if (this.isTask && this.userIsReviewer) { + return this.relatedPeerReviews({ + parent: { id: this.task.id, type: this.task.type }, + relationship: 'peer-reviews', + }); + } + return []; + }, + isPeerReviewAnonymous() { + return this.peerReviews.every(({ id, type }) => { + const process = this.relatedPeerReviewProcesses({ + parent: { id, type }, + relationship: 'process', + }); + return process.attributes.configuration.anonymous; + }); + }, }, methods: { @@ -1694,6 +1742,10 @@ export default { this.showStockImageSelector = false; this.deletingPreviewImage = false; }, + openPeerReviewForm(peerReview) { + this.showPeerReviewForm = true; + this.selectedPeerReview = peerReview; + }, }, created() { this.pluginManager.registerComponentsLocally(this); diff --git a/resources/vue/components/courseware/structural-element/CoursewareTreeItem.vue b/resources/vue/components/courseware/structural-element/CoursewareTreeItem.vue index a4bfa35e48cc43cf2bdd3b55f230a7540be27996..49c6cbd16421d26635a2db993ddd1df2727c8b9c 100644 --- a/resources/vue/components/courseware/structural-element/CoursewareTreeItem.vue +++ b/resources/vue/components/courseware/structural-element/CoursewareTreeItem.vue @@ -32,7 +32,7 @@ <studip-icon shape="edit" /> </button> - <span v-if="task">| {{ solverName }}</span> + <span v-if="task">| {{ userIsReviewer ? $gettext("anonym") : solverName }}</span> <span v-if="hasReleaseOrWithdrawDate" class="cw-tree-item-flag-date" @@ -48,7 +48,7 @@ class="cw-tree-item-flag-cant-read" :title="$gettext('Diese Seite kann von Teilnehmenden nicht gesehen werden')" ></span> - <template v-if="!userIsTeacher && inCourse"> + <template v-if="!(userIsTeacher || userIsReviewer) && inCourse"> <span v-if="complete" class="cw-tree-item-sequential cw-tree-item-sequential-complete" @@ -328,6 +328,9 @@ export default { complete() { return this.itemProgress === 100; }, + userIsReviewer() { + return this.task ? this.task.attributes['can-peer-review'] : false; + }, }, methods: { ...mapActions({ diff --git a/resources/vue/components/courseware/tasks/AddFeedbackDialog.vue b/resources/vue/components/courseware/tasks/AddFeedbackDialog.vue new file mode 100644 index 0000000000000000000000000000000000000000..2d7c28104e9f7bb9be195f6016bbad0ea44bb6bc --- /dev/null +++ b/resources/vue/components/courseware/tasks/AddFeedbackDialog.vue @@ -0,0 +1,48 @@ +<template> + <studip-dialog + :title="$gettext('Feedback zur Aufgabe geben')" + :confirmText="$gettext('Speichern')" + confirmClass="accept" + :closeText="$gettext('Schließen')" + closeClass="cancel" + height="420" + @close="$emit('close')" + @confirm="create" + > + <template #dialogContent> + <form class="default" @submit.prevent=""> + <label> + {{ $gettext('Feedback') }} + <textarea v-model="localContent" /> + </label> + </form> + </template> + </studip-dialog> +</template> + +<script> +export default { + props: ['content'], + data: () => ({ + localContent: '', + }), + methods: { + resetLocalVars() { + this.localContent = this.content; + }, + create() { + this.$emit('create', { content: this.localContent }); + }, + }, + mounted() { + this.resetLocalVars(); + }, + watch: { + content(newValue) { + if (newValue !== this.localContent) { + this.resetLocalVars(); + } + }, + }, +}; +</script> diff --git a/resources/vue/components/courseware/tasks/CoursewareDashboardStudents.vue b/resources/vue/components/courseware/tasks/CoursewareDashboardStudents.vue new file mode 100644 index 0000000000000000000000000000000000000000..1bdbf3ce0e17b2e1b048207169eed4603a3219b3 --- /dev/null +++ b/resources/vue/components/courseware/tasks/CoursewareDashboardStudents.vue @@ -0,0 +1,91 @@ +<template> + <div class="cw-dashboard-students-wrapper"> + <table class="default" v-if="taskGroups.length"> + <thead> + <tr> + <th>{{ $gettext('Status') }}</th> + <th>{{ $gettext('Titel') }}</th> + <th>{{ $gettext('Bearbeitungszeit') }}</th> + </tr> + </thead> + <tbody> + <tr v-for="(taskGroup, index) in taskGroups"> + <td> + <StudipIcon + :shape="status(taskGroup).shape" + :role="status(taskGroup).role" + :title="status(taskGroup).description" + aria-hidden="true" + /> + <span class="sr-only">{{ status(taskGroup).description }}</span> + </td> + <td> + <router-link :to="{ name: 'task-groups-show', params: { id: taskGroup.id } }">{{ taskGroup.attributes.title }}</router-link> + </td> + <td> + <StudipDate :date="new Date(taskGroup.attributes['start-date'])" />–<StudipDate :date="new Date(taskGroup.attributes['end-date'])" /> + </td> + </tr> + </tbody> + </table> + + <CompanionBox v-else-if="!tasksLoading" + :msgCompanion="$gettext('Es wurden noch keine Aufgaben verteilt.')"> + <template #companionActions> + <button @click="setShowTasksDistributeDialog(true)" type="button" class="button"> + {{ $gettext("Aufgabe verteilen") }} + </button> + </template> + </CompanionBox> + + <CoursewareTasksDialogDistribute v-if="showTasksDistributeDialog" @newtask="reloadTasks" /> + </div> +</template> + +<script> +import _ from 'lodash'; +import { mapActions, mapGetters } from 'vuex'; +import CompanionBox from '../layouts/CoursewareCompanionBox.vue'; +import CoursewareTasksDialogDistribute from './CoursewareTasksDialogDistribute.vue'; +import StudipDate from '../../StudipDate.vue'; +import StudipIcon from '../../StudipIcon.vue'; +import { getStatus } from './task-groups-helper.js'; + +export default { + name: 'courseware-dashboard-students', + components: { + CompanionBox, + CoursewareTasksDialogDistribute, + StudipDate, + StudipIcon, + }, + computed: { + ...mapGetters({ + context: 'context', + showTasksDistributeDialog: 'tasks/showTasksDistributeDialog', + taskGroupsByCid: 'tasks/taskGroupsByCid', + tasksLoading: 'courseware-tasks/isLoading', + }), + taskGroups() { + return _.sortBy(this.taskGroupsByCid(this.context.id), [ + (taskGroup) => -new Date(taskGroup.attributes['end-date']), + ]); + }, + }, + methods: { + ...mapActions({ + loadAllTasks: 'courseware-tasks/loadAll', + setShowTasksDistributeDialog: 'tasks/setShowTasksDistributeDialog', + }), + status: getStatus, + reloadTasks() { + this.loadAllTasks({ + options: { + 'filter[cid]': this.context.id, + include: 'solver, structural-element, task-feedback, task-group, task-group.lecturer', + }, + }); + }, + }, +}; +</script> diff --git a/resources/vue/components/courseware/CoursewareDashboardTasks.vue b/resources/vue/components/courseware/tasks/CoursewareDashboardTasks.vue similarity index 97% rename from resources/vue/components/courseware/CoursewareDashboardTasks.vue rename to resources/vue/components/courseware/tasks/CoursewareDashboardTasks.vue index caaea0bb1b6f654782b34cc392a2bd95e2d0649c..93536af95e33d87c122b17fe31a01d7c0afd0b49 100644 --- a/resources/vue/components/courseware/CoursewareDashboardTasks.vue +++ b/resources/vue/components/courseware/tasks/CoursewareDashboardTasks.vue @@ -102,11 +102,11 @@ </div> </template> <script> -import CoursewareCompanionBox from './layouts/CoursewareCompanionBox.vue'; -import StudipIcon from '../StudipIcon.vue'; -import StudipActionMenu from '../StudipActionMenu.vue'; -import StudipDialog from '../StudipDialog.vue'; -import taskHelperMixin from '../../mixins/courseware/task-helper.js'; +import CoursewareCompanionBox from '../layouts/CoursewareCompanionBox.vue'; +import StudipIcon from '../../StudipIcon.vue'; +import StudipActionMenu from '../../StudipActionMenu.vue'; +import StudipDialog from '../../StudipDialog.vue'; +import taskHelperMixin from '../../../mixins/courseware/task-helper.js'; import { mapActions, mapGetters } from 'vuex'; export default { diff --git a/resources/vue/components/courseware/CoursewareTasksDialogDistribute.vue b/resources/vue/components/courseware/tasks/CoursewareTasksDialogDistribute.vue similarity index 93% rename from resources/vue/components/courseware/CoursewareTasksDialogDistribute.vue rename to resources/vue/components/courseware/tasks/CoursewareTasksDialogDistribute.vue index 79c8cacac60578d18d6737764254feb12e8fec4b..27e99a6d3b34cdf1151f84b62135d5053ab2e9b8 100644 --- a/resources/vue/components/courseware/CoursewareTasksDialogDistribute.vue +++ b/resources/vue/components/courseware/tasks/CoursewareTasksDialogDistribute.vue @@ -27,10 +27,10 @@ :key="'label-' + unit.id" :for="'cw-task-dist-source-unit' + unit.id" > - <div class="icon"><studip-icon shape="courseware" size="32" /></div> + <div class="icon"><studip-icon shape="courseware" :size="32" /></div> <div class="text">{{ unit.element.attributes.title }}</div> - <studip-icon shape="radiobutton-unchecked" size="24" class="unchecked" /> - <studip-icon shape="check-circle" size="24" class="check" /> + <studip-icon shape="radiobutton-unchecked" :size="24" class="unchecked" /> + <studip-icon shape="check-circle" :size="24" class="check" /> </label> </template> </fieldset> @@ -62,10 +62,15 @@ <span aria-hidden="true" class="wizard-required">*</span> <input type="text" v-model="taskTitle" required /> </label> + <label> + <span>{{ $gettext('Startdatum') }}</span> + <span aria-hidden="true" class="wizard-required">*</span> + <input type="date" v-model="startDate" /> + </label> <label> <span>{{ $gettext('Abgabefrist') }}</span> <span aria-hidden="true" class="wizard-required">*</span> - <input type="date" v-model="submissionDate" /> + <input type="date" v-model="endDate" :min="startDate" /> </label> <label> {{ $gettext('Inhalte ergänzen') }} @@ -99,10 +104,10 @@ :key="'label-' + unit.id" :for="'cw-task-dist-target-unit' + unit.id" > - <div class="icon"><studip-icon shape="courseware" size="32" /></div> + <div class="icon"><studip-icon shape="courseware" :size="32" /></div> <div class="text">{{ unit.element.attributes.title }}</div> - <studip-icon shape="radiobutton-unchecked" size="24" class="unchecked" /> - <studip-icon shape="check-circle" size="24" class="check" /> + <studip-icon shape="radiobutton-unchecked" :size="24" class="unchecked" /> + <studip-icon shape="check-circle" :size="24" class="check" /> </label> </template> </fieldset> @@ -237,9 +242,9 @@ </template> <script> -import CoursewareCompanionBox from './layouts/CoursewareCompanionBox.vue'; -import CoursewareStructuralElementSelector from './structural-element/CoursewareStructuralElementSelector.vue'; -import StudipWizardDialog from '../StudipWizardDialog.vue'; +import CoursewareCompanionBox from '../layouts/CoursewareCompanionBox.vue'; +import CoursewareStructuralElementSelector from '../structural-element/CoursewareStructuralElementSelector.vue'; +import StudipWizardDialog from '../../StudipWizardDialog.vue'; import { mapActions, mapGetters } from 'vuex'; @@ -316,7 +321,8 @@ export default { ], selectedSourceUnit: null, taskTitle: '', - submissionDate: '', + startDate: new Date().toJSON().substr(0, 10), + endDate: '', solverMayAddBlocks: true, selectedTask: null, selectedTargetUnit: null, @@ -488,7 +494,7 @@ export default { }, methods: { ...mapActions({ - setShowTasksDistributeDialog: 'setShowTasksDistributeDialog', + setShowTasksDistributeDialog: 'tasks/setShowTasksDistributeDialog', loadCourseUnits: 'loadCourseUnits', loadUserUnits: 'loadUserUnits', loadStructuralElement: 'courseware-structural-elements/loadById', @@ -522,10 +528,21 @@ export default { return; } this.distributing = true; + const startDate = new Date(this.startDate); + startDate.setHours(0); + startDate.setMinutes(0); + startDate.setSeconds(0); + startDate.setMilliseconds(0); + const endDate = new Date(this.endDate); + endDate.setHours(23); + endDate.setMinutes(59); + endDate.setSeconds(59); + endDate.setMilliseconds(999); const taskGroup = { attributes: { title: this.taskTitle, - 'submission-date': new Date(this.submissionDate).toISOString(), + 'start-date': startDate.toISOString(), + 'end-date': endDate.toISOString(), 'solver-may-add-blocks': this.solverMayAddBlocks, }, relationships: { @@ -560,7 +577,7 @@ export default { this.$emit('newtask'); this.distributing = false; this.setShowTasksDistributeDialog(false); - + }, validateSolvers() { if ( @@ -575,7 +592,7 @@ export default { return this.wizardSlots[5].valid; }, validateTaskSettings() { - if (this.taskTitle !== '' && this.submissionDate !== '') { + if (this.taskTitle !== '' && this.endDate !== '') { this.wizardSlots[2].valid = true; } else { this.wizardSlots[2].valid = false; @@ -651,7 +668,14 @@ export default { taskTitle() { this.validate(); }, - submissionDate() { + startDate() { + if (new Date(this.startDate) > new Date(this.endDate)) { + const endDate = new Date(this.startDate); + endDate.setDate(endDate.getDate() + 1); + this.endDate = endDate.toJSON().substr(0, 10); + } + }, + endDate() { this.validate(); }, selectedAutors() { diff --git a/resources/vue/components/courseware/tasks/EditFeedbackDialog.vue b/resources/vue/components/courseware/tasks/EditFeedbackDialog.vue new file mode 100644 index 0000000000000000000000000000000000000000..a07356dfc68d186a676cbbb31fc78bbd711d13ca --- /dev/null +++ b/resources/vue/components/courseware/tasks/EditFeedbackDialog.vue @@ -0,0 +1,60 @@ +<template> + <studip-dialog + :title="$gettext('Feedback zur Aufgabe ändern')" + :confirmText="$gettext('Speichern')" + confirmClass="accept" + :closeText="$gettext('Schließen')" + closeClass="cancel" + height="420" + @close="$emit('close')" + @confirm="update" + > + <template #dialogContent> + <CompanionBox + v-if="localContent === ''" + mood="pointing" + :msgCompanion=" + $gettext('Sie haben kein Feedback geschrieben, beim Speichern wird dieses Feedback gelöscht!') + " + /> + <form class="default" @submit.prevent=""> + <label> + {{ $gettext('Feedback') }} + <textarea v-model="localContent" /> + </label> + </form> + </template> + </studip-dialog> +</template> + +<script> +import CompanionBox from '../layouts/CoursewareCompanionBox.vue'; + +export default { + props: ['content'], + components: { + CompanionBox, + }, + data: () => ({ + localContent: '', + }), + methods: { + resetLocalVars() { + this.localContent = this.content; + }, + update() { + this.$emit('update', { content: this.localContent }); + }, + }, + mounted() { + this.resetLocalVars(); + }, + watch: { + content(newValue) { + if (newValue !== this.localContent) { + this.resetLocalVars(); + } + }, + }, +}; +</script> diff --git a/resources/vue/components/courseware/TasksApp.vue b/resources/vue/components/courseware/tasks/PagesTaskGroupsIndex.vue similarity index 70% rename from resources/vue/components/courseware/TasksApp.vue rename to resources/vue/components/courseware/tasks/PagesTaskGroupsIndex.vue index 8a406d8086407df5cd567c38e86aca1c79f8c3a5..5701580d4894e20790de9294ce407a6ceb0f6f43 100644 --- a/resources/vue/components/courseware/TasksApp.vue +++ b/resources/vue/components/courseware/tasks/PagesTaskGroupsIndex.vue @@ -1,21 +1,21 @@ <template> <div class="cw-tasks-wrapper"> <div class="cw-tasks-list"> - <courseware-dashboard-students v-if="userIsTeacher" /> - <courseware-dashboard-tasks v-else /> + <CoursewareDashboardStudents v-if="userIsTeacher" /> + <CoursewareDashboardTasks v-else /> </div> <MountingPortal mountTo="#courseware-action-widget" name="sidebar-actions" v-if="userIsTeacher"> - <courseware-tasks-action-widget /> + <CoursewareTasksActionWidget /> </MountingPortal> <courseware-companion-overlay /> </div> </template> <script> -import CoursewareTasksActionWidget from './widgets/CoursewareTasksActionWidget.vue'; +import CoursewareTasksActionWidget from '../widgets/CoursewareTasksActionWidget.vue'; import CoursewareDashboardTasks from './CoursewareDashboardTasks.vue'; import CoursewareDashboardStudents from './CoursewareDashboardStudents.vue'; -import CoursewareCompanionOverlay from './layouts/CoursewareCompanionOverlay.vue'; +import CoursewareCompanionOverlay from '../layouts/CoursewareCompanionOverlay.vue'; import { mapGetters } from 'vuex'; export default { diff --git a/resources/vue/components/courseware/tasks/PagesTaskGroupsShow.vue b/resources/vue/components/courseware/tasks/PagesTaskGroupsShow.vue new file mode 100644 index 0000000000000000000000000000000000000000..77499aba5f5dcff85c8db820053bc788944fb5cd --- /dev/null +++ b/resources/vue/components/courseware/tasks/PagesTaskGroupsShow.vue @@ -0,0 +1,238 @@ +<template> + <div class="cw-tasks-wrapper"> + <MountingPortal mountTo="#courseware-action-widget" name="sidebar-actions" v-if="userIsTeacher"> + <CoursewareTasksActionWidget :taskGroup="taskGroup" /> + </MountingPortal> + + <div v-if="taskGroup" class="cw-tasks-list"> + <CoursewareRibbon :isContentBar="true" :showToolbarButton="false"> + <template #buttons> + <router-link :to="{ name: 'task-groups-index' }"> + <StudipIcon shape="category-task" :size="24" /> + </router-link> + </template> + <template #breadcrumbList> + <li> + <router-link :to="{ name: 'task-groups-index' }"> + {{ $gettext('Aufgaben') }} + </router-link> + </li> + <li>{{ taskGroup.attributes['title'] }}</li> + </template> + </CoursewareRibbon> + + <TaskGroup + :taskGroup="taskGroup" + :tasks="tasksByGroup[taskGroup.id]" + @add-feedback="onShowAddFeedback" + @add-peer-review-process="onShowPeerReviewProcessCreate" + @edit-feedback="onShowEditFeedback" + @solve-renewal="onShowSolveRenewal" + /> + </div> + <CompanionBox + v-else-if="!tasksLoading" + :msgCompanion="$gettext('Diese Courseware-Aufgabe konnte nicht gefunden werden.')" + /> + + <AddFeedbackDialog + v-if="showAddFeedbackDialog" + :content="currentDialogFeedback.attributes.content" + @create="createFeedback" + @close="closeDialogs" + /> + + <EditFeedbackDialog + v-if="showEditFeedbackDialog" + :content="currentDialogFeedback.attributes.content" + @update="updateFeedback" + @close="closeDialogs" + /> + + <PeerReviewProcessCreateDialog + v-if="showPeerReviewProcessCreate" + :taskGroup="taskGroup" + @create="onCreatePeerReviewProcess" + @close="closeDialogs" + /> + + <RenewalDialog + v-if="renewalTask" + :renewalDate="renewalDate" + :renewalState="renewalTask.attributes.renewal" + @update="updateRenewal" + @close="closeDialogs" + /> + + <TaskGroupsDeleteDialog v-if="showTaskGroupsDeleteDialog" :taskGroup="taskGroup" /> + <TaskGroupsModifyDeadlineDialog v-if="showTaskGroupsModifyDeadlineDialog" :taskGroup="taskGroup" /> + <CoursewareTasksDialogDistribute v-if="showTasksDistributeDialog" @newtask="reloadTasks" /> + </div> +</template> + +<script> +import { mapActions, mapGetters } from 'vuex'; +import AddFeedbackDialog from './AddFeedbackDialog.vue'; +import CompanionBox from '../layouts/CoursewareCompanionBox.vue'; +import CoursewareRibbon from '../structural-element/CoursewareRibbon.vue'; +import CoursewareTasksActionWidget from '../widgets/CoursewareTasksActionWidget.vue'; +import CoursewareTasksDialogDistribute from './CoursewareTasksDialogDistribute.vue'; +import EditFeedbackDialog from './EditFeedbackDialog.vue'; +import PeerReviewProcessCreateDialog from './peer-review/ProcessCreateDialog.vue'; +import RenewalDialog from './RenewalDialog.vue'; +import TaskGroup from './TaskGroup.vue'; +import TaskGroupsDeleteDialog from './TaskGroupsDeleteDialog.vue'; +import TaskGroupsModifyDeadlineDialog from './TaskGroupsModifyDeadlineDialog.vue'; + +export default { + components: { + AddFeedbackDialog, + CompanionBox, + CoursewareRibbon, + CoursewareTasksActionWidget, + CoursewareTasksDialogDistribute, + EditFeedbackDialog, + PeerReviewProcessCreateDialog, + RenewalDialog, + TaskGroup, + TaskGroupsDeleteDialog, + TaskGroupsModifyDeadlineDialog, + }, + props: ['id'], + data() { + return { + currentDialogFeedback: {}, + renewalTask: null, + showAddFeedbackDialog: false, + showPeerReviewProcessCreate: null, + showEditFeedbackDialog: false, + }; + }, + computed: { + ...mapGetters({ + context: 'context', + getTaskGroup: 'courseware-task-groups/byId', + showTaskGroupsDeleteDialog: 'tasks/showTaskGroupsDeleteDialog', + showTaskGroupsModifyDeadlineDialog: 'tasks/showTaskGroupsModifyDeadlineDialog', + showTasksDistributeDialog: 'tasks/showTasksDistributeDialog', + tasksByCid: 'tasks/tasksByCid', + tasksLoading: 'courseware-tasks/isLoading', + userIsTeacher: 'userIsTeacher', + }), + renewalDate() { + return this.renewalTask ? new Date(this.renewalTask.attributes['renewal-date']) : new Date(); + }, + taskGroup() { + return this.getTaskGroup({ id: this.id }); + }, + tasksByGroup() { + return this.tasksByCid(this.context.id).reduce((memo, task) => { + const key = task.relationships['task-group'].data.id; + (memo[key] || (memo[key] = [])).push(task); + + return memo; + }, {}); + }, + }, + methods: { + ...mapActions({ + companionError: 'companionError', + companionSuccess: 'companionSuccess', + createPeerReviewProcess: 'tasks/createPeerReviewProcess', + createTaskFeedback: 'createTaskFeedback', + deleteTaskFeedback: 'deleteTaskFeedback', + loadAllTasks: 'courseware-tasks/loadAll', + loadTaskGroup: 'tasks/loadTaskGroup', + updateTask: 'updateTask', + updateTaskFeedback: 'updateTaskFeedback', + }), + closeDialogs() { + this.showAddFeedbackDialog = false; + this.showEditFeedbackDialog = false; + this.showPeerReviewProcessCreate = false; + + this.currentDialogFeedback = {}; + this.renewalTask = null; + }, + createFeedback({ content }) { + if (content === '') { + this.companionError({ + info: this.$gettext('Bitte schreiben Sie ein Feedback.'), + }); + return false; + } + this.currentDialogFeedback.attributes.content = content; + this.createTaskFeedback({ taskFeedback: this.currentDialogFeedback }); + this.closeDialogs(); + }, + onCreatePeerReviewProcess(options) { + this.createPeerReviewProcess({ taskGroup: this.taskGroup, options }) + .then(() => this.loadTaskGroup(this.taskGroup)) + .then(() => this.closeDialogs()); + }, + onShowAddFeedback(task) { + this.currentDialogFeedback = { + attributes: { content: '' }, + relationships: { + task: { + data: { + id: task.id, + type: task.type, + }, + }, + }, + }; + this.showAddFeedbackDialog = true; + }, + onShowEditFeedback(feedback) { + this.currentDialogFeedback = _.cloneDeep(feedback); + this.showEditFeedbackDialog = true; + }, + onShowPeerReviewProcessCreate() { + this.showPeerReviewProcessCreate = true; + }, + onShowSolveRenewal(task) { + this.renewalTask = _.cloneDeep(task); + this.renewalTask.attributes['renewal-date'] = new Date().toISOString(); + }, + reloadTasks() { + this.loadAllTasks({ + options: { + 'filter[cid]': this.context.id, + include: 'solver, structural-element, task-feedback, task-group, task-group.lecturer', + }, + }); + }, + updateRenewal({ state, date }) { + const attributes = { renewal: state }; + if (date) { + attributes['renewal-date'] = date.toISOString(); + } + + this.updateTask({ attributes, taskId: this.renewalTask.id }); + this.closeDialogs(); + }, + async updateFeedback({ content }) { + if (content === '') { + await this.deleteTaskFeedback({ taskFeedbackId: this.currentDialogFeedback.id }); + this.companionSuccess({ info: this.$gettext('Feedback wurde gelöscht.') }); + } else { + await this.updateTaskFeedback({ + attributes: { content }, + taskFeedbackId: this.currentDialogFeedback.id, + }); + this.companionSuccess({ + info: this.$gettext('Feedback wurde gespeichert.'), + }); + } + this.closeDialogs(); + }, + }, +}; +</script> + +<style scoped> +.cw-tasks-wrapper >>> .cw-ribbon-nav { + justify-content: center; +} +</style> diff --git a/resources/vue/components/courseware/tasks/RenewalDialog.vue b/resources/vue/components/courseware/tasks/RenewalDialog.vue new file mode 100644 index 0000000000000000000000000000000000000000..f08719e22d2d13d17f059a13298545e0052334f5 --- /dev/null +++ b/resources/vue/components/courseware/tasks/RenewalDialog.vue @@ -0,0 +1,79 @@ +<template> + <studip-dialog + :title="$gettext('Verlängerungsanfrage bearbeiten')" + :confirmText="$gettext('Speichern')" + confirmClass="accept" + :closeText="$gettext('Schließen')" + closeClass="cancel" + height="350" + @close="$emit('close')" + @confirm="updateRenewal" + > + <template #dialogContent> + <form class="default" @submit.prevent=""> + <label> + {{ $gettext('Fristverlängerung') }} + <select v-model="state"> + <option value="declined"> + {{ $gettext('ablehnen') }} + </option> + <option value="granted"> + {{ $gettext('gewähren') }} + </option> + </select> + </label> + <label v-if="state === 'granted'"> + {{ $gettext('neue Frist') }} + <DateInput v-model="date" class="size-l" /> + </label> + </form> + </template> + </studip-dialog> +</template> + +<script> +import DateInput from '../layouts/CoursewareDateInput.vue'; +export default { + props: ['renewalDate', 'renewalState'], + components: { + DateInput, + }, + data: () => ({ + date: null, + state: null, + }), + methods: { + resetLocalVars() { + this.date = this.renewalDate ?? null; + this.state = this.renewalState; + }, + updateRenewal() { + const date = new Date(this.date); + date.setHours(23); + date.setMinutes(59); + date.setSeconds(59); + date.setMilliseconds(999); + + this.$emit('update', { + state: this.state, + date: this.state === 'granted' ? date || Date.now() : null, + }); + }, + }, + mounted() { + this.resetLocalVars(); + }, + watch: { + renewalDate(newValue) { + if (newValue !== this.date) { + this.resetLocalVars(); + } + }, + renewalState(newValue) { + if (newValue !== this.state) { + this.resetLocalVars(); + } + }, + }, +}; +</script> diff --git a/resources/vue/components/courseware/tasks/TaskGroup.vue b/resources/vue/components/courseware/tasks/TaskGroup.vue new file mode 100644 index 0000000000000000000000000000000000000000..36e0ffc80359df2a22954826e6477ec4256813f7 --- /dev/null +++ b/resources/vue/components/courseware/tasks/TaskGroup.vue @@ -0,0 +1,169 @@ +<template> + <div> + <CompanionBox :msgCompanion="statusMessage"> + <template #companionActions> + <span> + {{ $gettext('Bearbeitungszeit') }} + <StudipDate :date="startDate" />–<StudipDate :date="endDate" /> + </span> + </template> + </CompanionBox> + + <section v-if="tasks.length > 0"> + <table class="default"> + <caption> + {{ + $gettext('Verteilte Aufgaben') + }} + </caption> + <colgroup> + <col /> + </colgroup> + <thead> + <tr> + <th>{{ $gettext('Status') }}</th> + <th>{{ $gettext('Teilnehmende/Gruppen') }}</th> + <th class="responsive-hidden">{{ $gettext('Seite') }}</th> + <th>{{ $gettext('bearbeitet') }}</th> + <th>{{ $gettext('Abgabefrist') }}</th> + <th>{{ $gettext('Abgabe') }}</th> + <th class="responsive-hidden renewal">{{ $gettext('Verlängerungsanfrage') }}</th> + <th class="responsive-hidden feedback">{{ $gettext('Feedback') }}</th> + </tr> + </thead> + <tbody> + <TaskItem + v-for="task in tasks" + :task="task" + :taskGroup="taskGroup" + :key="task.id" + @add-feedback="(task) => $emit('add-feedback', task)" + @edit-feedback="(feedback) => $emit('edit-feedback', feedback)" + @solve-renewal="(task) => $emit('solve-renewal', task)" + /> + </tbody> + </table> + + <table class="default"> + <caption> + {{ + $gettext('Peer-Review-Verfahren') + }} + <span class="actions" v-if="false && peerReviewActionMenuItems.length"> + <StudipActionMenu + :collapseAt="false" + :context="actionMenuContext" + :items="peerReviewActionMenuItems" + @add-peer-review-process="() => $emit('add-peer-review-process', this.taskGroup)" + /> + </span> + </caption> + <thead> + <tr> + <th>{{ $gettext('Status') }}</th> + <th>{{ $gettext('Bearbeitungszeit') }}</th> + <th>{{ $gettext('Einstellungen') }}</th> + </tr> + </thead> + <tbody v-if="hasPeerReviewProcesses"> + <ProcessListItem + v-for="(process, index) in peerReviewProcesses" + :key="process.id" + :process="process" + /> + </tbody> + <tbody v-else> + <tr class="nohover"> + <td colspan="3"> + <CompanionBox + v-if="isAfter" + :msgCompanion=" + $gettext('Für diese Aufgabe wurde kein Peer-Review-Verfahren aktiviert.') + " + > + </CompanionBox> + <CompanionBox + v-else + mood="pointing" + :msgCompanion=" + $gettext('Für diese Aufgabe wurde noch kein Peer-Review-Verfahren aktiviert.') + " + > + <template #companionActions> + <button class="button" @click="$emit('add-peer-review-process', taskGroup)"> + {{ $gettext('Peer-Review-Verfahren aktivieren') }} + </button> + </template> + </CompanionBox> + </td> + </tr> + </tbody> + </table> + </section> + <div v-else> + <CompanionBox mood="pointing" :msgCompanion="$gettext('Diese Aufgabe wurde an niemanden verteilt.')" /> + </div> + </div> +</template> + +<script> +import { mapGetters } from 'vuex'; +import CompanionBox from '../layouts/CoursewareCompanionBox.vue'; +import StudipActionMenu from '../../StudipActionMenu.vue'; +import StudipDate from '../../StudipDate.vue'; +import TaskItem from './TaskGroupTaskItem.vue'; +import ProcessListItem from './TaskGroupPeerReviewProcessListItem.vue'; +import { getStatus } from './task-groups-helper.js'; + +export default { + components: { CompanionBox, ProcessListItem, StudipActionMenu, StudipDate, TaskItem }, + props: ['taskGroup', 'tasks'], + computed: { + ...mapGetters({ + coursewareContext: 'context', + relatedPeerReviewProcesses: 'courseware-peer-review-processes/related', + }), + actionMenuContext() { + return this.$gettextInterpolate(this.$gettext('Courseware-Aufgabe "%{ taskGroup }"'), { + taskGroup: this.taskGroup.attributes.title, + }); + }, + canAddPeerReviewProcess() { + return this.startDate < new Date(); + }, + endDate() { + return new Date(this.taskGroup.attributes['end-date']); + }, + hasPeerReviewProcesses() { + return !!this.peerReviewProcesses; + }, + isAfter() { + return new Date() > this.endDate; + }, + peerReviewActionMenuItems() { + return this.isAfter + ? [] + : [ + { + emit: 'add-peer-review-process', + icon: 'add', + id: 'add-peer-review-processes', + label: this.$gettext('Peer-Review aktivieren'), + }, + ]; + }, + peerReviewProcesses() { + return this.relatedPeerReviewProcesses({ parent: this.taskGroup, relationship: 'peer-review-processes' }); + }, + startDate() { + return new Date(this.taskGroup.attributes['start-date']); + }, + status() { + return getStatus(this.taskGroup); + }, + statusMessage() { + return this.status.description; + }, + }, +}; +</script> diff --git a/resources/vue/components/courseware/tasks/TaskGroupPeerReviewProcessListItem.vue b/resources/vue/components/courseware/tasks/TaskGroupPeerReviewProcessListItem.vue new file mode 100644 index 0000000000000000000000000000000000000000..830a30fc40a16e2e74d15623bfcc0a13993edf33 --- /dev/null +++ b/resources/vue/components/courseware/tasks/TaskGroupPeerReviewProcessListItem.vue @@ -0,0 +1,68 @@ +<template> + <tr> + <td> + <StudipIcon + v-if="status.shape !== undefined" + :shape="status.shape" + :role="status.role" + :title="status.description" + aria-hidden="true" + /> + <span class="sr-only">{{ status.description }}</span> + </td> + <td> + <router-link :to="{ name: 'peer-review-processes-show', params: { id: process.id } }"> + <StudipDate :date="startDate" /> + – + <StudipDate :date="endDate" /> + </router-link> + </td> + <td> + {{ process.attributes.configuration.anonymous ? $gettext('anonym') : $gettext('nicht anonym') }}, + {{ reviewType }}, + {{ + process.attributes.configuration.automaticPairing + ? $gettext('automatische Zuordnung') + : $gettext('manuelle Zuordnung') + }} + </td> + </tr> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { mapGetters } from 'vuex'; +import StudipDate from '../../StudipDate.vue'; +import StudipIcon from '../../StudipIcon.vue'; +import { AssessmentType, ASSESSMENT_TYPES } from './peer-review/process-configuration'; +import { getProcessStatus, JsonApiSchema, StatusDescriptor } from './peer-review/definitions'; + +export default Vue.extend({ + props: { + process: { + required: true, + type: Object, + }, + }, + components: { StudipDate, StudipIcon }, + computed: { + ...mapGetters({ coursewareContext: 'context' }), + cid(): string { + return this.coursewareContext.id; + }, + endDate(): Date { + return new Date(this.process.attributes['review-end']); + }, + reviewType(): string { + const type = <AssessmentType>this.process.attributes.configuration.type; + return ASSESSMENT_TYPES[type].short; + }, + startDate(): Date { + return new Date(this.process.attributes['review-start']); + }, + status(): StatusDescriptor { + return getProcessStatus(<JsonApiSchema>this.process); + }, + }, +}); +</script> diff --git a/resources/vue/components/courseware/tasks/TaskGroupTaskItem.vue b/resources/vue/components/courseware/tasks/TaskGroupTaskItem.vue new file mode 100644 index 0000000000000000000000000000000000000000..fee7a2f743f2051390c4cedb3d7b415c012bded5 --- /dev/null +++ b/resources/vue/components/courseware/tasks/TaskGroupTaskItem.vue @@ -0,0 +1,120 @@ +<template> + <tr> + <td> + <studip-icon + v-if="status.shape !== undefined" + :shape="status.shape" + :role="status.role" + :title="status.description" + aria-hidden="true" + /> + <span class="sr-only">{{ status.description }}</span> + </td> + <td> + <span v-if="user"> + <studip-icon shape="person2" role="info" aria-hidden="true" :title="$gettext('Teilnehmende Person')" /> + <span class="sr-only">{{ $gettext('Teilnehmende Person') }}</span> + {{ user.attributes['formatted-name'] }} + </span> + <span v-if="group"> + <studip-icon shape="group2" role="info" aria-hidden="true" :title="$gettext('Gruppe')" /> + <span class="sr-only">{{ $gettext('Gruppe') }}</span> + {{ group.attributes['name'] }} + </span> + </td> + <td class="responsive-hidden"> + <a v-if="task.attributes.submitted" :href="getLinkToElement(element)"> + {{ element.attributes.title }} + </a> + <span v-else>{{ element.attributes.title }}</span> + </td> + <td>{{ task.attributes?.progress?.toFixed(2) || '-.--' }}%</td> + <td>{{ getReadableDate(task.attributes['submission-date']) }}</td> + <td> + <studip-icon v-if="task.attributes.submitted" shape="accept" role="status-green" /> + </td> + <td class="responsive-hidden"> + <button v-show="task.attributes.renewal === 'pending'" class="button" @click="$emit('solve-renewal', task)"> + {{ $gettext('Anfrage bearbeiten') }} + </button> + <span v-show="task.attributes.renewal === 'declined'"> + <studip-icon shape="decline" role="status-red" /> + {{ $gettext('Anfrage abgelehnt') }} + </span> + <span v-show="task.attributes.renewal === 'granted'"> + {{ $gettext('verlängert bis') }}: + {{ getReadableDate(task.attributes['renewal-date']) }} + </span> + <studip-icon + v-if="task.attributes.renewal === 'declined' || task.attributes.renewal === 'granted'" + :title="$gettext('Anfrage bearbeiten')" + class="edit" + shape="edit" + role="clickable" + @click="$emit('solve-renewal', task)" + /> + </td> + <td class="responsive-hidden"> + <span + v-if="feedback" + :title=" + $gettextInterpolate($gettext('Feedback geschrieben am: %{ date }'), { + date: getReadableDate(feedback.attributes['chdate']), + }) + " + > + <studip-icon shape="accept" role="status-green" /> + {{ $gettext('Feedback gegeben') }} + <studip-icon + :title="$gettext('Feedback bearbeiten')" + class="edit" + shape="edit" + role="clickable" + @click="$emit('edit-feedback', feedback)" + /> + </span> + + <button v-show="!feedback && task.attributes.submitted" class="button" @click="$emit('add-feedback', task)"> + {{ $gettext('Feedback geben') }} + </button> + </td> + </tr> +</template> +<script> +import taskHelper from '../../../mixins/courseware/task-helper.js'; +import { mapGetters } from 'vuex'; + +export default { + mixins: [taskHelper], + props: ['task', 'taskGroup'], + computed: { + ...mapGetters({ + elementById: 'courseware-structural-elements/byId', + feedbackById: 'courseware-task-feedback/byId', + statusGroupById: 'status-groups/byId', + userById: 'users/byId', + }), + element() { + return this.elementById({ id: this.task.relationships['structural-element'].data.id }); + }, + feedback() { + const id = this.task.relationships['task-feedback'].data?.id; + return id ? this.feedbackById({ id }) : null; + }, + group() { + const { id, type } = this.solver; + return type === 'status-groups' ? this.statusGroupById({ id }) : null; + }, + solver() { + return this.task.relationships.solver.data; + }, + status() { + return this.getStatus(this.task); + }, + user() { + const { id, type } = this.solver; + return type === 'users' ? this.userById({ id }) : null; + }, + }, +}; +</script> diff --git a/resources/vue/components/courseware/tasks/TaskGroupsDeleteDialog.vue b/resources/vue/components/courseware/tasks/TaskGroupsDeleteDialog.vue new file mode 100644 index 0000000000000000000000000000000000000000..dd25c701bedad249e9981663a8d14c80683e4401 --- /dev/null +++ b/resources/vue/components/courseware/tasks/TaskGroupsDeleteDialog.vue @@ -0,0 +1,38 @@ +<template> + <studip-dialog + :title="$gettext('Aufgabe löschen')" + :confirmText="$gettext('Löschen')" + confirmClass="accept" + :closeText="$gettext('Abbrechen')" + closeClass="cancel" + @close="onClose" + @confirm="onConfirm" + > + <template #dialogContent> + {{ $gettext('Wollen Sie die Aufgabe wirklich löschen?') }} + </template> + </studip-dialog> +</template> + +<script> +import { mapActions } from 'vuex'; + +export default { + props: ['taskGroup'], + methods: { + ...mapActions({ + deleteTaskGroup: 'courseware-task-groups/delete', + setShowTaskGroupsDeleteDialog: 'tasks/setShowTaskGroupsDeleteDialog' + }), + onClose() { + this.setShowTaskGroupsDeleteDialog(false); + }, + onConfirm() { + this.deleteTaskGroup(this.taskGroup).then(() => { + this.onClose(); + this.$router.push({ name: 'task-groups-index' }); + }); + }, + }, +}; +</script> diff --git a/resources/vue/components/courseware/tasks/TaskGroupsModifyDeadlineDialog.vue b/resources/vue/components/courseware/tasks/TaskGroupsModifyDeadlineDialog.vue new file mode 100644 index 0000000000000000000000000000000000000000..47c6c5d760599a1657dcc57a1d0d167ae98c83dd --- /dev/null +++ b/resources/vue/components/courseware/tasks/TaskGroupsModifyDeadlineDialog.vue @@ -0,0 +1,109 @@ +<template> + <studip-dialog + :title="$gettext('Bearbeitungszeit verlängern')" + :confirmText="$gettext('Verlängern')" + confirmClass="accept" + :closeText="$gettext('Abbrechen')" + closeClass="cancel" + @close="onClose" + @confirm="onConfirm" + > + <template #dialogContent> + <form class="default"> + <p> + {{ $gettext('Aktuelle Bearbeitungszeit:') }} <StudipDate :date="startDate" />–<StudipDate :date="endDate" /> + ({{ $gettextInterpolate($gettext('%{ count } Tage'), { count: oldDuration }) }}) + </p> + <div class="formpart"> + <LabelRequired + :id="`task-groups-${uid}`" + :label="$gettext('Bearbeitungszeit verlängern bis zum:')" + /> + <input + :id="`task-groups-${uid}`" + name="end-date" + type="date" + v-model="localEndDate" + :min="endDateString" + class="size-l" + required + /> + <div>({{ $gettextInterpolate($gettext('%{ count } Tage'), { count: newDuration }) }})</div> + </div> + </form> + </template> + </studip-dialog> +</template> + +<script> +import { mapActions, mapGetters } from 'vuex'; +import LabelRequired from '../../forms/LabelRequired.vue'; +import StudipDate from '../../StudipDate.vue'; + +const midnight = (_date) => { + const date = new Date(_date); + date.setHours(0); + date.setMinutes(0); + date.setSeconds(0); + date.setMilliseconds(0); + return date; +}; + +const dateString = (date) => + `${date.getFullYear()}-${('' + (date.getMonth() + 1)).padStart(2, '0')}-${('' + date.getDate()).padStart(2, '0')}`; + +let nextUid = 0; + +export default { + props: ['taskGroup'], + components: { + LabelRequired, + StudipDate, + }, + data: () => ({ localEndDate: null, uid: nextUid++ }), + computed: { + endDate() { + return midnight(this.taskGroup?.attributes?.['end-date'] ?? new Date()); + }, + endDateString() { + return dateString(this.endDate); + }, + newDuration() { + return this.localEndDate + ? Math.floor((midnight(this.localEndDate) - this.startDate) / (1000 * 60 * 60 * 24)) + : 0; + }, + oldDuration() { + return Math.floor((this.endDate - this.startDate) / (1000 * 60 * 60 * 24)); + }, + startDate() { + return midnight(this.taskGroup.attributes['start-date']); + }, + }, + methods: { + ...mapActions({ + modifyDeadline: 'tasks/modifyDeadlineOfTaskGroup', + setShowDialog: 'tasks/setShowTaskGroupsModifyDeadlineDialog' + }), + onClose() { + this.setShowDialog(false); + }, + onConfirm() { + const endDate = midnight(this.localEndDate); + this.modifyDeadline({ taskGroup: this.taskGroup, endDate }); + this.onClose(); + }, + resetLocalVars() { + this.localEndDate = dateString(this.endDate ?? new Date()); + }, + }, + mounted() { + this.resetLocalVars(); + }, + watch: { + taskGroup() { + this.resetLocalVars(); + }, + }, +}; +</script> diff --git a/resources/vue/components/courseware/tasks/peer-review/AssessmentDialog.vue b/resources/vue/components/courseware/tasks/peer-review/AssessmentDialog.vue new file mode 100644 index 0000000000000000000000000000000000000000..d80a31c2b27f4e7e89699f597aba4370d892f2a7 --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/AssessmentDialog.vue @@ -0,0 +1,91 @@ +<template> + <StudipDialog + v-if="show" + :title="$gettext('Aufgabe begutachten')" + :confirmText="$gettext('Speichern')" + confirmClass="accept" + :closeText="$gettext('Schließen')" + closeClass="cancel" + height="420" + @close="onClose" + @confirm="onConfirm" + > + <template #dialogContent> + <component v-bind:is="assessmentComponent" :process="process" :review="review" @answer="onAnswer"></component> + </template> + </StudipDialog> +</template> + +<script> +import AssessmentTypeForm from './AssessmentTypeForm.vue'; +import AssessmentTypeFreetext from './AssessmentTypeFreetext.vue'; +import AssessmentTypeTable from './AssessmentTypeTable.vue'; +import StudipDialog from '../../../StudipDialog.vue'; +import { mapActions, mapGetters } from 'vuex'; + +export default { + model: { + prop: 'show', + event: 'updateShow', + }, + components: { + AssessmentTypeForm, + StudipDialog, + }, + props: { + show: { + type: Boolean, + required: true, + }, + review: { + type: Object, + required: true, + }, + }, + data: () => ({ + assessment: {} + }), + computed: { + ...mapGetters({ + relatedProcess: 'courseware-peer-review-processes/related', + }), + assessmentComponent() { + switch (this.configuration?.type) { + case 'form': + return AssessmentTypeForm; + case 'freetext': + return AssessmentTypeFreetext; + case 'table': + return AssessmentTypeTable; + default: + return null; + } + }, + configuration() { + return this.process?.attributes?.configuration ?? {}; + }, + process() { + return this.relatedProcess({ + parent: { id: this.review.id, type: this.review.type }, + relationship: 'process', + }); + }, + }, + methods: { + ...mapActions({ + storeAssessment: 'tasks/storeAssessment', + }), + onAnswer(assessment) { + this.assessment = assessment; + }, + onClose() { + this.$emit('updateShow', false); + this.assessment = {}; + }, + onConfirm() { + this.$emit('updateShow', false); + this.storeAssessment({ review: this.review, assessment: this.assessment }); + }, + }, +}; +</script> diff --git a/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeEditorDialog.vue b/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeEditorDialog.vue new file mode 100644 index 0000000000000000000000000000000000000000..d01fb39407311ea952951286203aaea84e60ff0d --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeEditorDialog.vue @@ -0,0 +1,86 @@ +<template> + <StudipDialog + v-if="show && process" + :title="$gettext('Gutachten-Form ändern')" + :confirmText="$gettext('Speichern')" + confirmClass="accept" + :closeText="$gettext('Schließen')" + closeClass="cancel" + height="420" + width="800" + @close="onClose" + @confirm="onConfirm" + > + <template #dialogContent> + <component v-bind:is="editorComponent" v-model="payload"></component> + </template> + </StudipDialog> +</template> + +<script> +import { mapGetters } from 'vuex'; +import EditorForm from './EditorForm.vue'; +import EditorTable from './EditorTable.vue'; +import StudipDialog from '../../../StudipDialog.vue'; +import { ASSESSMENT_TYPES } from './process-configuration'; + +export default { + model: { + prop: 'show', + event: 'updateShow', + }, + components: { + StudipDialog, + }, + props: { + show: { + type: Boolean, + required: true, + }, + process: { + type: Object, + default: null, + }, + }, + data() { + return { localPayload: _.cloneDeep(this.payload) }; + }, + computed: { + ...mapGetters({}), + editorComponent() { + switch (this.configuration?.type) { + case 'form': + return EditorForm; + case 'freetext': + return null; + case 'table': + return EditorTable; + default: + return null; + } + }, + configuration() { + return this.process?.attributes?.configuration ?? {}; + }, + defaultPayload() { + return ASSESSMENT_TYPES[this.configuration.type].defaultPayload ?? {}; + }, + payload: { + get() { + return _.isEmpty(this.configuration.payload) ? this.defaultPayload : this.configuration.payload; + }, + set(payload) { + this.localPayload = payload; + }, + }, + }, + methods: { + onClose() { + this.$emit('updateShow', false); + }, + onConfirm(...args) { + this.$emit('update', _.cloneDeep(this.localPayload)); + }, + }, +}; +</script> diff --git a/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeForm.vue b/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeForm.vue new file mode 100644 index 0000000000000000000000000000000000000000..c5f9da2c8fa8d072d1104df0ef199b3ee82b7506 --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeForm.vue @@ -0,0 +1,96 @@ +<template> + <article> + <form class="default studipform"> + <div class="formpart" v-for="(criterium, index) in criteria" :key="index"> + <LabelRequired + :id="`assessment-type-form-${index}`" + :label="criterium.text" + /> + <p>{{ criterium.description }}</p> + <textarea + :id="`assessment-type-form-${index}`" + required + aria-required="true" + v-model="answers[index]" + @change="changeAnswers" /> + </div> + </form> + </article> +</template> +<script lang="ts"> +import Vue, { PropType } from 'vue'; +import LabelRequired from '../../../forms/LabelRequired.vue'; +import { JsonApiSchema } from './definitions'; +import { ProcessConfiguration, FormAssessmentPayload } from './process-configuration'; + +interface PeerReviewProcessSchema { + id?: string; + type: string; + meta?: any; + relationships?: any; + attributes: { + configuration: ProcessConfiguration; + 'review-start': string; + 'review-end': string; + mkdate: string; + chdate: string; + }; +} + +interface PeerReviewSchema { + id?: string; + type: string; + meta?: any; + relationships?: any; + attributes: { + assessment: { + answers: string[]; + }; + mkdate: string; + chdate: string; + }; +} + +export default Vue.extend({ + components: { LabelRequired }, + props: { + process: { + type: Object as PropType<PeerReviewProcessSchema>, + required: true, + }, + review: { + type: Object as PropType<PeerReviewSchema>, + required: true, + }, + }, + data() { + return { + answers: this.review.attributes.assessment?.answers ?? [], + }; + }, + computed: { + criteria() { + const payload = <FormAssessmentPayload>this.process.attributes.configuration.payload; + return payload.criteria ?? []; + }, + }, + methods: { + changeAnswers() { + const answers = this.criteria.map((_, index) => this.answers[index] ?? ''); + this.$emit('answer', { answers }); + }, + }, +}); +</script> + +<style scoped> +textarea { + min-height: 5em; + max-width: 48em; + width: 100%; +} + +.formpart + .formpart { + margin-block-start: 1rem; +} +</style> diff --git a/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeFreetext.vue b/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeFreetext.vue new file mode 100644 index 0000000000000000000000000000000000000000..843b40820ee066220601dbd87da02e3bab1e19c3 --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeFreetext.vue @@ -0,0 +1,89 @@ +<template> + <article> + <form class="default studipform"> + <div class="formpart"> + <LabelRequired + id="assessment-type-freetext" + :label="$gettext('Bewertung')" + /> + <textarea + id="assessment-type-freetext" + required + aria-required="true" + v-model="answer" + @change="changeAnswer" /> + </div> + </form> + </article> +</template> +<script lang="ts"> +import Vue, { PropType } from 'vue'; +import LabelRequired from '../../../forms/LabelRequired.vue'; +import { JsonApiSchema } from './definitions'; +import { ProcessConfiguration } from './process-configuration'; + +interface PeerReviewProcessSchema { + id?: string; + type: string; + meta?: any; + relationships?: any; + attributes: { + configuration: ProcessConfiguration; + 'review-start': string; + 'review-end': string; + mkdate: string; + chdate: string; + }; +} + +interface PeerReviewSchema { + id?: string; + type: string; + meta?: any; + relationships?: any; + attributes: { + assessment: { + answer: string; + }; + mkdate: string; + chdate: string; + }; +} + +export default Vue.extend({ + components: { LabelRequired }, + props: { + process: { + type: Object as PropType<PeerReviewProcessSchema>, + required: true, + }, + review: { + type: Object as PropType<PeerReviewSchema>, + required: true, + }, + }, + data() { + return { + answer: this.review.attributes.assessment?.answer ?? "", + }; + }, + methods: { + changeAnswer() { + const answer = this.answer ?? ''; + this.$emit('answer', { answer }); + }, + }, +}); +</script> + +<style scoped> +textarea { + min-height: 5em; + max-width: 48em; + width: 100%; +} + +.formpart + .formpart { + margin-block-start: 1rem; +} +</style> diff --git a/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeTable.vue b/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeTable.vue new file mode 100644 index 0000000000000000000000000000000000000000..d67be8b353ceb3e107b322e599023e324ead3e8a --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeTable.vue @@ -0,0 +1,138 @@ +<template> + <article> + <form class="default studipform"> + <div class="formpart" v-for="(criterium, index) in criteria" :key="index"> + <LabelRequired :id="`assessment-type-table-${index}`" :label="criterium.text" /> + <section> + <textarea + :id="`assessment-type-table-${index}`" + required + aria-required="true" + v-model="answers[index].text" + @change="changeAnswers" + /> + + <div> + <label v-for="(text, rating) in ratingLevels" :key="text" + ><input + v-model="answers[index].rating" + :name="`rating-${index}`" + type="radio" + :value="rating + 1" + @change="changeAnswers" + />{{ text }}</label + > + </div> + </section> + </div> + </form> + </article> +</template> +<script lang="ts"> +import Vue, { PropType } from 'vue'; +import { $gettext } from '../../../../../assets/javascripts/lib/gettext'; +import LabelRequired from '../../../forms/LabelRequired.vue'; +import { JsonApiSchema } from './definitions'; +import { EditorTableCriterium, ProcessConfiguration, TableAssessmentPayload } from './process-configuration'; + +interface PeerReviewProcessSchema { + id?: string; + type: string; + meta?: any; + relationships?: any; + attributes: { + configuration: ProcessConfiguration; + 'review-start': string; + 'review-end': string; + mkdate: string; + chdate: string; + }; +} + +type Answer = { + text: string; + rating: number; +}; + +type Assessment = { + answers: Answer[]; +}; + +interface PeerReviewSchema { + id?: string; + type: string; + meta?: any; + relationships?: any; + attributes: { + assessment: Assessment; + mkdate: string; + chdate: string; + }; +} + +const emptyAssessment = (criteria: EditorTableCriterium[]): Assessment => { + return { + answers: criteria.map((_) => ({ text: '', rating: 0 })), + }; +}; + +export default Vue.extend({ + components: { LabelRequired }, + props: { + process: { + type: Object as PropType<PeerReviewProcessSchema>, + required: true, + }, + review: { + type: Object as PropType<PeerReviewSchema>, + required: true, + }, + }, + data() { + return { + answers: [] as Answer[], + }; + }, + computed: { + criteria(): EditorTableCriterium[] { + const payload = <TableAssessmentPayload>this.process.attributes.configuration.payload; + return payload.criteria ?? []; + }, + ratingLevels() { + return [$gettext('gut'), $gettext('ok'), $gettext('schwach')]; + }, + }, + methods: { + changeAnswers() { + this.$emit('answer', { answers: this.answers }); + }, + }, + beforeMount() { + if (this.review.attributes.assessment && 'answers' in this.review.attributes.assessment) { + this.answers = this.review.attributes.assessment.answers; + } else { + this.answers = emptyAssessment(this.criteria).answers; + } + }, +}); +</script> + +<style scoped> +textarea { + min-height: 5em; + max-width: 48em; + width: 100%; +} + +.formpart + .formpart { + margin-block-start: 1rem; +} + +.formpart > section { + display: flex; +} + +.formpart > section label { + white-space: nowrap; +} +</style> diff --git a/resources/vue/components/courseware/tasks/peer-review/EditorForm.vue b/resources/vue/components/courseware/tasks/peer-review/EditorForm.vue new file mode 100644 index 0000000000000000000000000000000000000000..8a8b7c49474cdcff92920a861dbdab6615e0be90 --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/EditorForm.vue @@ -0,0 +1,153 @@ +<template> + <CoursewareTabs> + <CoursewareTab :name="$gettext('Editor')" :index="0" selected class="cw-peer-review-editor-form--editor"> + <form class="default studipform"> + <StudipArticle v-for="(criterium, index) in localCriteria" :key="index" collapsable> + <template v-slot:title="{ isOpen }"> + <template v-if="isOpen"> + {{ + $gettextInterpolate($gettext('Kriterium %{ index }: "%{ text }"'), { + index: index + 1, + text: criterium.text, + }) + }} + </template> + <template v-else> + {{ $gettextInterpolate($gettext('Kriterium %{ index }'), { index: index + 1 }) }} + </template> + </template> + <template #titleplus> + <StudipActionMenu :items="actionItems(index)" :collapseAt="2" @trash="removeLine" /> + </template> + <template #body> + <div class="formpart criterium-text"> + <LabelRequired :id="`editor-form-text-${index}`" :label="$gettext('Kriterium')" /> + <input + :id="`editor-form-text-${index}`" + type="text" + v-model="criterium.text" + required + aria-required="true" + /> + </div> + <div class="formpart criterium-description"> + <LabelRequired :id="`editor-form-description-${index}`" :label="$gettext('Beschreibung')" /> + <textarea + :id="`editor-form-description-${index}`" + v-model="criterium.description" + required + aria-required="true" + ></textarea> + </div> + </template> + </StudipArticle> + <div class="formpart"> + <button class="button add" type="button" @click="addLine"> + <span>{{ $gettext('Kriterium hinzufügen') }}</span> + </button> + </div> + </form> + </CoursewareTab> + <CoursewareTab :name="$gettext('Vorschau')" :index="1" class="cw-peer-review-editor-form--preview"> + <article> + <section v-for="(criterium, index) in nonEmptyCriteria" :key="index"> + <strong>{{ criterium.text }}</strong> + <p>{{ criterium.description }}</p> + <textarea disabled /> + </section> + </article> + </CoursewareTab> + </CoursewareTabs> +</template> +<script lang="ts"> +import Vue, { PropType } from 'vue'; +import StudipActionMenu from '../../../StudipActionMenu.vue'; +import StudipArticle from '../../../StudipArticle.vue'; +import LabelRequired from '../../../forms/LabelRequired.vue'; +import CoursewareTab from '../../layouts/CoursewareTab.vue'; +import CoursewareTabs from '../../layouts/CoursewareTabs.vue'; +import { EditorFormCriterium, FormAssessmentPayload } from './process-configuration'; + +export default Vue.extend({ + components: { CoursewareTab, CoursewareTabs, LabelRequired, StudipActionMenu, StudipArticle }, + props: { + payload: { + type: Object as PropType<FormAssessmentPayload>, + }, + }, + model: { + prop: 'payload', + event: 'save', + }, + data: () => ({ localCriteria: [] as EditorFormCriterium[] }), + computed: { + criteria() { + return this.payload.criteria; + }, + nonEmptyCriteria() { + return this.localCriteria.filter(({ text }) => text.trim().length); + }, + }, + methods: { + actionItems(index: number) { + return this.localCriteria.length > 1 + ? [ + { + id: 1, + label: this.$gettext('Kriterium entfernen'), + icon: 'trash', + emit: 'trash', + emitArguments: [index], + }, + ] + : []; + }, + addLine() { + this.localCriteria.push({ text: '', description: '' }); + }, + removeLine(lineNumber: number) { + this.localCriteria = this.localCriteria.filter((item, index) => index !== lineNumber); + }, + resetLocalState() { + this.localCriteria = this.criteria.map(({ text, description }) => ({ text, description })); + }, + }, + mounted() { + this.resetLocalState(); + }, + watch: { + payload() { + this.resetLocalState(); + }, + localCriteria: { + handler() { + this.$emit('save', { criteria: this.nonEmptyCriteria.map((c) => ({ ...c })) }); + }, + deep: true, + }, + }, +}); +</script> + +<style scoped> +.cw-peer-review-editor-form--editor form input { + max-width: 48em; +} + +textarea { + min-height: 5em; + max-width: 48em; + width: 100%; +} + +.cw-peer-review-editor-form--preview > article { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.cw-peer-review-editor-form--preview > article > * + * { + border-top: 1px solid var(--light-gray-color-40); + padding-block-start: 1rem; +} +</style> diff --git a/resources/vue/components/courseware/tasks/peer-review/EditorTable.vue b/resources/vue/components/courseware/tasks/peer-review/EditorTable.vue new file mode 100644 index 0000000000000000000000000000000000000000..351e96f39d6f6e849042fa58c50e2c144a52a46f --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/EditorTable.vue @@ -0,0 +1,133 @@ +<template> + <CoursewareTabs> + <CoursewareTab :name="$gettext('Editor')" :index="0" selected class="cw-peer-review-editor-table-editor"> + <form class="studip studipform"> + <div class="formpart" v-for="(criterium, index) in localCriteria" :key="index"> + <LabelRequired :id="`editor-table-text-${index}`" :label="$gettext('Kriterium')" class="sr-only" /> + <input + :id="`editor-table-text-${index}`" + type="text" + v-model="criterium.text" + required + aria-required="true" + /> + <button + class="button trash" + type="button" + @click="removeLine(index)" + :disabled="criteria.length === 1" + > + <span class="sr-only">{{ $gettext('Kriterium entfernen') }}</span> + </button> + </div> + <div class="formpart"> + <button class="button add" type="button" @click="addLine"> + <span>{{ $gettext('Kriterium hinzufügen') }}</span> + </button> + </div> + </form> + </CoursewareTab> + <CoursewareTab :name="$gettext('Vorschau')" :index="1" class="cw-peer-review-editor-table--preview"> + <table class="default"> + <thead> + <tr> + <th>{{ $gettext('Kriterien') }}</th> + <th>{{ $gettext('Bewertung') }}</th> + <th>{{ $gettext('Kommentar') }}</th> + </tr> + </thead> + <tbody> + <tr v-for="(criterium, index) in nonEmptyCriteria" :key="index"> + <td>{{ criterium.text }}</td> + <td> + <label v-for="text in [$gettext('gut'), $gettext('ok'), $gettext('schwach')]" :key="text"> + <input name="rating" type="radio" disabled /> + {{ text }} + </label> + </td> + <td> + <textarea disabled /> + </td> + </tr> + </tbody> + </table> + </CoursewareTab> + </CoursewareTabs> +</template> +<script lang="ts"> +import Vue, { PropType } from 'vue'; +import LabelRequired from '../../../forms/LabelRequired.vue'; +import StudipArticle from '../../../StudipArticle.vue'; +import CoursewareTab from '../../layouts/CoursewareTab.vue'; +import CoursewareTabs from '../../layouts/CoursewareTabs.vue'; +import { EditorTableCriterium, TableAssessmentPayload } from './process-configuration'; + +export default Vue.extend({ + components: { CoursewareTab, CoursewareTabs, LabelRequired, StudipArticle }, + props: { + payload: { + type: Object as PropType<TableAssessmentPayload>, + }, + }, + model: { + prop: 'payload', + event: 'save', + }, + data: () => ({ localCriteria: [] as EditorTableCriterium[] }), + computed: { + criteria() { + return this.payload.criteria; + }, + nonEmptyCriteria() { + return this.localCriteria.filter(({ text }) => text.trim().length); + }, + }, + methods: { + addLine() { + this.localCriteria.push({ text: '' }); + }, + removeLine(lineNumber: number) { + this.localCriteria = this.localCriteria.filter((item, index) => index !== lineNumber); + }, + resetLocalState() { + this.localCriteria = this.criteria.map(({ text }) => ({ text })); + }, + }, + mounted() { + this.resetLocalState(); + }, + watch: { + payload() { + this.resetLocalState(); + }, + localCriteria: { + handler() { + this.$emit('save', { criteria: this.nonEmptyCriteria.map((c) => ({ ...c })) }); + }, + deep: true, + }, + }, +}); +</script> + +<style scoped> +form button.trash { + min-width: 2em; + width: 2em; +} +form input { + flex-grow: 1; + height: 1.7em; + max-width: 48em; +} + +form .formpart { + display: flex; + align-items: center; + gap: 1em; +} + +.cw-peer-review-editor-table--preview label { + display: block; +} +</style> diff --git a/resources/vue/components/courseware/tasks/peer-review/PagesProcessesIndex.vue b/resources/vue/components/courseware/tasks/peer-review/PagesProcessesIndex.vue new file mode 100644 index 0000000000000000000000000000000000000000..4abc75a0c8e5ae8adefb18578ce91f8819493a6c --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/PagesProcessesIndex.vue @@ -0,0 +1,34 @@ +<template> + <div class="cw-peer-review-processes-wrapper"> + <div v-if="userIsTeacher"> + <ProcessesList /> + </div> + <div v-else> + <PeerReviewAssignments /> + </div> + + <MountingPortal mountTo="#courseware-action-widget" name="sidebar-actions" v-if="userIsTeacher"> + <SidebarActionWidget /> + </MountingPortal> + </div> +</template> + +<script> +import PeerReviewAssignments from './PeerReviewAssignments.vue'; +import ProcessesList from './ProcessesList.vue'; +import SidebarActionWidget from './SidebarActionWidget.vue'; +import { mapGetters } from 'vuex'; + +export default { + components: { + PeerReviewAssignments, + ProcessesList, + SidebarActionWidget, + }, + computed: { + ...mapGetters({ + userIsTeacher: 'userIsTeacher', + }), + }, +}; +</script> diff --git a/resources/vue/components/courseware/tasks/peer-review/PagesProcessesShow.vue b/resources/vue/components/courseware/tasks/peer-review/PagesProcessesShow.vue new file mode 100644 index 0000000000000000000000000000000000000000..2f906898ac8f05087c269614b7f7d22e4c81c8a4 --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/PagesProcessesShow.vue @@ -0,0 +1,311 @@ +<template> + <div class="cw-peer-review-processes-wrapper"> + <div class="cw-peer-review-processes-list"> + <CoursewareRibbon :isContentBar="true" :showToolbarButton="false"> + <template #buttons> + <router-link :to="{ name: 'peer-review-processes-index' }"> + <StudipIcon shape="category-task" :size="24" /> + </router-link> + </template> + <template #breadcrumbList> + <li class="cw-peer-review-processes-breadcrumb-item"> + <router-link :to="{ name: 'peer-review-processes-index' }"> + {{ $gettext('Peer-Reviews') }} + </router-link> + </li> + <li v-if="taskGroup" class="cw-peer-review-processes-breadcrumb-item"> + {{ $gettext('Peer-Review-Prozess') }} + </li> + </template> + </CoursewareRibbon> + <div> + <CompanionBox + v-if="!canChangeConfiguration" + :msgCompanion=" + $gettext( + 'Der Peer-Review-Prozess hat bereits begonnen. Bis auf die Bearbeitungsdauer können die Einstellungen nicht geändert werden.' + ) + " + > + </CompanionBox> + + <section> + <article> + <header> + <h2>{{ $gettext('Status') }}</h2> + </header> + <div class="cw-peer-review-processes-status"> + <ProcessStatus :process="process" /> + <span>{{ processStatus.description }}</span> + </div> + <div> + <span>{{ $gettext('Bearbeitungszeit:') }}</span> + <StudipDate :date="startDate" />–<StudipDate :date="endDate" /> + </div> + <div v-if="canChangeDurationOnly"> + <button class="button" @click="onShowDuration"> + {{ $gettext('Bearbeitungszeit verlängern') }} + </button> + </div> + </article> + <article> + <header> + <h2>{{ $gettext('Aufgabe') }}</h2> + </header> + <div> + <router-link :to="{ name: 'task-groups-show', params: { id: taskGroup.id } }"> + {{ taskGroup.attributes.title }} + </router-link> + <StudipDate :date="new Date(taskGroup.attributes['start-date'])" />–<StudipDate + :date="new Date(taskGroup.attributes['end-date'])" + /> + </div> + </article> + <article> + <header> + <h2>{{ $gettext('Einstellungen') }}</h2> + </header> + <div> + <ProcessConfiguration :options="configuration" /> + </div> + <div v-if="canChangeConfiguration" class="cw-peer-review-processes-configuration-buttons"> + <button class="button" @click="onShowConfiguration"> + {{ $gettext('Einstellungen ändern') }} + </button> + <button + class="button" + @click="onShowAssessmentTypeEditor" + v-if="configuration.type === 'form' || configuration.type === 'table'" + > + {{ $gettext('Bewertungssystem konfigurieren') }} + </button> + </div> + </article> + + <article> + <header> + <h2>{{ $gettext('Peer-Reviews') }}</h2> + </header> + <div> + <template v-if="isBefore"> + <CompanionBox + v-if="isAutomaticPairing" + :msgCompanion=" + $gettext( + 'In diesem Peer-Review-Prozess werden die Paarungen automatisch verteilt, sobald der Bearbeitungszeitraum beginnt.' + ) + " + > + </CompanionBox> + <CompanionBox + v-else + mood="pointing" + :msgCompanion=" + $gettext( + 'In diesem Peer-Review-Prozess werden die Paarungen manuell verteilt, bevor der Bearbeitungszeitraum beginnt.' + ) + " + > + <template #companionActions> + <button class="button" @click="onShowPairingEditor"> + {{ $gettext('Paarungen manuell festlegen') }} + </button> + </template> + </CompanionBox> + </template> + <PeerReviewList :process="process" :task-group="taskGroup" /> + </div> + </article> + </section> + </div> + </div> + <AssessmentTypeEditorDialog + v-model="showAssessmentTypeEditor" + :process="process" + @update="onUpdateAssessmentType" + /> + <PairingEditorDialog v-model="showPairingEditor" :process="process" @update="onUpdatePairing" /> + <ProcessEditDialog + v-if="showPeerReviewProcessEdit" + :process="process" + @update="onUpdatePeerReviewProcess" + @close="showPeerReviewProcessEdit = false" + /> + <ProcessDurationDialog v-model="showPeerReviewProcessDuration" :process="process" @update="onUpdateDuration" /> + + <MountingPortal mountTo="#courseware-action-widget" name="sidebar-actions" v-if="userIsTeacher"> + <SidebarActionWidget /> + </MountingPortal> + </div> +</template> + +<script> +import { mapActions, mapGetters } from 'vuex'; +import AssessmentTypeEditorDialog from './AssessmentTypeEditorDialog.vue'; +import CompanionBox from '../../layouts/CoursewareCompanionBox.vue'; +import CoursewareRibbon from '../../structural-element/CoursewareRibbon.vue'; +import PairingEditorDialog from './PairingEditorDialog.vue'; +import PeerReviewList from './PeerReviewList.vue'; +import ProcessConfiguration from './ProcessConfiguration.vue'; +import ProcessDurationDialog from './ProcessDurationDialog.vue'; +import ProcessEditDialog from './ProcessEditDialog.vue'; +import ProcessStatus from './ProcessStatus.vue'; +import SidebarActionWidget from './SidebarActionWidget.vue'; +import StudipArticle from '../../../StudipArticle.vue'; +import StudipDate from '../../../StudipDate.vue'; +import StudipMessageBox from '../../../StudipMessageBox.vue'; +import { getProcessStatus, ProcessStatus as Status } from './definitions'; + +export default { + components: { + AssessmentTypeEditorDialog, + CompanionBox, + CoursewareRibbon, + PairingEditorDialog, + PeerReviewList, + ProcessConfiguration, + ProcessDurationDialog, + ProcessEditDialog, + ProcessStatus, + SidebarActionWidget, + StudipArticle, + StudipDate, + StudipMessageBox, + }, + props: { + id: { + type: String, + required: true, + }, + }, + data: () => ({ + showAssessmentTypeEditor: false, + showPairingEditor: false, + showPeerReviewProcessDuration: false, + showPeerReviewProcessEdit: false, + }), + computed: { + ...mapGetters({ + getProcess: 'courseware-peer-review-processes/byId', + relatedPeerReviews: 'courseware-peer-reviews/related', + relatedTasks: 'courseware-tasks/related', + relatedTaskGroups: 'courseware-task-groups/related', + relatedUsers: 'users/related', + userIsTeacher: 'userIsTeacher', + }), + canChangeConfiguration() { + return this.isBefore; + }, + canChangeDurationOnly() { + return this.processStatus.status === Status.Active; + }, + configuration() { + return this.process.attributes['configuration']; + }, + endDate() { + return new Date(this.process.attributes['review-end']); + }, + isBefore() { + return this.processStatus.status === Status.Before; + }, + isAutomaticPairing() { + return this.configuration.automaticPairing; + }, + owner() { + return this.relatedUsers({ parent: this.process, relationship: 'owner' }); + }, + peerReviews() { + const result = this.relatedPeerReviews({ parent: this.process, relationship: 'peer-reviews' }); + console.debug(result); + return result; + }, + process() { + return this.getProcess({ id: this.id }); + }, + processStatus() { + return getProcessStatus(this.process); + }, + solvers() { + return this.taskGroup.relationships.solvers.data.map(({ id, type }) => { + return [id, type]; + }); + }, + startDate() { + return new Date(this.process.attributes['review-start']); + }, + taskGroup() { + return this.relatedTaskGroups({ parent: this.process, relationship: 'task-group' }); + }, + tasks() { + return this.relatedTasks({ parent: this.taskGroup, relationship: 'tasks' }); + }, + }, + methods: { + ...mapActions({ + loadRelatedPeerReviews: 'courseware-peer-reviews/loadRelated', + replacePairings: 'tasks/replacePairings', + updatePeerReviewProcess: 'tasks/updatePeerReviewProcess', + }), + loadPeerReviews() { + return this.loadRelatedPeerReviews({ + parent: this.process, + relationship: 'peer-reviews', + options: { include: 'reviewer,task' }, + }); + }, + onShowAssessmentTypeEditor() { + this.showAssessmentTypeEditor = true; + }, + onShowConfiguration() { + this.showPeerReviewProcessEdit = true; + }, + onShowDuration() { + this.showPeerReviewProcessDuration = true; + }, + onShowPairingEditor() { + this.showPairingEditor = true; + }, + onUpdateAssessmentType(payload) { + const configuration = this.process.attributes.configuration; + configuration.payload = payload; + + this.updatePeerReviewProcess({ process: this.process, configuration }).then( + () => (this.showAssessmentTypeEditor = false) + ); + }, + onUpdateDuration(duration) { + const configuration = { ...this.process.attributes.configuration, duration }; + this.updatePeerReviewProcess({ process: this.process, configuration }).then( + () => (this.showPeerReviewProcessDuration = false) + ); + }, + onUpdatePairing(pairings) { + this.replacePairings({ process: this.process, pairings }) + .then(() => this.loadPeerReviews()) + .then(() => (this.showPairingEditor = false)) + .catch((error) => { + console.error('Could not replace pairings.', error); + }); + }, + onUpdatePeerReviewProcess({ configuration }) { + this.updatePeerReviewProcess({ process: this.process, configuration }).then( + () => (this.showPeerReviewProcessEdit = false) + ); + }, + }, + async mounted() { + await this.loadPeerReviews(); + }, +}; +</script> + +<style> +.cw-peer-review-processes-breadcrumb-item { + white-space: nowrap; +} +.cw-peer-review-processes-status, +.cw-peer-review-processes-configuration-buttons { + display: flex; + gap: 0.25rem; +} +</style> diff --git a/resources/vue/components/courseware/tasks/peer-review/PairingEditor.vue b/resources/vue/components/courseware/tasks/peer-review/PairingEditor.vue new file mode 100644 index 0000000000000000000000000000000000000000..1b627848df9f49e1c6006538ce167366fe73b446 --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/PairingEditor.vue @@ -0,0 +1,224 @@ +<template> + <div> + <form class="default"> + <div> + <label> + {{ $gettext('verfasst von') }} + <select v-model="selectedSubmitter" size="10"> + <option v-for="solver in selectableSubmitters" :value="solver"> + <span v-if="isUser(solver)"> + {{ solver.attributes['formatted-name'] }} + </span> + <span v-if="isStatusGroup(solver)"> + {{ solver.attributes.name }} + </span> + </option> + <option v-if="!selectableSubmitters?.length" disabled>{{ $gettext('--leer--') }}</option> + </select> + </label> + </div> + <div> + <label> + {{ $gettext('begutachtet von') }} + <select v-model="selectedReviewer" size="10"> + <option v-for="solver in selectableReviewers" :value="solver"> + <span v-if="isUser(solver)"> + {{ solver.attributes['formatted-name'] }} + </span> + <span v-if="isStatusGroup(solver)"> + {{ solver.attributes.name }} + </span> + </option> + <option v-if="!selectableReviewers?.length" disabled>{{ $gettext('--leer--') }}</option> + </select> + </label> + </div> + <div> + <div> + <div>{{ $gettext('Paarungen') }}</div> + <div> + <button + class="button button-icon" + type="button" + :disabled="!(selectedSubmitter && selectedReviewer)" + @click="onAdd" + > + <StudipIcon shape="arr_2right" role="info_alt" /> + <StudipIcon shape="arr_2right" /> + <span class="sr-only">{{ $gettext('Hinzufügen') }}</span> + </button> + <table> + <tr v-for="({ submitter, reviewer }, index) in localPairings" :key="index"> + <td> + <UserAvatar + v-if="submitter.type === 'users'" + :avatar-url="submitter.meta.avatar.small" + :formatted-name="submitter.attributes['formatted-name']" + /> + <span v-if="submitter.type === 'status-groups'"> + {{ submitter.attributes.name }} + </span> + </td> + + <td><span>»</span></td> + <td> + <UserAvatar + v-if="reviewer.type === 'users'" + :avatar-url="reviewer.meta.avatar.small" + :formatted-name="reviewer.attributes['formatted-name']" + /> + <span v-if="reviewer.type === 'status-groups'"> + {{ reviewer.attributes.name }} + </span> + </td> + <td> + <button @click="() => onTrash(index)" class="button button-icon" type="button"> + <StudipIcon shape="trash" role="info_alt" /> + <StudipIcon shape="trash" /> + <span class="sr-only">{{ $gettext('Entfernen') }}</span> + </button> + </td> + </tr> + </table> + </div> + </div> + </div> + </form> + </div> +</template> + +<script> +import _ from 'lodash'; +import { mapGetters } from 'vuex'; +import StudipIcon from '../../../StudipIcon.vue'; +import UserAvatar from '../../../StudipUserAvatar.vue'; + +export default { + model: { + prop: 'pairings', + event: 'update', + }, + components: { StudipIcon, UserAvatar }, + props: { + pairings: { + type: Array, + required: true, + }, + solvers: { + type: Array, + default: [], + }, + }, + data() { + return { + localPairings: [], + selectedSubmitter: null, + selectedReviewer: null, + }; + }, + computed: { + selectableReviewers() { + const selected = this.localPairings.map(({ reviewer }) => reviewer.id); + return this.solvers.filter(({ id }) => !selected.includes(id)); + }, + selectableSubmitters() { + const selected = this.localPairings.map(({ submitter }) => submitter.id); + return this.solvers.filter(({ id }) => !selected.includes(id)); + }, + }, + methods: { + isStatusGroup(object) { + return object.type === 'status-groups'; + }, + isUser(object) { + return object.type === 'users'; + }, + onAdd() { + this.localPairings.push({ + reviewer: this.selectedReviewer, + submitter: this.selectedSubmitter, + }); + this.selectedReviewer = null; + this.selectedSubmitter = null; + }, + onTrash(index) { + this.localPairings = [...this.localPairings.slice(0, index), ...this.localPairings.slice(index + 1)]; + }, + resetLocalState() { + this.localPairings = [...this.pairings]; + }, + }, + mounted() { + this.resetLocalState(); + }, + watch: { + localPairings(newP, oldP) { + if (!_.isEqual(this.localPairings, this.pairings)) { + this.$emit('update', [...this.localPairings]); + } + }, + pairings() { + if (!_.isEqual(this.localPairings, this.pairings)) { + this.resetLocalState(); + } + }, + selectedReviewer() { + if (this.selectedReviewer === this.selectedSubmitter) { + this.selectedSubmitter = null; + } + }, + selectedSubmitter() { + if (this.selectedReviewer === this.selectedSubmitter) { + this.selectedReviewer = null; + } + }, + }, +}; +</script> + +<style scoped> +form { + display: flex; + flex-wrap: wrap; + gap: 1rem; +} + +form > * { + flex-grow: 1; +} + +form > :nth-child(1) select, +form > :nth-child(2) select { + max-width: 15rem; +} + +form > :nth-child(3) { + flex-basis: 15rem; +} + +tr > :nth-child(2) { + padding-inline: 0.5rem; +} + +button.button-icon { + min-width: auto; + line-height: 2rem; + padding: 0; + width: 2rem; +} +button.button-icon > img { + vertical-align: middle; +} +button.button-icon > img:first-child { + display: none; +} +button.button-icon:hover > img:first-child { + display: inline; +} +button.button-icon > img:first-child { + display: hidden; +} +button.button-icon:hover > img:nth-child(2) { + display: none; +} +</style> diff --git a/resources/vue/components/courseware/tasks/peer-review/PairingEditorDialog.vue b/resources/vue/components/courseware/tasks/peer-review/PairingEditorDialog.vue new file mode 100644 index 0000000000000000000000000000000000000000..008891a4739f6be786928223c5924c67d665cf89 --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/PairingEditorDialog.vue @@ -0,0 +1,89 @@ +<template> + <StudipDialog + v-if="show && process" + :title="$gettext('Zuordnungen festlegen')" + :confirmText="$gettext('Speichern')" + confirmClass="accept" + :closeText="$gettext('Schließen')" + closeClass="cancel" + height="800" + width="800" + @close="onClose" + @confirm="onConfirm" + > + <template #dialogContent> + <pre>{{ JSON.stringify(pairings) }}</pre> + <PairingEditor v-if="pairings" v-model="pairings" :solvers="solvers" /> + </template> + </StudipDialog> +</template> + +<script> +import { mapGetters } from 'vuex'; +import PairingEditor from './PairingEditor.vue'; +import StudipDialog from '../../../StudipDialog.vue'; + +const objId = ({ id, type }) => ({ id, type }); + +export default { + model: { + prop: 'show', + event: 'updateShow', + }, + components: { + PairingEditor, + StudipDialog, + }, + props: { + show: { + type: Boolean, + required: true, + }, + process: { + type: Object, + default: null, + }, + }, + data() { + return { + pairings: [], + }; + }, + computed: { + ...mapGetters({ + relatedPeerReviews: 'courseware-peer-reviews/related', + relatedTaskGroups: 'courseware-task-groups/related', + }), + reviewPairs() { + return this.relatedPeerReviews({ parent: this.process, relationship: 'peer-reviews' }).map((review) => ({ + reviewer: this.getObject(review.relationships.reviewer.data), + submitter: this.getObject(review.relationships.submitter.data), + })); + }, + solvers() { + return this.taskGroup.relationships.solvers.data.map((solver) => this.getObject(solver)); + }, + taskGroup() { + return this.relatedTaskGroups({ parent: this.process, relationship: 'task-group' }); + }, + }, + methods: { + getObject({ type, id }) { + return this.$store.getters[`${type}/byId`]({ id }); + }, + onClose() { + this.$emit('updateShow', false); + }, + onConfirm() { + this.$emit('update', this.pairings); + }, + }, + watch: { + show() { + if (this.show) { + this.pairings = this.reviewPairs; + } + }, + }, +}; +</script> diff --git a/resources/vue/components/courseware/tasks/peer-review/PeerReviewAssignments.vue b/resources/vue/components/courseware/tasks/peer-review/PeerReviewAssignments.vue new file mode 100644 index 0000000000000000000000000000000000000000..9c4b3bdcf8db2237cf5f3f06032c5f5760ba48b2 --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/PeerReviewAssignments.vue @@ -0,0 +1,102 @@ +<template> + <div> + <p> + Es gibt im Moment in diese Mannschaft, oh, einige Spieler vergessen ihnen Profi was sie sind. Ich lese nicht + sehr viele Zeitungen, aber ich habe gehört viele Situationen. Erstens: wir haben nicht offensiv gespielt. Es + gibt keine deutsche Mannschaft spielt offensiv und die Name offensiv wie Bayern. Letzte Spiel hatten wir in + Platz drei Spitzen: Elber, Jancka und dann Zickler. Wir müssen nicht vergessen Zickler. Zickler ist eine + Spitzen mehr, Mehmet eh mehr Basler. + </p> + <table class="default"> + <thead> + <tr> + <th>Aufgabe</th> + <th>Bearbeitungszeit</th> + <th><span class="sr-only">Aktionen</span></th> + </tr> + </thead> + <tbody> + <tr v-for="review in peerReviews" :key="review.id"> + <td> + <a :href="elementUrls[review.id]"> + {{ taskGroups[review.id].attributes.title }} + </a> + </td> + + <td> + <StudipDate :date="new Date(processes[review.id].attributes['review-start'])" /> + – + <StudipDate :date="new Date(processes[review.id].attributes['review-end'])" /> + </td> + <td class="actions">...</td> + </tr> + </tbody> + </table> + </div> +</template> + +<script> +import { mapActions, mapGetters } from 'vuex'; +import StudipDate from '../../../StudipDate.vue'; +import taskHelper from '../../../../mixins/courseware/task-helper.js'; + +export default { + components: { StudipDate }, + props: {}, + mixins: [taskHelper], + computed: { + ...mapGetters({ + context: 'context', + relatedPeerReviewProcesses: 'courseware-peer-review-processes/related', + relatedPeerReviews: 'courseware-peer-reviews/related', + relatedStructuralElement: 'courseware-structural-elements/related', + relatedTask: 'courseware-tasks/related', + relatedTaskGroups: 'courseware-task-groups/related', + }), + elementUrls() { + return this.peerReviews.reduce((memo, review) => { + const task = this.tasks[review.id]; + const element = this.relatedStructuralElement({ parent: task, relationship: 'structural-element' }); + memo[review.id] = this.getLinkToElement(element); + return memo; + }, {}); + }, + peerReviews() { + const course = { type: 'courses', id: this.context.id }; + return this.relatedPeerReviews({ parent: course, relationship: 'courseware-peer-reviews' }); + }, + processes() { + return this.peerReviews.reduce((memo, review) => { + memo[review.id] = this.relatedPeerReviewProcesses({ parent: review, relationship: 'process' }); + return memo; + }, {}); + }, + taskGroups() { + return this.peerReviews.reduce((memo, review) => { + const process = this.processes[review.id]; + memo[review.id] = this.relatedTaskGroups({ parent: process, relationship: 'task-group' }); + return memo; + }, {}); + }, + tasks() { + return this.peerReviews.reduce((memo, review) => { + memo[review.id] = this.relatedTask({ parent: review, relationship: 'task' }); + return memo; + }, {}); + }, + }, + methods: { + ...mapActions({ + loadRelatedPeerReviews: 'courseware-peer-reviews/loadRelated', + }), + }, + mounted() { + const parent = { type: 'courses', id: this.context.id }; + const relationship = 'courseware-peer-reviews'; + const options = { + include: 'process,task.structural-element,task.task-group', + }; + this.loadRelatedPeerReviews({ parent, relationship, options }); + }, +}; +</script> diff --git a/resources/vue/components/courseware/tasks/peer-review/PeerReviewList.vue b/resources/vue/components/courseware/tasks/peer-review/PeerReviewList.vue new file mode 100644 index 0000000000000000000000000000000000000000..ffd140b9c2d806360e6f39f31c323abd2d1feca1 --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/PeerReviewList.vue @@ -0,0 +1,53 @@ +<template> + <div v-if="peerReviews && peerReviews.length > 0"> + <table class="default"> + <thead> + <tr> + <th>{{ $gettext("Aufgabe") }}</th> + <th>{{ $gettext("verfasst von") }}</th> + <th>{{ $gettext("begutachtet von") }}</th> + <th> </th> + </tr> + </thead> + <tbody> + <PeerReviewListItem + v-for="review in peerReviews" + :review="review" + :key="review.id" + :process="process" + :task-group="taskGroup" + /> + </tbody> + </table> + </div> +</template> + +<script lang="ts"> +import Vue, { PropType } from 'vue'; +import { mapGetters } from 'vuex'; +import { JsonApiSchema } from './definitions'; +import PeerReviewListItem from './PeerReviewListItem.vue'; +import ProgressIndicator from '../../../StudipProgressIndicator.vue'; + +export default Vue.extend({ + components: { PeerReviewListItem, ProgressIndicator }, + props: { + process: { + type: Object as PropType<JsonApiSchema>, + required: true, + }, + taskGroup: { + type: Object as PropType<JsonApiSchema>, + required: true, + }, + }, + computed: { + ...mapGetters({ + relatedPeerReviews: 'courseware-peer-reviews/related', + }), + peerReviews() { + return this.relatedPeerReviews({ parent: this.process, relationship: 'peer-reviews' }); + }, + }, +}); +</script> diff --git a/resources/vue/components/courseware/tasks/peer-review/PeerReviewListItem.vue b/resources/vue/components/courseware/tasks/peer-review/PeerReviewListItem.vue new file mode 100644 index 0000000000000000000000000000000000000000..2ef2ebd48f88318a7604037e023556934aebb2d2 --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/PeerReviewListItem.vue @@ -0,0 +1,118 @@ +<template> + <tr> + <td> + <a :href="getLinkToElement(element)"> + {{ taskGroup.attributes.title }} + </a> + </td> + <td> + <a v-if="isUser(submitter)" :href="userProfile(submitter)"> + <UserAvatar + :avatar-url="submitter.meta.avatar.small" + :formatted-name="submitter.attributes['formatted-name']" + /> + </a> + <a v-else :href="statusGroupUrl(submitter)"> + {{ submitter.attributes.name }} + </a> + </td> + <td> + <a v-if="isUser(submitter)" :href="userProfile(reviewer)"> + <UserAvatar + :avatar-url="reviewer.meta.avatar.small" + :formatted-name="reviewer.attributes['formatted-name']" + /> + </a> + <a v-else :href="statusGroupUrl(reviewer)"> + {{ reviewer.attributes.name }} + </a> + </td> + <td> + <button class="button" @click="onShowAssessment" :disabled="canShowReview">{{ $gettext('Gutachten anzeigen') }}</button> + </td> + </tr> +</template> + +<script> +import { mapGetters } from 'vuex'; +import UserAvatar from '@/vue/components/StudipUserAvatar.vue'; +import taskHelper from '../../../../mixins/courseware/task-helper.js'; +import { getProcessStatus, ProcessStatus } from './definitions'; + +export default { + mixins: [taskHelper], + props: { + process: { + type: Object, + required: true, + }, + review: { + type: Object, + required: true, + }, + taskGroup: { + type: Object, + required: true, + }, + }, + components: { UserAvatar }, + computed: { + ...mapGetters({ + context: 'context', + relatedStructuralElement: 'courseware-structural-elements/related', + relatedTasks: 'courseware-tasks/related', + relatedStatusGroups: 'status-groups/related', + relatedUsers: 'users/related', + }), + canShowReview() { + return getProcessStatus(this.process).status !== ProcessStatus.After; + }, + element() { + const parent = { id: this.task.id, type: this.task.type }; + const relationship = 'structural-element'; + return this.relatedStructuralElement({ parent, relationship }); + }, + reviewer() { + const user = this.relatedUsers({ parent: this.review, relationship: 'reviewer' }); + if (user) { + return user; + } + const statusGroup = this.relatedStatusGroups({ parent: this.review, relationship: 'reviewer' }); + return statusGroup; + }, + submitter() { + const user = this.relatedUsers({ parent: this.task, relationship: 'solver' }); + if (user) { + return user; + } + const statusGroup = this.relatedStatusGroups({ parent: this.task, relationship: 'solver' }); + return statusGroup; + }, + task() { + const parent = { id: this.review.id, type: this.review.type }; + const relationship = 'task'; + return this.relatedTasks({ parent, relationship }); + }, + }, + methods: { + isUser(object) { + return object.type === 'users'; + }, + onShowAssessment() { + console.debug('NYI'); + }, + statusGroupUrl(statusGroup) { + const cid = this.context.id; + return window.STUDIP.URLHelper.getURL( + 'dispatch.php/course/statusgroups', + { cid, contentbox_open: statusGroup.id }, + true + ); + }, + userProfile(user) { + const username = user.attributes.username; + return window.STUDIP.URLHelper.getURL('dispatch.php/profile', { username }, true); + }, + }, +}; +</script> diff --git a/resources/vue/components/courseware/tasks/peer-review/ProcessConfiguration.vue b/resources/vue/components/courseware/tasks/peer-review/ProcessConfiguration.vue new file mode 100644 index 0000000000000000000000000000000000000000..637752800340e47eb526ecb91bcf77e4455afca8 --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/ProcessConfiguration.vue @@ -0,0 +1,40 @@ +<template> + <ul> + <li v-if="options.anonymous">{{ $gettext('Anonymes Review') }}</li> + <li v-else>{{ $gettext('Offenes Review') }}</li> + + <li> + {{ + $gettextInterpolate($gettext('%{n} Tage Zeit für das Review'), { + n: options.duration, + }) + }} + </li> + + <li> + {{ reviewTypes[options.type].long }} + </li> + + <li v-if="options.automaticPairing"> + {{ $gettext('Zusammenstellung der Review-Paarungen durch das Programm') }} + </li> + <li v-else>{{ $gettext('Zusammenstellung der Review-Paarungen durch die Lehrenden') }}</li> + </ul> +</template> + +<script lang="ts"> +import Vue, { PropType } from 'vue'; +import { ProcessConfiguration, ASSESSMENT_TYPES } from './process-configuration'; + +export default Vue.extend({ + props: { + options: { + required: true, + type: Object as PropType<ProcessConfiguration>, + }, + }, + computed: { + reviewTypes: () => ASSESSMENT_TYPES, + }, +}); +</script> diff --git a/resources/vue/components/courseware/tasks/peer-review/ProcessCreateDialog.vue b/resources/vue/components/courseware/tasks/peer-review/ProcessCreateDialog.vue new file mode 100644 index 0000000000000000000000000000000000000000..3bd551e7b58de10b7d1ab4b1d64e85254063e4c5 --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/ProcessCreateDialog.vue @@ -0,0 +1,65 @@ +<template> + <StudipDialog + :title="$gettext('Peer-Review-Prozess anlegen')" + :confirmText="$gettext('Anlegen')" + confirmClass="accept" + :confirmDisabled="!changed" + :closeText="$gettext('Schließen')" + closeClass="cancel" + height="600" + width="800" + @close="$emit('close')" + @confirm="create" + > + <template #dialogContent> + <header> + <h2> + {{ + $gettextInterpolate($gettext('Aufgabe "%{title}"'), { + title: taskGroup.attributes.title, + }) + }} + <span> + (<StudipDate :date="new Date(taskGroup.attributes['start-date'])" /> + – + <StudipDate :date="new Date(taskGroup.attributes['end-date'])" />) + </span> + </h2> + </header> + + <ProcessCreateForm :configuration="configuration" @update="updateConfiguration" /> + </template> + </StudipDialog> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import StudipDate from '../../../StudipDate.vue'; +import StudipDialog from '../../../StudipDialog.vue'; +import ProcessCreateForm from './ProcessCreateForm.vue'; +import { defaultConfiguration, ProcessConfiguration } from './process-configuration'; + +export default Vue.extend({ + components: { ProcessCreateForm, StudipDate, StudipDialog }, + props: ['taskGroup'], + data: () => ({ + changed: false, + configuration: defaultConfiguration(), + }), + methods: { + create() { + this.$emit('create', { ...this.configuration }); + }, + updateConfiguration(configuration: ProcessConfiguration) { + this.changed = true; + this.configuration = configuration; + }, + }, +}); +</script> + +<style scoped> +header { + margin-block-end: 2rem; +} +</style> diff --git a/resources/vue/components/courseware/tasks/peer-review/ProcessCreateForm.vue b/resources/vue/components/courseware/tasks/peer-review/ProcessCreateForm.vue new file mode 100644 index 0000000000000000000000000000000000000000..0bf0b6482db0803889f2b39c3fa11c7054d97a1f --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/ProcessCreateForm.vue @@ -0,0 +1,258 @@ +<template> + <form class="default" @submit.prevent=""> + <div class="peer-review-process-create-form-switcher"> + <button + class="button" + :class="{ active: !showCustomConfiguration }" + @click="showCustomConfiguration = false" + > + {{ $gettext('Einfach') }} + </button> + <button class="button" :class="{ active: showCustomConfiguration }" @click="showCustomConfiguration = true"> + {{ $gettext('Erweitert') }} + </button> + </div> + + <section class="peer-review-process-create-form-type-cards" v-if="!showCustomConfiguration"> + <article + v-for="(configurationSet, index) in configurationSets" + :key="index" + :class="{ selected: selectedConfigurationSet === index }" + > + <h2>{{ configurationSet.name }}</h2> + + <button + class="button" + :class="{ accept: selectedConfigurationSet === index }" + :disabled="selectedConfigurationSet === index" + type="button" + @click="selectConfigurationSet(index)" + > + {{ selectedConfigurationSet === index ? $gettext('Ausgewählt') : $gettext('Auswählen') }} + </button> + + <PeerReviewProcessConfiguration :options="configurationSet.configuration" /> + </article> + </section> + + <ContentBox + v-else + class="peer-review-process-create-form-custom-configuration" + :title="$gettext('Erweiterte Einstellungen')" + > + <div class="custom-configuration"> + <div class="formpart"> + <LabelRequired + :id="`peer-review-process-create-form-${uid}-anonymous`" + :label="$gettext('Anonymes oder offenes Review:')" + > + <select + v-model="localConfiguration.anonymous" + :id="`peer-review-process-create-form-${uid}-anonymous`" + @change="customizeConfiguration" + > + <option :value="true">{{ $gettext('anonym') }}</option> + <option :value="false">{{ $gettext('offen') }}</option> + </select> + </LabelRequired> + </div> + + <div class="formpart"> + <LabelRequired + :id="`peer-review-process-create-form-${uid}-duration`" + :label="$gettext('Bearbeitungszeitraum in Tagen:')" + > + <select + v-model.number="localConfiguration.duration" + :id="`peer-review-process-create-form-${uid}-duration`" + @change="customizeConfiguration" + > + <option v-for="i in 21" :key="i">{{ i }}</option> + </select> + </LabelRequired> + </div> + + <div class="formpart"> + <LabelRequired + :id="`peer-review-process-create-form-${uid}-type`" + :label="$gettext('Art des Reviews:')" + > + <select + v-model="localConfiguration.type" + :id="`peer-review-process-create-form-${uid}-type`" + @change="onChangeType" + > + <option v-for="[key, { short }] in Object.entries(reviewTypes)" :key="key" :value="key"> + {{ short }} + </option> + </select> + </LabelRequired> + </div> + + <div class="formpart"> + <LabelRequired + :id="`peer-true-process-create-form-${uid}-anonymous`" + :label="$gettext('Review-Paarungen')" + > + <select + v-model="localConfiguration.automaticPairing" + :id="`peer-review-process-create-form-${uid}-automatic-pairing`" + @change="customizeConfiguration" + > + <option :value="true">{{ $gettext('Zufall') }}</option> + <option :value="false">{{ $gettext('Manuell') }}</option> + </select> + </LabelRequired> + </div> + </div> + </ContentBox> + </form> +</template> + +<script lang="ts"> +import Vue, { PropType } from 'vue'; +import ContentBox from '../../../StudipContentBox.vue'; +import LabelRequired from '../../../forms/LabelRequired.vue'; +import PeerReviewProcessConfiguration from './ProcessConfiguration.vue'; +import { ASSESSMENT_TYPES, CONFIGURATION_SETS, ProcessConfiguration } from './process-configuration'; + +let nextId = 0; + +export default Vue.extend({ + components: { ContentBox, LabelRequired, PeerReviewProcessConfiguration }, + props: { + configuration: { + required: true, + type: Object as PropType<ProcessConfiguration>, + }, + custom: { + type: Boolean, + default: false, + }, + }, + data() { + return { + localConfiguration: { ...this.configuration }, + selectedConfigurationSet: -1, + showCustomConfiguration: this.custom, + uid: nextId++, + }; + }, + computed: { + reviewTypes: () => ASSESSMENT_TYPES, + configurationSets: () => CONFIGURATION_SETS, + }, + methods: { + customizeConfiguration() { + this.selectedConfigurationSet = -1; + this.update(); + }, + onChangeType() { + this.localConfiguration.payload = + this.localConfiguration.type === this.configuration.type + ? this.configuration.payload + : ASSESSMENT_TYPES[this.localConfiguration.type].defaultPayload; + this.customizeConfiguration(); + }, + resetData() { + this.localConfiguration = { ...this.configuration }; + }, + selectConfigurationSet(configurationSetIndex: number) { + this.selectedConfigurationSet = configurationSetIndex; + this.localConfiguration = CONFIGURATION_SETS[configurationSetIndex].configuration; + this.update(); + }, + update() { + this.$emit('update', this.localConfiguration); + }, + }, + watch: { + configuration() { + this.resetData(); + }, + }, +}); +</script> + +<style scoped lang="scss"> +.peer-review-process-create-form-type-cards { + box-sizing: border-box; + width: 100%; + margin-block: 1.5rem 0; + + display: flex; + flex-wrap: wrap; + gap: 1.5rem; + --threshold: 45rem; + + article { + flex-grow: 1; + flex-basis: calc((var(--threshold) - 100%) * 999); + box-sizing: border-box; + padding: 1rem; + border: 2px var(--dark-gray-color-20) solid; + + &.selected { + border-color: var(--dark-gray-color-80); + border-width: 2px; + } + + h2 { + font-weight: bold; + font-size: 1.2rem; + margin-block: 1rem 0; + } + button { + margin-block: 1.5rem; + } + ul { + padding-inline: 1em 0; + } + li { + padding-block: 0.5rem; + } + } + + > :nth-last-child(n + 4), + > :nth-last-child(n + 4) ~ * { + flex-basis: 100%; + } +} + +.peer-review-process-create-form-type-cards + section { + text-align: center; + margin-block-end: 1.5rem; +} + +.peer-review-process-create-form-custom-configuration { + margin-block: 1.5rem; +} + +.custom-configuration { + padding: 1rem; +} + +.peer-review-process-create-form-switcher { + display: flex; + justify-content: center; +} + +.peer-review-process-create-form-switcher button { + margin: 0; +} + +.peer-review-process-create-form-switcher button + button { + border-left: none; +} + +.peer-review-process-create-form-switcher button.active { + background: var(--base-color); + color: var(--white); + cursor: default; +} + +.peer-review-process-create-form-switcher button:not(.active):hover { + background: var(--white); + color: var(--base-color); +} +</style> diff --git a/resources/vue/components/courseware/tasks/peer-review/ProcessDurationDialog.vue b/resources/vue/components/courseware/tasks/peer-review/ProcessDurationDialog.vue new file mode 100644 index 0000000000000000000000000000000000000000..af6756ff4bbca7793ddf7a26925ccf76f24946b7 --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/ProcessDurationDialog.vue @@ -0,0 +1,122 @@ +<template> + <StudipDialog + v-if="show && process" + :title="$gettext('Bearbeitungszeit ändern')" + :confirmText="$gettext('Speichern')" + confirmClass="accept" + :closeText="$gettext('Schließen')" + closeClass="cancel" + @close="onClose" + @confirm="onConfirm" + > + <template #dialogContent> + <form class="default"> + <p> + {{ $gettext('Aktuelle Bearbeitungszeit:') }} <StudipDate :date="startDate" />–<StudipDate :date="endDate" /> + ({{ $gettextInterpolate($gettext('%{ count } Tage'), { count: oldDuration }) }}) + </p> + <div class="formpart"> + <LabelRequired + :id="`peer-review-process-${uid}`" + :label="$gettext('Bearbeitungszeit verlängern bis zum:')" + /> + <input + :id="`peer-review-process-${uid}`" + name="end-date" + type="date" + v-model="localEndDate" + :min="endDateString" + class="size-l" + required + /> + <div>({{ $gettextInterpolate($gettext('%{ count } Tage'), { count: newDuration }) }})</div> + </div> + </form> + </template> + </StudipDialog> +</template> + +<script> +import { mapGetters } from 'vuex'; +import LabelRequired from '../../../forms/LabelRequired.vue'; +import StudipDate from '../../../StudipDate.vue'; +import StudipDialog from '../../../StudipDialog.vue'; + +const midnight = (_date) => { + const date = new Date(_date); + date.setHours(0); + date.setMinutes(0); + date.setSeconds(0); + date.setMilliseconds(0); + return date; +}; + +const dateString = (date) => + `${date.getFullYear()}-${('' + (date.getMonth() + 1)).padStart(2, '0')}-${('' + date.getDate()).padStart(2, '0')}`; + +let nextUid = 0; + +export default { + model: { + prop: 'show', + event: 'updateShow', + }, + components: { + LabelRequired, + StudipDate, + StudipDialog, + }, + props: { + show: { + type: Boolean, + required: true, + }, + process: { + type: Object, + default: null, + }, + }, + data: () => ({ localEndDate: null, uid: nextUid++ }), + computed: { + configuration() { + return this.process?.attributes?.configuration ?? {}; + }, + endDate() { + return midnight(this.process?.attributes?.['review-end'] ?? new Date()); + }, + endDateString() { + return dateString(this.endDate); + }, + newDuration() { + return this.localEndDate + ? Math.floor((midnight(this.localEndDate) - midnight(this.startDate)) / (1000 * 60 * 60 * 24)) + : 0; + }, + oldDuration() { + return this.configuration.duration ?? '??'; + }, + startDate() { + return midnight(this.process.attributes['review-start']); + }, + }, + methods: { + onClose() { + this.$emit('updateShow', false); + }, + onConfirm(...args) { + this.$emit('update', this.newDuration); + }, + resetLocalVars() { + this.localEndDate = dateString(this.endDate ?? new Date()); + }, + }, + mounted() { + this.resetLocalVars(); + }, + watch: { + process() { + this.resetLocalVars(); + }, + }, +}; +</script> diff --git a/resources/vue/components/courseware/tasks/peer-review/ProcessEditDialog.vue b/resources/vue/components/courseware/tasks/peer-review/ProcessEditDialog.vue new file mode 100644 index 0000000000000000000000000000000000000000..56a8cb74b67311a724699df6a4c8bdc958e6bede --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/ProcessEditDialog.vue @@ -0,0 +1,65 @@ +<template> + <StudipDialog + :title="title" + :confirmText="$gettext('Speichern')" + confirmClass="accept" + :confirmDisabled="!changed" + :closeText="$gettext('Schließen')" + closeClass="cancel" + height="600" + width="800" + @close="$emit('close')" + @confirm="confirm" + > + <template #dialogContent> + <ProcessCreateForm :configuration="process.attributes.configuration" custom @update="updateConfiguration" /> + </template> + </StudipDialog> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { mapGetters } from 'vuex'; +import { $gettext, $gettextInterpolate } from '../../../../../assets/javascripts/lib/gettext'; +import StudipDialog from '../../../StudipDialog.vue'; +import ProcessCreateForm from './ProcessCreateForm.vue'; +import { defaultConfiguration, ProcessConfiguration } from './process-configuration'; + +export default Vue.extend({ + components: { ProcessCreateForm, StudipDialog }, + props: ['process'], + data: () => ({ + changed: false, + configuration: defaultConfiguration(), + }), + computed: { + ...mapGetters({ + relatedTaskGroups: 'courseware-task-groups/related', + }), + title() { + const taskGroup = this.relatedTaskGroups({ parent: this.process, relationship: 'task-group' }); + return $gettextInterpolate($gettext('Peer-Review-Prozess konfigurieren zur Aufgabe "%{title}"'), { + title: taskGroup.attributes.title, + }); + }, + }, + methods: { + confirm() { + this.$emit('update', { + process: this.process, + configuration: { ...this.configuration }, + }); + }, + updateConfiguration(configuration: ProcessConfiguration) { + this.changed = true; + this.configuration = configuration; + }, + }, +}); +</script> + +<style scoped> +header { + margin-block-end: 2rem; +} +</style> diff --git a/resources/vue/components/courseware/tasks/peer-review/ProcessStatus.vue b/resources/vue/components/courseware/tasks/peer-review/ProcessStatus.vue new file mode 100644 index 0000000000000000000000000000000000000000..faf366634aebfaa9faef3397b90cc7b88bcd208e --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/ProcessStatus.vue @@ -0,0 +1,40 @@ +<template> + <span class="peer-review-process-status"> + <StudipIcon + v-if="status.shape !== undefined" + :shape="status.shape" + :role="status.role" + :title="status.description" + aria-hidden="true" + /> + <span class="sr-only">{{ status.description }}</span> + </span> +</template> +<script lang="ts"> +import Vue, { PropType } from 'vue'; +import StudipIcon from '../../../StudipIcon.vue'; +import { JsonApiSchema, getProcessStatus } from './definitions'; + +export default Vue.extend({ + components: { StudipIcon }, + props: { + process: { + type: Object as PropType<JsonApiSchema>, + required: true, + }, + }, + computed: { + status() { + return getProcessStatus(this.process); + }, + }, +}); +</script> + +<style scoped> +.peer-review-process-status { + display: flex; + align-items: center; + gap: 0.5rem; +} +</style> diff --git a/resources/vue/components/courseware/tasks/peer-review/ProcessesList.vue b/resources/vue/components/courseware/tasks/peer-review/ProcessesList.vue new file mode 100644 index 0000000000000000000000000000000000000000..b808c68e0e39870fc706e3ad40c8a951fdb46147 --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/ProcessesList.vue @@ -0,0 +1,88 @@ +<template> + <table class="default"> + <thead> + <tr> + <th>{{ $gettext('Status') }}</th> + <th>{{ $gettext('Titel') }}</th> + <th>{{ $gettext('Bearbeitungszeit') }}</th> + </tr> + </thead> + <tbody> + <tr v-for="process in sortedProcesses" :key="process.id"> + <td> + <ProcessStatus :process="process" /> + </td> + <td> + <router-link :to="{ name: 'peer-review-processes-show', params: { id: process.id } }"> + {{ + $gettextInterpolate($gettext('Peer-Review-Prozess zur Aufgabe "%{ title }"'), { + title: taskTitle(process), + }) + }} + </router-link> + </td> + <td><StudipDate :date="getStartDate(process)" />–<StudipDate :date="getEndDate(process)" /></td> + </tr> + </tbody> + </table> +</template> + +<script> +import _ from 'lodash'; +import { mapActions, mapGetters } from 'vuex'; +import ProcessStatus from './ProcessStatus.vue'; +import StudipDate from '../../../StudipDate.vue'; + +export default { + components: { + ProcessStatus, + StudipDate, + }, + computed: { + ...mapGetters({ + context: 'context', + relatedProcesses: 'courseware-peer-review-processes/related', + relatedTaskGroups: 'courseware-task-groups/related', + taskGroups: 'courseware-task-groups/all', + }), + sortedProcesses() { + return _.sortBy(this.processes, [({ attributes }) => -new Date(attributes['review-end'])]); + }, + processes() { + return this.taskGroups.reduce((memo, parent) => { + const result = memo.concat( + this.relatedProcesses({ parent, relationship: 'peer-review-processes' }) ?? [] + ); + return result; + }, []); + }, + }, + methods: { + ...mapActions({ + loadCourseMemberships: 'course-memberships/loadRelated', + loadCourseStatusGroups: 'status-groups/loadRelated', + loadTasks: 'tasks/loadTasksOfCourse', + }), + getEndDate(process) { + return new Date(process.attributes['review-end']); + }, + getStartDate(process) { + return new Date(process.attributes['review-start']); + }, + taskTitle(process) { + const taskGroup = this.relatedTaskGroups({ parent: process, relationship: 'task-group' }); + return taskGroup?.attributes.title ?? ''; + }, + }, + mounted() { + this.loadTasks({ cid: this.context.id }); + const parent = { type: 'courses', id: this.context.id }; + this.loadCourseMemberships({ + parent, + relationship: 'memberships', + options: { include: 'user', 'page[offset]': 0, 'page[limit]': 10000, 'filter[permission]': 'autor' }, + }); + this.loadCourseStatusGroups({ parent, relationship: 'status-groups' }); + }, +}; +</script> diff --git a/resources/vue/components/courseware/tasks/peer-review/ProcessesListItem.vue b/resources/vue/components/courseware/tasks/peer-review/ProcessesListItem.vue new file mode 100644 index 0000000000000000000000000000000000000000..0a17785803131b2b226829670204895d1eeac54b --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/ProcessesListItem.vue @@ -0,0 +1,230 @@ +<template> + <StudipArticle collapsable closed class="cw-peer-review-processes-list-item"> + <template #title> + <ProcessStatus :process="process" /> + <span class="peer-review-process-title"> + {{ + $gettextInterpolate($gettext('Peer-Review-Prozess zur Aufgabe "%{ title }"'), { + title: taskGroup.attributes.title, + }) + }} + </span> + </template> + + <template #titleplus> + <span> {{ $gettext('Bearbeitungszeit:') }} <StudipDate :date="startDate" />–<StudipDate :date="endDate" /> </span> + </template> + + <template #body> + <section> + <article> + <header> + <h2>Status</h2> + </header> + <div> + <ProcessStatus :process="process" /> + {{ processStatus.description }} + </div> + </article> + <article> + <header> + <h2>Owner</h2> + </header> + <div> + <span> + <img :src="owner.meta.avatar.small" /> + </span> + {{ owner.attributes['formatted-name'] }} + </div> + </article> + <article> + <header> + <h2>Aufgabe</h2> + </header> + <div> + <a :href="`#task-groups-${taskGroup.id}`">{{ taskGroup.attributes.title }}</a> + <StudipDate :date="new Date(taskGroup.attributes['start-date'])" />–<StudipDate :date="new Date(taskGroup.attributes['end-date'])" /> + </div> + </article> + <article> + <header> + <h2>{{ $gettext('Einstellungen') }}</h2> + </header> + <div v-if="!canChangeConfiguration"> + <StudipMessageBox hideClose> + {{ + $gettext( + 'Der Peer-Review-Prozess hat bereits begonnen. Bis auf die Bearbeitungsdauer können die Einstellungen nicht geändert werden.' + ) + }} + </StudipMessageBox> + </div> + <div> + <ProcessConfiguration :options="configuration" /> + </div> + <div v-if="canChangeConfiguration"> + <button class="button" @click="onShowConfiguration"> + {{ $gettext('Einstellungen ändern') }} + </button> + <div v-if="configuration.type === 'form' || configuration.type === 'table'"> + <button class="button" @click="onShowAssessmentTypeEditor"> + <span v-if="configuration.type === 'form'"> + {{ $gettext('Formular ändern') }} + </span> + <span v-if="configuration.type === 'table'"> + {{ $gettext('Tabelle ändern') }} + </span> + </button> + </div> + </div> + <div v-if="canChangeDurationOnly"> + <button class="button" @click="onShowDuration">{{ $gettext('Bearbeitungszeit verlängern') }}</button> + </div> + </article> + + <StudipArticle collapsable closed> + <template #title> + {{ $gettext('Peer-Reviews') }} + </template> + <template #body> + <PeerReviewList v-if="!isBefore" :process="process" :task-group="taskGroup" /> + <template v-else> + <CompanionBox + v-if="isAutomaticPairing" + :msgCompanion=" + $gettext( + 'In diesem Peer-Review-Prozess werden die Paarungen automatisch verteilt, sobald der Bearbeitungszeitraum beginnt.' + ) + " + > + </CompanionBox> + <CompanionBox + v-else + mood="pointing" + :msgCompanion=" + $gettext( + 'In diesem Peer-Review-Prozess werden die Paarungen manuell verteilt, bevor der Bearbeitungszeitraum beginnt.' + ) + " + > + <template #companionActions> + <button class="button" @click="$emit('showPairingEditor', process)"> + {{ $gettext('Paarungen manuell festlegen') }} + </button> + </template> + </CompanionBox> + </template> + </template> + </StudipArticle> + + <StudipArticle collapsable closed> + <template #title> Solvers </template> + <template #body> + <pre>{{ solvers }}</pre> + </template> + </StudipArticle> + </section> + </template> + </StudipArticle> +</template> + +<script> +import { mapGetters } from 'vuex'; +import StudipArticle from '../../../StudipArticle.vue'; +import StudipDate from '../../../StudipDate.vue'; +import StudipMessageBox from '../../../StudipMessageBox.vue'; +import CompanionBox from '../../layouts/CoursewareCompanionBox.vue'; +import ProcessConfiguration from './ProcessConfiguration.vue'; +import ProcessStatus from './ProcessStatus.vue'; +import PeerReviewList from './PeerReviewList.vue'; +import { getProcessStatus, ProcessStatus as Status } from './definitions'; + +export default { + components: { + CompanionBox, + PeerReviewList, + ProcessConfiguration, + ProcessStatus, + StudipArticle, + StudipDate, + StudipMessageBox, + }, + props: { + process: { + type: Object, + required: true, + }, + }, + computed: { + ...mapGetters({ + relatedPeerReviews: 'courseware-peer-reviews/related', + relatedTasks: 'courseware-tasks/related', + relatedTaskGroups: 'courseware-task-groups/related', + relatedUsers: 'users/related', + }), + canChangeConfiguration() { + return this.isBefore; + }, + canChangeDurationOnly() { + return this.processStatus.status === Status.Active; + }, + configuration() { + return this.process.attributes['configuration']; + }, + endDate() { + return new Date(this.process.attributes['review-end']); + }, + isBefore() { + return this.processStatus.status === Status.Before; + }, + isAutomaticPairing() { + return this.configuration.automaticPairing; + }, + owner() { + return this.relatedUsers({ parent: this.process, relationship: 'owner' }); + }, + peerReviews() { + const result = this.relatedPeerReviews({ parent: this.process, relationship: 'peer-reviews' }); + console.debug(result); + return result; + }, + processStatus() { + return getProcessStatus(this.process); + }, + solvers() { + return this.taskGroup.relationships.solvers.data.map(({ id, type }) => { + return [id, type]; + }); + }, + startDate() { + return new Date(this.process.attributes['review-start']); + }, + taskGroup() { + return this.relatedTaskGroups({ parent: this.process, relationship: 'task-group' }); + }, + tasks() { + return this.relatedTasks({ parent: this.taskGroup, relationship: 'tasks' }); + }, + }, + methods: { + onShowAssessmentTypeEditor() { + this.$emit('showAssessmentTypeEditor', this.process); + }, + onShowConfiguration() { + this.$emit('showConfiguration', this.process); + }, + onShowDuration() { + this.$emit('showDuration', this.process); + }, + }, +}; +</script> + +<style> +.cw-peer-review-processes-list-item:target { + border-color: var(--base-color); +} +.peer-review-process-title { + margin-inline-start: 0.5rem; +} +</style> diff --git a/resources/vue/components/courseware/tasks/peer-review/definitions.ts b/resources/vue/components/courseware/tasks/peer-review/definitions.ts new file mode 100644 index 0000000000000000000000000000000000000000..2e6729edf4430a293258bad2909c9ecb5e9a6503 --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/definitions.ts @@ -0,0 +1,57 @@ +import { $gettext } from '../../../../../assets/javascripts/lib/gettext'; + +export enum ProcessStatus { + Before = 'before', + After = 'after', + Active = 'active', +} + +export interface StatusDescriptor { + status: ProcessStatus; + shape: string; + role: string; + description: string; +} + +interface StringDict { + [key: string]: string; +} + +export interface JsonApiSchema { + id?: string; + type: string; + attributes: StringDict; + meta?: StringDict; + relationships?: StringDict; +} + +export function getProcessStatus(process: JsonApiSchema): StatusDescriptor { + const now = new Date(); + const startDate = new Date(process.attributes['review-start']); + const endDate = new Date(process.attributes['review-end']); + + if (now < startDate) { + return { + status: ProcessStatus.Before, + shape: 'span-empty', + role: 'status-yellow', + description: $gettext('Peer-Review-Process noch nicht aktiv'), + }; + } + + if (endDate < now) { + return { + status: ProcessStatus.After, + shape: 'span-full', + role: 'status-red', + description: $gettext('Peer-Review-Process beendet'), + }; + } + + return { + status: ProcessStatus.Active, + shape: 'span-empty', + role: 'status-green', + description: $gettext('Peer-Review-Prozess aktiv'), + }; +} diff --git a/resources/vue/components/courseware/tasks/peer-review/process-configuration.ts b/resources/vue/components/courseware/tasks/peer-review/process-configuration.ts new file mode 100644 index 0000000000000000000000000000000000000000..04b952a847775fdb0d6d56dcbcada076d5f621df --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/process-configuration.ts @@ -0,0 +1,129 @@ +import { $gettext } from '../../../../../assets/javascripts/lib/gettext'; + +export enum AssessmentType { + Form = 'form', + Freetext = 'freetext', + Table = 'table', +} + +export interface EditorFormCriterium { + text: string; + description: string; +} + +export interface EditorTableCriterium { + text: string; +} + +export type FormAssessmentPayload = { criteria: EditorFormCriterium[] }; +export type TableAssessmentPayload = { criteria: EditorTableCriterium[] }; +export type FreetextAssessmentPayload = {}; + +export type ProcessConfigurationPayload = FormAssessmentPayload | FreetextAssessmentPayload | TableAssessmentPayload; + +export interface ProcessConfiguration { + anonymous: boolean; + duration: number; + automaticPairing: boolean; + type: AssessmentType; + payload?: ProcessConfigurationPayload; +} + +export interface ConfigurationSet { + name: string; + configuration: ProcessConfiguration; +} + +export const ASSESSMENT_TYPES = { + [AssessmentType.Form]: { + short: $gettext('Formular'), + long: $gettext('Strukturiertes Bewertungssystem mit detailierten Fragen zur Begutachtung'), + defaultPayload: { criteria: defaultCriteriaForm() }, + }, + [AssessmentType.Freetext]: { + short: $gettext('Freitext'), + long: $gettext('Freitextliche Begutachtung'), + defaultPayload: { }, + }, + [AssessmentType.Table]: { + short: $gettext('Tabelle'), + long: $gettext('Einfaches Bewertungssystem mit 3 Bewertungsnoten und kurzer Erläuterung'), + defaultPayload: { criteria: defaultCriteriaTable() }, + }, +}; + +export const CONFIGURATION_SETS: Array<ConfigurationSet> = [ + { + name: $gettext('Kurz und bündig'), + configuration: { + anonymous: true, + duration: 7, + automaticPairing: true, + type: AssessmentType.Table, + payload: ASSESSMENT_TYPES[AssessmentType.Table].defaultPayload, + }, + }, + { + name: $gettext('Strukturiert begleitet'), + configuration: { + anonymous: true, + duration: 7, + automaticPairing: true, + type: AssessmentType.Form, + payload: ASSESSMENT_TYPES[AssessmentType.Form].defaultPayload, + }, + }, + { + name: $gettext('Selbstbestimmt'), + configuration: { + anonymous: true, + duration: 7, + automaticPairing: true, + type: AssessmentType.Freetext, + payload: ASSESSMENT_TYPES[AssessmentType.Freetext].defaultPayload, + }, + }, +]; + +export function defaultConfiguration(): ProcessConfiguration { + return CONFIGURATION_SETS[0].configuration; +} + +function defaultCriteriaForm() { + return [ + { + text: $gettext('Aufbau'), + description: $gettext( + 'Wo sind die grundlegenden Abschnitte (Einführung, Schlussfolgerung, Literatur, Zitate, usw.) und sind sie angemessen? Wenn nicht, was fehlt?\nHat der Schreiber verschiedene Überschriftenstile verwendet um die Abschnitte klar zu kennzeichnen? Kurze Erklärung.\nWie wurde der Inhalt geordnet? War er logisch, klar und leicht zu folgen? Kurze Erklärung.' + ), + }, + { + text: $gettext('Grammatik und Stil'), + description: $gettext( + 'Gibt es grammatische oder orthografische Probleme?\nIst der Schreibstil klar? Sind die Absätze und die enthaltenen Sätze zusammengehörig?' + ), + }, + { + text: $gettext('Inhalt'), + description: $gettext( + 'Hat der Autor hinreichend verdichtet und die Aufgabe diskutiert? Kurze Erklärung.\nHat der Autor umfassend Material aus Standardquellen benutzt? Wenn nicht, was fehlt?\nHat der Autor auch eigene Gedanken beigetragen, oder hat er mehrheitlich Zusammenfassungen von Veröffentlichungen/Daten zusammengetragen? Kurze Erklärung.' + ), + }, + { + text: $gettext('Zitate'), + description: $gettext( + 'Hat der Autor Zitatquellen passend und korrekt angebeben? Notiere unkorrekte Formatierungen.\nSind alle Zitate auch in dem Literaturhinweis zu finden? Notiere die Unstimmigkeiten.' + ), + }, + ]; +} + +function defaultCriteriaTable() { + return [ + { text: $gettext('These: Klarheit, Bedeutung') }, + { text: $gettext('Belege: Relevanz, Glaubwürdigkeit, Aussagekraft') }, + { text: $gettext('Aufbau: Anordnung des Inhalts, Nachvollziehbarkeit') }, + { text: $gettext('Handwerk: Orthografie, Grammatik, Zeichensetzung') }, + { text: $gettext('Gesamtwirkung') }, + ]; +} diff --git a/resources/vue/components/courseware/tasks/task-groups-helper.js b/resources/vue/components/courseware/tasks/task-groups-helper.js new file mode 100644 index 0000000000000000000000000000000000000000..8a9e46972298b7b94d653eba984860bb56ba94fc --- /dev/null +++ b/resources/vue/components/courseware/tasks/task-groups-helper.js @@ -0,0 +1,31 @@ +import { $gettext } from '../../../../assets/javascripts/lib/gettext'; + +export function getStatus(taskGroup) { + const now = new Date(); + const startDate = new Date(taskGroup.attributes['start-date']); + const endDate = new Date(taskGroup.attributes['end-date']); + + if (startDate <= now && now <= endDate) { + return { + shape: 'span-3quarter', + role: 'status-green', + description: $gettext('Die Bearbeitungszeit hat begonnen.'), + }; + } + + if (now < startDate) { + return { + shape: 'span-empty', + role: 'status-yellow', + description: $gettext('Die Bearbeitungszeit hat noch nicht begonnen.'), + }; + } + + if (endDate < now) { + return { + shape: 'span-full', + role: 'status-red', + description: $gettext('Die Bearbeitungszeit ist beendet.'), + }; + } +} diff --git a/resources/vue/components/courseware/widgets/CoursewareTasksActionWidget.vue b/resources/vue/components/courseware/widgets/CoursewareTasksActionWidget.vue index c2f54e9171daca7b0658070ba4bf0d4e0435f1a7..8b5d159d59ccea248ed775eb7611480293066bff 100644 --- a/resources/vue/components/courseware/widgets/CoursewareTasksActionWidget.vue +++ b/resources/vue/components/courseware/widgets/CoursewareTasksActionWidget.vue @@ -2,6 +2,18 @@ <sidebar-widget id="courseware-action-widget" :title="$gettext('Aktionen')"> <template #content> <ul class="widget-list widget-links cw-action-widget"> + <template v-if="taskGroup"> + <li v-if="mayModifyDeadline" class="cw-action-widget-task-groups-deadline"> + <button @click="modifyDeadline(taskGroup)"> + {{ $gettext('Bearbeitungszeit verlängern') }} + </button> + </li> + <li class="cw-action-widget-task-groups-delete"> + <button @click="deleteTaskGroup(taskGroup)"> + {{ $gettext('Aufgabe löschen') }} + </button> + </li> + </template> <li class="cw-action-widget-add"> <button @click="setShowTasksDistributeDialog(true)"> {{ $gettext('Aufgabe verteilen') }} @@ -22,10 +34,29 @@ export default { components: { SidebarWidget, }, + props: ['taskGroup'], + computed: { + mayModifyDeadline() { + return this.taskGroup && new Date() < new Date(this.taskGroup.attributes['end-date']); + }, + }, methods: { ...mapActions({ - setShowTasksDistributeDialog: 'setShowTasksDistributeDialog', + deleteTaskGroup: 'tasks/setShowTaskGroupsDeleteDialog', + modifyDeadline: 'tasks/setShowTaskGroupsModifyDeadlineDialog', + setShowTasksDistributeDialog: 'tasks/setShowTasksDistributeDialog', }), - } + }, +}; +</script> + +<style scoped> +.cw-action-widget-task-groups-deadline { + background-image: url('../images/icons/blue/date.svg'); + background-size: 16px; +} +.cw-action-widget-task-groups-delete { + background-image: url('../images/icons/blue/trash.svg'); + background-size: 16px; } -</script> \ No newline at end of file +</style> diff --git a/resources/vue/components/forms/LabelRequired.vue b/resources/vue/components/forms/LabelRequired.vue new file mode 100644 index 0000000000000000000000000000000000000000..7a123776c79c2e9cf9985ef719e94f70aca01f12 --- /dev/null +++ b/resources/vue/components/forms/LabelRequired.vue @@ -0,0 +1,22 @@ +<template> + <label class="studiprequired" :for="id"> + <span class="textlabel">{{ label }}</span> + <span class="asterisk" :title="$gettext('Dies ist ein Pflichtfeld')" aria-hidden="true">*</span> + <slot></slot> + </label> +</template> + +<script> +export default { + props: { + id: { + type: String, + required: true, + }, + label: { + type: String, + required: true, + }, + }, +}; +</script> diff --git a/resources/vue/courseware-index-app.js b/resources/vue/courseware-index-app.js index 3309b55ce47f16e98983da45e904cab1d112523e..761b7f95e7987f6223f99701b3c7799acebbb3a0 100644 --- a/resources/vue/courseware-index-app.js +++ b/resources/vue/courseware-index-app.js @@ -1,6 +1,7 @@ import CoursewareModule from './store/courseware/courseware.module'; import CoursewareStructureModule from './store/courseware/structure.module'; import CoursewareStructuralElement from './components/courseware/structural-element/CoursewareStructuralElement.vue'; +import CoursewareTasksModule from './store/courseware/courseware-tasks.module'; import IndexApp from './components/courseware/IndexApp.vue'; import PluginManager from './components/courseware/plugin-manager.js'; import Vue from 'vue'; @@ -82,6 +83,7 @@ const mountApp = async (STUDIP, createApp, element) => { modules: { courseware: CoursewareModule, 'courseware-structure': CoursewareStructureModule, + 'tasks': CoursewareTasksModule, ...mapResourceModules({ names: [ 'courses', @@ -93,6 +95,8 @@ const mountApp = async (STUDIP, createApp, element) => { 'courseware-containers', 'courseware-instances', 'courseware-public-links', + 'courseware-peer-reviews', + 'courseware-peer-review-processes', 'courseware-structural-elements', 'courseware-structural-element-comments', 'courseware-structural-element-feedback', diff --git a/resources/vue/courseware-tasks-app.js b/resources/vue/courseware-tasks-app.js index 2f332466d796aba116efa98372a04ae9ba3883a9..94dfef83fe38cf19a7e656525b0c30d74535f0ce 100644 --- a/resources/vue/courseware-tasks-app.js +++ b/resources/vue/courseware-tasks-app.js @@ -1,5 +1,9 @@ -import TasksApp from './components/courseware/TasksApp.vue'; +import PeerReviewProcessesIndex from './components/courseware/tasks/peer-review/PagesProcessesIndex.vue'; +import PeerReviewProcessesShow from './components/courseware/tasks/peer-review/PagesProcessesShow.vue'; +import TaskGroupsIndex from './components/courseware/tasks/PagesTaskGroupsIndex.vue'; +import TaskGroupsShow from './components/courseware/tasks/PagesTaskGroupsShow.vue'; import { mapResourceModules } from '@elan-ev/reststate-vuex'; +import VueRouter, { RouterView } from 'vue-router'; import Vuex from 'vuex'; import CoursewareModule from './store/courseware/courseware.module'; import CoursewareTasksModule from './store/courseware/courseware-tasks.module'; @@ -17,6 +21,51 @@ const mountApp = async (STUDIP, createApp, element) => { const httpClient = getHttpClient(); + const routes = [ + { + path: '/', + name: 'task-groups-index', + component: TaskGroupsIndex, + }, + { + path: '/task-groups/:id', + name: 'task-groups-show', + component: TaskGroupsShow, + props: true, + }, + { + path: '/peer-review-processes', + name: 'peer-review-processes-index', + component: PeerReviewProcessesIndex, + }, + { + path: '/peer-review-processes/:id', + name: 'peer-review-processes-show', + component: PeerReviewProcessesShow, + props: true, + }, + ]; + + const base = new URL( + window.STUDIP.URLHelper.getURL( + 'dispatch.php/course/courseware/tasks', + { cid: STUDIP.URLHelper.parameters.cid }, + true + ) + ); + const router = new VueRouter({ + base: base.pathname, + mode: 'history', + routes, + }); + router.beforeEach((to, from, next) => { + if ('cid' in to?.query) { + next(); + } else { + next({ ...to, query: { ...to.query, cid: window.STUDIP.URLHelper.parameters.cid } }); + } + }); + const store = new Vuex.Store({ modules: { courseware: CoursewareModule, @@ -33,6 +82,8 @@ const mountApp = async (STUDIP, createApp, element) => { 'courseware-block-feedback', 'courseware-containers', 'courseware-instances', + 'courseware-peer-reviews', + 'courseware-peer-review-processes', 'courseware-structural-elements', 'courseware-task-feedback', 'courseware-task-groups', @@ -71,22 +122,18 @@ const mountApp = async (STUDIP, createApp, element) => { } store.dispatch('setUserId', STUDIP.USER_ID); - await store.dispatch('users/loadById', {id: STUDIP.USER_ID}); + await store.dispatch('users/loadById', { id: STUDIP.USER_ID }); store.dispatch('setHttpClient', httpClient); store.dispatch('coursewareContext', { id: entry_id, type: entry_type, }); await store.dispatch('loadTeacherStatus', STUDIP.USER_ID); - store.dispatch('courseware-tasks/loadAll', { - options: { - 'filter[cid]': entry_id, - include: 'solver, structural-element, task-feedback, task-group, task-group.lecturer', - }, - }); + await store.dispatch('tasks/loadTasksOfCourse', { cid: entry_id }); const app = createApp({ - render: (h) => h(TasksApp), + render: (h) => h(RouterView), + router, store, }); diff --git a/resources/vue/mixins/courseware/task-helper.js b/resources/vue/mixins/courseware/task-helper.js index 0bc694c12260b0e4449ae385a526f16154cf4b0c..a0510f7697dd4433f9d6cf965b03a5d21a55cf14 100644 --- a/resources/vue/mixins/courseware/task-helper.js +++ b/resources/vue/mixins/courseware/task-helper.js @@ -8,7 +8,7 @@ export default { limit.setDate(now.getDate() + 3); status.canSubmit = true; - if (now < submissionDate) { + if (now <= submissionDate) { status.shape = 'span-empty'; status.role = 'status-green'; status.description = this.$gettext('Aufgabe bereit'); @@ -20,7 +20,7 @@ export default { status.description = this.$gettext('Aufgabe muss bald abgegeben werden'); } - if (now >= submissionDate) { + if (now > submissionDate) { status.canSubmit = false; status.shape = 'span-full'; status.role = 'status-red'; @@ -34,7 +34,7 @@ export default { status.description = this.$gettext('Aufgabe muss bald abgegeben werden'); } - if (now >= renewalDate) { + if (now > renewalDate) { status.canSubmit = false; status.shape = 'span-full'; status.role = 'status-red'; diff --git a/resources/vue/store/courseware/courseware-tasks.module.js b/resources/vue/store/courseware/courseware-tasks.module.js index fd5152dfa839f569e6ec2ed62f93372056ad25f5..96af9393dd675b8d1bdc049497072a47fa5c9cd3 100644 --- a/resources/vue/store/courseware/courseware-tasks.module.js +++ b/resources/vue/store/courseware/courseware-tasks.module.js @@ -1,5 +1,9 @@ +import { ASSESSMENT_TYPES } from '../../components/courseware/tasks/peer-review/process-configuration'; + const getDefaultState = () => { return { + showTaskGroupsDeleteDialog: false, + showTaskGroupsModifyDeadlineDialog: false, showTasksDistributeDialog: false, }; }; @@ -7,29 +11,158 @@ const getDefaultState = () => { const initialState = getDefaultState(); const getters = { + showTaskGroupsDeleteDialog(state) { + return state.showTaskGroupsDeleteDialog; + }, + showTaskGroupsModifyDeadlineDialog(state) { + return state.showTaskGroupsModifyDeadlineDialog; + }, showTasksDistributeDialog(state) { return state.showTasksDistributeDialog; }, + taskGroupsByCid(state, getters, rootState, rootGetters) { + return (cid) => { + return rootGetters['courseware-task-groups/all'].filter( + (taskGroup) => taskGroup.relationships.course.data.id === cid + ); + }; + }, + tasksByCid(state, getters, rootState, rootGetters) { + return (cid) => { + const taskGroupIds = getters.taskGroupsByCid(cid).map(({ id }) => id); + + return rootGetters['courseware-tasks/all'].filter((task) => + taskGroupIds.includes(task.relationships['task-group'].data.id) + ); + }; + }, }; export const state = { ...initialState }; export const actions = { // setters + setShowTaskGroupsDeleteDialog({ commit }, context) { + commit('setShowTaskGroupsDeleteDialog', context); + }, + setShowTaskGroupsModifyDeadlineDialog({ commit }, context) { + commit('setShowTaskGroupsModifyDeadlineDialog', context); + }, setShowTasksDistributeDialog({ commit }, context) { commit('setShowTasksDistributeDialog', context); }, // other actions + loadTasksOfCourse({ dispatch }, { cid }) { + const options = { + 'filter[cid]': cid, + include: + 'solver, structural-element, task-feedback, task-group, task-group.lecturer, task-group.peer-review-processes', + }; + return dispatch('courseware-tasks/loadAll', { options }, { root: true }); + }, + + loadTaskGroup({ dispatch }, { id }) { + const options = { + include: 'lecturer, peer-review-processes', + }; + return dispatch('courseware-task-groups/loadById', { id, options }, { root: true }); + }, + + modifyDeadlineOfTaskGroup({ dispatch }, { taskGroup, endDate }) { + taskGroup.attributes['end-date'] = endDate.toISOString(); + + return dispatch('courseware-task-groups/update', taskGroup, { root: true }); + }, + + createPeerReviewProcess({ dispatch }, { taskGroup, options }) { + const { anonymous, duration, automaticPairing, type, payload } = options; + const startDate = new Date(taskGroup.attributes['end-date']); + const endDate = new Date(startDate); + endDate.setDate(endDate.getDate() + duration); + + const data = { + attributes: { + configuration: { anonymous, duration, automaticPairing, type, payload }, + 'review-start': startDate.toISOString(), + 'review-end': endDate.toISOString(), + }, + relationships: { + 'task-group': { + data: { + type: taskGroup.type, + id: taskGroup.id, + }, + }, + }, + }; + + return dispatch('courseware-peer-review-processes/create', data, { root: true }); + }, + replacePairings({ dispatch, rootGetters }, { process, pairings }) { + const reviews = rootGetters['courseware-peer-reviews/related']({ + parent: process, + relationship: 'peer-reviews', + }); + const relation = ({ id, type }) => ({ data: { id, type } }); + const deleteReview = (review) => dispatch('courseware-peer-reviews/delete', review, { root: true }); + const createReview = (pairing) => + dispatch( + 'courseware-peer-reviews/create', + { + type: 'courseware-peer-reviews', + attributes: {}, + relationships: { + process: relation(process), + submitter: relation(pairing.submitter), + reviewer: relation(pairing.reviewer), + }, + }, + { root: true } + ); + + return Promise.all(reviews.map(deleteReview)).then(() => Promise.all(pairings.map(createReview))); + }, + updatePeerReviewProcess({ dispatch, rootGetters }, { process, configuration }) { + const taskGroup = rootGetters['courseware-task-groups/related']({ + parent: process, + relationship: 'task-group', + }); + + const startDate = new Date(taskGroup.attributes['end-date']); + const endDate = new Date(startDate); + endDate.setDate(endDate.getDate() + configuration.duration); + + if (_.isEmpty(configuration.payload)) { + configuration.payload = ASSESSMENT_TYPES[configuration.type].defaultPayload; + } + + process.attributes.configuration = configuration; + process.attributes['review-start'] = startDate.toISOString(); + process.attributes['review-end'] = endDate.toISOString(); + + return dispatch('courseware-peer-review-processes/update', process, { root: true }); + }, + storeAssessment({ dispatch, rootGetters }, { review, assessment }) { + review.attributes.assessment = assessment; + return dispatch('courseware-peer-reviews/update', review, { root: true }); + }, }; export const mutations = { - setShowTasksDistributeDialog(state, data){ + setShowTasksDistributeDialog(state, data) { state.showTasksDistributeDialog = data; }, + setShowTaskGroupsDeleteDialog(state, data) { + state.showTaskGroupsDeleteDialog = data; + }, + setShowTaskGroupsModifyDeadlineDialog(state, data) { + state.showTaskGroupsModifyDeadlineDialog = data; + }, }; export default { + namespaced: true, state, actions, mutations, diff --git a/resources/vue/store/courseware/courseware.module.js b/resources/vue/store/courseware/courseware.module.js index 89d7da82d7ecdd27f0f8f0ae571e2443bf610fb5..ecf4664b72a541e7ed650787b65276b322845d43 100644 --- a/resources/vue/store/courseware/courseware.module.js +++ b/resources/vue/store/courseware/courseware.module.js @@ -1263,7 +1263,7 @@ export const actions = { { id: taskId, options: { - include: 'solver,task-group,task-group.lecturer', + include: 'solver,task-group,task-group.lecturer,peer-reviews.process', }, }, { root: true } diff --git a/tests/jsonapi/PeerReviewProcessesIndexTest.php b/tests/jsonapi/PeerReviewProcessesIndexTest.php new file mode 100644 index 0000000000000000000000000000000000000000..16791a97e9908e856d7c957f1e3449e29c015e0b --- /dev/null +++ b/tests/jsonapi/PeerReviewProcessesIndexTest.php @@ -0,0 +1,60 @@ +<?php + +use Courseware\PeerReviewProcess as ProcessSorm; +use JsonApi\Routes\Courseware\PeerReview\ProcessesIndex; + +class PeerReviewProcessesIndexTest extends \Codeception\Test\Unit +{ + /** + * @var \UnitTester + */ + protected $tester; + + protected function _before() + { + \DBManager::getInstance()->setConnection('studip', $this->getModule('\\Helper\\StudipDb')->dbh); + } + + protected function _after() + { + } + + // tests + public function testShouldIndexProcesses() + { + $credentials = $this->tester->getCredentialsForTestDozent(); + $user = \User::findOneByUsername($credentials['username']); + $processes = ProcessSorm::findByUser($user); + $count = count($processes); + $cid = $count ? $processes[0]->getCourse()->id : null; + + $response = $this->getProcesses($credentials); + $this->tester->assertTrue($response->isSuccessfulDocument([200])); + $this->tester->assertCount($count, $response->document()->primaryResources()); + } + + public function testShouldIndexFilteredProcesses() + { + $credentials = $this->tester->getCredentialsForTestDozent(); + $user = \User::findOneByUsername($credentials['username']); + + // TODO: + $newCourse = \Course::create(); + + var_dump(count($user->course_memberships));exit; + } + + // **** helper functions **** + private function getProcesses($credentials) + { + $app = $this->tester->createApp($credentials, 'get', '/courseware-peer-review-processes', ProcessesIndex::class); + + return $this->tester->sendMockRequest( + $app, + $this->tester->createRequestBuilder($credentials) + ->setUri('/courseware-peer-review-processes') + ->fetch() + ->getRequest() + ); + } +} diff --git a/tsconfig.json b/tsconfig.json index 55b45dc2e0fa2d508c766a69d453be0517c3410f..2ada63c6be2a057f1c10d48d058d1d1f4c1fe37e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,9 +1,11 @@ { "compilerOptions": { - "target": "es2015", + "allowJs": true, + "module": "es2020", + "moduleResolution": "node", + "resolveJsonModule": true, "strict": true, - "module": "es2015", - "moduleResolution": "node" + "target": "es2020" }, "include": ["resources/**/*.ts", "resources/**/*.vue"], "exclude": ["node_modules"] diff --git a/webpack.common.js b/webpack.common.js index 3dd376d6f9391133a510ff002cb261a3b23ecf2e..8edc9c6b1500bb3f1ba1fc5fb763c86345a4b7ee 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -172,6 +172,7 @@ module.exports = { 'jquery-ui/widgets/resizable': 'jquery-ui/ui/widgets/resizable', '@': path.resolve(__dirname, 'resources') }, + extensions: ['.ts', '.vue', '.js'], fallback: { 'stream': require.resolve("stream-browserify"), 'buffer': require.resolve("buffer/") diff --git a/webpack.dev.js b/webpack.dev.js index c0cee71c597ad4a0339f2a0cf83af1b7c25b5cc0..92fb8a8e147aff31793ed0a29d2803f50d92c3ef 100644 --- a/webpack.dev.js +++ b/webpack.dev.js @@ -1,3 +1,4 @@ +const webpack = require('webpack'); const { merge } = require('webpack-merge'); const common = require('./webpack.common.js'); const WebpackNotifierPlugin = require('webpack-notifier'); @@ -10,8 +11,13 @@ const statusesPaths = { module.exports = merge(common, { mode: 'development', - devtool: 'eval', + devtool: 'eval-cheap-module-source-map', plugins: [ + new webpack.WatchIgnorePlugin({ + paths:[ + /\.d\.[cm]ts$/ + ] + }), new WebpackNotifierPlugin({ appID: 'Stud.IP Webpack', title: function (params) {