From 3281196ec04af612fb4f896756adb094c0812d24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Thu, 11 Feb 2021 12:27:11 +0100 Subject: [PATCH] MOBILE-3636 assign: Add assignment submission page --- .../assign/components/components.module.ts | 11 +- .../addon-mod-assign-feedback-plugin.html | 23 + .../feedback-plugin/feedback-plugin.ts | 117 ++ .../index/addon-mod-assign-index.html | 4 +- .../mod/assign/components/index/index.ts | 23 +- .../addon-mod-assign-submission-plugin.html | 23 + .../submission-plugin/submission-plugin.ts | 114 ++ .../addon-mod-assign-submission.html | 388 ++++++ .../components/submission/submission.scss | 263 ++++ .../components/submission/submission.ts | 1223 +++++++++++++++++ .../mod/assign/services/assign-helper.ts | 56 +- .../mod/assign/services/assign-offline.ts | 4 +- src/addons/mod/assign/services/assign-sync.ts | 4 +- src/addons/mod/assign/services/assign.ts | 13 +- .../mod/assign/services/database/assign.ts | 2 +- .../mod/assign/services/feedback-delegate.ts | 23 +- .../services/handlers/default-feedback.ts | 15 +- .../services/handlers/default-submission.ts | 15 +- .../assign/services/submission-delegate.ts | 14 +- 19 files changed, 2239 insertions(+), 96 deletions(-) create mode 100644 src/addons/mod/assign/components/feedback-plugin/addon-mod-assign-feedback-plugin.html create mode 100644 src/addons/mod/assign/components/feedback-plugin/feedback-plugin.ts create mode 100644 src/addons/mod/assign/components/submission-plugin/addon-mod-assign-submission-plugin.html create mode 100644 src/addons/mod/assign/components/submission-plugin/submission-plugin.ts create mode 100644 src/addons/mod/assign/components/submission/addon-mod-assign-submission.html create mode 100644 src/addons/mod/assign/components/submission/submission.scss create mode 100644 src/addons/mod/assign/components/submission/submission.ts diff --git a/src/addons/mod/assign/components/components.module.ts b/src/addons/mod/assign/components/components.module.ts index bdc46ebfb..5f2534604 100644 --- a/src/addons/mod/assign/components/components.module.ts +++ b/src/addons/mod/assign/components/components.module.ts @@ -21,13 +21,16 @@ import { TranslateModule } from '@ngx-translate/core'; import { CoreSharedModule } from '@/core/shared.module'; import { CoreCourseComponentsModule } from '@features/course/components/components.module'; import { AddonModAssignIndexComponent } from './index/index'; +import { AddonModAssignSubmissionComponent } from './submission/submission'; +import { AddonModAssignSubmissionPluginComponent } from './submission-plugin/submission-plugin'; +import { AddonModAssignFeedbackPluginComponent } from './feedback-plugin/feedback-plugin'; @NgModule({ declarations: [ AddonModAssignIndexComponent, - /* AddonModAssignSubmissionComponent, + AddonModAssignSubmissionComponent, AddonModAssignSubmissionPluginComponent, - AddonModAssignFeedbackPluginComponent*/ + AddonModAssignFeedbackPluginComponent, ], imports: [ CommonModule, @@ -39,9 +42,9 @@ import { AddonModAssignIndexComponent } from './index/index'; ], exports: [ AddonModAssignIndexComponent, - /* AddonModAssignSubmissionComponent, + AddonModAssignSubmissionComponent, AddonModAssignSubmissionPluginComponent, - AddonModAssignFeedbackPluginComponent */ + AddonModAssignFeedbackPluginComponent, ], }) export class AddonModAssignComponentsModule {} diff --git a/src/addons/mod/assign/components/feedback-plugin/addon-mod-assign-feedback-plugin.html b/src/addons/mod/assign/components/feedback-plugin/addon-mod-assign-feedback-plugin.html new file mode 100644 index 000000000..0c6116e3c --- /dev/null +++ b/src/addons/mod/assign/components/feedback-plugin/addon-mod-assign-feedback-plugin.html @@ -0,0 +1,23 @@ + + + + + + +

{{ plugin.name }}

+ + {{ 'addon.mod_assign.feedbacknotsupported' | translate }} + +

+ + +

+ + +
+
+
+
diff --git a/src/addons/mod/assign/components/feedback-plugin/feedback-plugin.ts b/src/addons/mod/assign/components/feedback-plugin/feedback-plugin.ts new file mode 100644 index 000000000..709c8aa72 --- /dev/null +++ b/src/addons/mod/assign/components/feedback-plugin/feedback-plugin.ts @@ -0,0 +1,117 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, OnInit, ViewChild, Type } from '@angular/core'; +import { CoreDynamicComponent } from '@components/dynamic-component/dynamic-component'; +import { CoreWSExternalFile } from '@services/ws'; +import { + AddonModAssignAssign, + AddonModAssignSubmission, + AddonModAssignPlugin, + AddonModAssignProvider, + AddonModAssign, +} from '../../services/assign'; +import { AddonModAssignHelper, AddonModAssignPluginConfig } from '../../services/assign-helper'; +import { AddonModAssignFeedbackDelegate } from '../../services/feedback-delegate'; + +/** + * Component that displays an assignment feedback plugin. + */ +@Component({ + selector: 'addon-mod-assign-feedback-plugin', + templateUrl: 'addon-mod-assign-feedback-plugin.html', +}) +export class AddonModAssignFeedbackPluginComponent implements OnInit { + + @ViewChild(CoreDynamicComponent) dynamicComponent!: CoreDynamicComponent; + + @Input() assign!: AddonModAssignAssign; // The assignment. + @Input() submission!: AddonModAssignSubmission; // The submission. + @Input() plugin!: AddonModAssignPlugin; // The plugin object. + @Input() userId!: number; // The user ID of the submission. + @Input() canEdit = false; // Whether the user can edit. + @Input() edit = false; // Whether the user is editing. + + pluginComponent?: Type; // Component to render the plugin. + data?: AddonModAssignFeedbackPluginData; // Data to pass to the component. + + // Data to render the plugin if it isn't supported. + component = AddonModAssignProvider.COMPONENT; + text = ''; + files: CoreWSExternalFile[] = []; + notSupported = false; + pluginLoaded = false; + + /** + * Component being initialized. + */ + async ngOnInit(): Promise { + if (!this.plugin) { + this.pluginLoaded = true; + + return; + } + + const name = AddonModAssignFeedbackDelegate.instance.getPluginName(this.plugin); + + if (!name) { + this.pluginLoaded = true; + + return; + } + this.plugin.name = name; + + // Check if the plugin has defined its own component to render itself. + this.pluginComponent = await AddonModAssignFeedbackDelegate.instance.getComponentForPlugin(this.plugin); + + if (this.pluginComponent) { + // Prepare the data to pass to the component. + this.data = { + assign: this.assign, + submission: this.submission, + plugin: this.plugin, + userId: this.userId, + configs: AddonModAssignHelper.instance.getPluginConfig(this.assign, 'assignfeedback', this.plugin.type), + edit: this.edit, + canEdit: this.canEdit, + }; + } else { + // Data to render the plugin. + this.text = AddonModAssign.instance.getSubmissionPluginText(this.plugin); + this.files = AddonModAssign.instance.getSubmissionPluginAttachments(this.plugin); + this.notSupported = AddonModAssignFeedbackDelegate.instance.isPluginSupported(this.plugin.type); + this.pluginLoaded = true; + } + } + + /** + * Invalidate the plugin data. + * + * @return Promise resolved when done. + */ + async invalidate(): Promise { + await this.dynamicComponent.callComponentFunction('invalidate', []); + } + +} + +export type AddonModAssignFeedbackPluginData = { + assign: AddonModAssignAssign; + submission: AddonModAssignSubmission; + plugin: AddonModAssignPlugin; + configs: AddonModAssignPluginConfig; + edit: boolean; + canEdit: boolean; + userId: number; +}; diff --git a/src/addons/mod/assign/components/index/addon-mod-assign-index.html b/src/addons/mod/assign/components/index/addon-mod-assign-index.html index 67c2169e9..c71bacbc9 100644 --- a/src/addons/mod/assign/components/index/addon-mod-assign-index.html +++ b/src/addons/mod/assign/components/index/addon-mod-assign-index.html @@ -135,8 +135,8 @@ - + diff --git a/src/addons/mod/assign/components/index/index.ts b/src/addons/mod/assign/components/index/index.ts index d62267888..89040249c 100644 --- a/src/addons/mod/assign/components/index/index.ts +++ b/src/addons/mod/assign/components/index/index.ts @@ -34,6 +34,7 @@ import { AddonModAssignGradedEventData, AddonModAssignProvider, AddonModAssignSubmissionGradingSummary, + AddonModAssignSubmittedForGradingEventData, } from '../../services/assign'; import { AddonModAssignOffline } from '../../services/assign-offline'; import { @@ -42,6 +43,7 @@ import { AddonModAssignSyncProvider, AddonModAssignSyncResult, } from '../../services/assign-sync'; +import { AddonModAssignSubmissionComponent } from '../submission/submission'; /** * Component that displays an assignment. @@ -52,8 +54,7 @@ import { }) export class AddonModAssignIndexComponent extends CoreCourseModuleMainActivityComponent implements OnInit, OnDestroy { - // @todo @ViewChild(AddonModAssignSubmissionComponent) submissionComponent?: AddonModAssignSubmissionComponent; - submissionComponent?: any; + @ViewChild(AddonModAssignSubmissionComponent) submissionComponent?: AddonModAssignSubmissionComponent; component = AddonModAssignProvider.COMPONENT; moduleName = 'assign'; @@ -112,15 +113,19 @@ export class AddonModAssignIndexComponent extends CoreCourseModuleMainActivityCo } }, this.siteId); - this.submittedObserver = CoreEvents.on(AddonModAssignProvider.SUBMITTED_FOR_GRADING_EVENT, (data) => { - if (this.assign && data.assignmentId == this.assign.id && data.userId == this.currentUserId) { + this.submittedObserver = CoreEvents.on( + AddonModAssignProvider.SUBMITTED_FOR_GRADING_EVENT, + (data) => { + if (this.assign && data.assignmentId == this.assign.id && data.userId == this.currentUserId) { // Assignment submitted, check completion. - CoreCourse.instance.checkModuleCompletion(this.courseId!, this.module!.completiondata); + CoreCourse.instance.checkModuleCompletion(this.courseId!, this.module!.completiondata); - // Reload data since it can have offline data now. - this.showLoadingAndRefresh(true, false); - } - }, this.siteId); + // Reload data since it can have offline data now. + this.showLoadingAndRefresh(true, false); + } + }, + this.siteId, + ); this.gradedObserver = CoreEvents.on(AddonModAssignProvider.GRADED_EVENT, (data) => { if (this.assign && data.assignmentId == this.assign.id && data.userId == this.currentUserId) { diff --git a/src/addons/mod/assign/components/submission-plugin/addon-mod-assign-submission-plugin.html b/src/addons/mod/assign/components/submission-plugin/addon-mod-assign-submission-plugin.html new file mode 100644 index 000000000..9ec50a95c --- /dev/null +++ b/src/addons/mod/assign/components/submission-plugin/addon-mod-assign-submission-plugin.html @@ -0,0 +1,23 @@ + + + + + + +

{{ plugin.name }}

+ + {{ 'addon.mod_assign.submissionnotsupported' | translate }} + +

+ + +

+ + +
+
+
+
diff --git a/src/addons/mod/assign/components/submission-plugin/submission-plugin.ts b/src/addons/mod/assign/components/submission-plugin/submission-plugin.ts new file mode 100644 index 000000000..5507f6273 --- /dev/null +++ b/src/addons/mod/assign/components/submission-plugin/submission-plugin.ts @@ -0,0 +1,114 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, OnInit, Type, ViewChild } from '@angular/core'; +import { CoreDynamicComponent } from '@components/dynamic-component/dynamic-component'; +import { CoreWSExternalFile } from '@services/ws'; +import { + AddonModAssignAssign, + AddonModAssignSubmission, + AddonModAssignPlugin, + AddonModAssignProvider, + AddonModAssign, +} from '../../services/assign'; +import { AddonModAssignHelper, AddonModAssignPluginConfig } from '../../services/assign-helper'; +import { AddonModAssignSubmissionDelegate } from '../../services/submission-delegate'; + +/** + * Component that displays an assignment submission plugin. + */ +@Component({ + selector: 'addon-mod-assign-submission-plugin', + templateUrl: 'addon-mod-assign-submission-plugin.html', +}) +export class AddonModAssignSubmissionPluginComponent implements OnInit { + + @ViewChild(CoreDynamicComponent) dynamicComponent!: CoreDynamicComponent; + + @Input() assign!: AddonModAssignAssign; // The assignment. + @Input() submission!: AddonModAssignSubmission; // The submission. + @Input() plugin!: AddonModAssignPlugin; // The plugin object. + @Input() edit = false; // Whether the user is editing. + @Input() allowOffline = false; // Whether to allow offline. + + pluginComponent?: Type; // Component to render the plugin. + data?: AddonModAssignSubmissionPluginData; // Data to pass to the component. + + // Data to render the plugin if it isn't supported. + component = AddonModAssignProvider.COMPONENT; + text = ''; + files: CoreWSExternalFile[] = []; + notSupported = false; + pluginLoaded = false; + + /** + * Component being initialized. + */ + async ngOnInit(): Promise { + if (!this.plugin) { + this.pluginLoaded = true; + + return; + } + + const name = AddonModAssignSubmissionDelegate.instance.getPluginName(this.plugin); + + if (!name) { + this.pluginLoaded = true; + + return; + } + this.plugin.name = name; + + // Check if the plugin has defined its own component to render itself. + this.pluginComponent = await AddonModAssignSubmissionDelegate.instance.getComponentForPlugin(this.plugin, this.edit); + + if (this.pluginComponent) { + // Prepare the data to pass to the component. + this.data = { + assign: this.assign, + submission: this.submission, + plugin: this.plugin, + configs: AddonModAssignHelper.instance.getPluginConfig(this.assign, 'assignsubmission', this.plugin.type), + edit: this.edit, + allowOffline: this.allowOffline, + }; + } else { + // Data to render the plugin. + this.text = AddonModAssign.instance.getSubmissionPluginText(this.plugin); + this.files = AddonModAssign.instance.getSubmissionPluginAttachments(this.plugin); + this.notSupported = AddonModAssignSubmissionDelegate.instance.isPluginSupported(this.plugin.type); + this.pluginLoaded = true; + } + } + + /** + * Invalidate the plugin data. + * + * @return Promise resolved when done. + */ + async invalidate(): Promise { + await this.dynamicComponent.callComponentFunction('invalidate', []); + } + +} + +export type AddonModAssignSubmissionPluginData = { + assign: AddonModAssignAssign; + submission: AddonModAssignSubmission; + plugin: AddonModAssignPlugin; + configs: AddonModAssignPluginConfig; + edit: boolean; + allowOffline: boolean; +}; diff --git a/src/addons/mod/assign/components/submission/addon-mod-assign-submission.html b/src/addons/mod/assign/components/submission/addon-mod-assign-submission.html new file mode 100644 index 000000000..5c45c5af4 --- /dev/null +++ b/src/addons/mod/assign/components/submission/addon-mod-assign-submission.html @@ -0,0 +1,388 @@ + + + + + + +

{{ user.fullname }}

+ +
+ +
+ + + + +

{{ 'addon.mod_assign.hiddenuser' | translate }} {{blindId}}

+ +
+ +
+ + + + +

{{ 'addon.mod_assign.submissionstatus' | translate }}

+ +
+ +
+ + + + + + + + + + + + +

{{ 'addon.mod_assign.timemodified' | translate }}

+

{{ userSubmission.timemodified * 1000 | coreFormatDate }}

+
+
+ + + +

{{ 'addon.mod_assign.timeremaining' | translate }}

+

+
+
+ + + +

+

+

+

+
+
+ + + +

{{ 'addon.mod_assign.duedate' | translate }}

+

{{ assign.duedate * 1000 | coreFormatDate }}

+

{{ 'addon.mod_assign.duedateno' | translate }}

+
+
+ + + +

{{ 'addon.mod_assign.cutoffdate' | translate }}

+

{{ assign.cutoffdate * 1000 | coreFormatDate }}

+
+
+ + + +

{{ 'addon.mod_assign.extensionduedate' | translate }}

+

{{ lastAttempt.extensionduedate * 1000 | coreFormatDate }}

+
+
+ + + +

{{ 'addon.mod_assign.attemptnumber' | translate }}

+

+ {{ 'addon.mod_assign.outof' | translate : + {'$a': {'current': currentAttempt, 'total': maxAttemptsText} } }} +

+

+ {{ 'addon.mod_assign.outof' | translate : + {'$a': {'current': currentAttempt, 'total': assign.maxattempts} } }} +

+
+
+ + + + +
+ + + {{ 'addon.mod_assign.editsubmission' | translate }} + + + + {{ 'addon.mod_assign.addsubmission' | translate }} + + + + + {{ 'addon.mod_assign.addnewattemptfromprevious' | translate }} + + + {{ 'addon.mod_assign.addnewattempt' | translate }} + + + + + {{ 'addon.mod_assign.editsubmission' | translate }} + +
+
+

{{ 'addon.mod_assign.erroreditpluginsnotsupported' | translate }}

+

{{ name }}

+
+
+

{{ 'addon.mod_assign.cannoteditduetostatementsubmission' | translate }}

+
+
+
+ + + + + + + + + + + + + + + {{ 'addon.mod_assign.submitassignment' | translate }} + +

{{ 'addon.mod_assign.submitassignment_help' | translate }}

+
+
+ + + +

+ {{ 'addon.mod_assign.cannotsubmitduetostatementsubmission' | translate }} +

+
+
+
+ + + +

{{ 'addon.mod_assign.userswhoneedtosubmit' | translate: {$a: ''} }}

+ + + + +

{{ user.fullname }}

+
+
+
+ + + + {{ 'addon.mod_assign.hiddenuser' | translate }} {{ blindId }} + + + +
+ + + +

{{ 'addon.mod_assign.submissionslocked' | translate }}

+
+ + + + +

{{ 'addon.mod_assign.editingstatus' | translate }}

+

{{ 'addon.mod_assign.submissioneditable' | translate }}

+

{{ 'addon.mod_assign.submissionnoteditable' | translate }}

+
+
+
+
+ + + + + + + +

{{ 'addon.mod_assign.currentgrade' | translate }}

+

+
+ + + +
+ + + + + +

{{ 'addon.mod_assign.gradeoutof' | translate: {$a: gradeInfo!.grade} }}

+
+ + +

{{ 'addon.mod_assign.gradelocked' | translate }}

+
+ + + +

{{ 'addon.mod_assign.grade' | translate }}

+ + + {{grade.label}} + + +
+ + + +

{{ outcome.name }}

+ + + {{grade.label}} + + +

{{ outcome.selected }}

+
+ + + + +

{{ 'addon.mod_assign.currentgrade' | translate }}

+

+ {{ grade.gradebookGrade }} +

+

+ {{ grade.scale[grade.gradebookGrade].label }} +

+

-

+
+
+
+ + + + + + + +

{{ 'addon.mod_assign.markingworkflowstate' | translate }}

+

{{ workflowStatusTranslationId | translate }}

+
+
+ + + +

{{ 'addon.mod_assign.groupsubmissionsettings' | translate }}

+ {{ 'addon.mod_assign.applytoteam' | translate }} + +
+ + + + + +

{{ 'addon.mod_assign.attemptsettings' | translate }}

+

+ {{ 'addon.mod_assign.outof' | translate : + {'$a': {'current': currentAttempt, 'total': maxAttemptsText} } }} +

+

+ {{ 'addon.mod_assign.outof' | translate : + {'$a': {'current': currentAttempt, 'total': assign.maxattempts} } }} +

+

+ {{ 'addon.mod_assign.attemptreopenmethod' | translate }}: + {{ 'addon.mod_assign.attemptreopenmethod_' + assign.attemptreopenmethod | translate }} +

+
+
+ + {{ 'addon.mod_assign.addattempt' | translate }} + + +
+ + + + + +

{{ 'addon.mod_assign.gradedby' | translate }}

+

{{ grader.fullname }}

+

{{ feedback.gradeddate * 1000 | coreFormatDate }}

+
+
+ + + + +

{{ 'addon.mod_assign.gradedon' | translate }}

+

{{ feedback.gradeddate * 1000 | coreFormatDate }}

+
+
+ + +
+ +

{{ 'addon.mod_assign.cannotgradefromapp' | translate }}

+ + {{ 'core.openinbrowser' | translate }} + + +
+
+
+
+
+ + + + +

{{lastAttempt!.submissiongroupname}}

+ +

{{ 'addon.mod_assign.noteam' | translate }}

+

{{ 'addon.mod_assign.noteam_desc' | translate }}

+
+ +

{{ 'addon.mod_assign.multipleteams' | translate }}

+

{{ 'addon.mod_assign.multipleteams_desc' | translate }}

+
+

+ {{ 'addon.mod_assign.defaultteam' | translate }} +

+
+
+ + + {{ statusTranslated }} + + + {{ gradingStatusTranslationId | translate }} + + diff --git a/src/addons/mod/assign/components/submission/submission.scss b/src/addons/mod/assign/components/submission/submission.scss new file mode 100644 index 000000000..dee6a5037 --- /dev/null +++ b/src/addons/mod/assign/components/submission/submission.scss @@ -0,0 +1,263 @@ +:host { + div.latesubmission, + div.overdue { + // @extend .core-danger-item; + } + + div.earlysubmission { + // @extend .core-success-item; + } + + div.submissioneditable p { + color: $red; + @include darkmode() { + color: $red-light; + } + } + + .core-grading-summary .advancedgrade { + display: none; + } +} + +core-format-text { + + .gradingform_rubric_editform .status { + font-weight: normal; + text-transform: uppercase; + font-size: 60%; + padding: 0.25em; + border: 1px solid $gray-light; + } + + .gradingform_rubric_editform .status.ready { + background-color: $green-light; + border-color: $green; + } + + .gradingform_rubric_editform .status.draft { + background-color: $yellow-light; + border-color: $yellow; + } + + .gradingform_rubric { + overflow: auto; + padding-bottom: 1.5em; + max-width: 720px; + position: relative; + margin: 0 auto; + tbody { + background: $white; + color: $text-color; + } + } + + // Do not display remark column. + .gradingform_rubric .criterion .remark { + display: none; + } + + .gradingform_rubric.editor .criterion .controls, + .gradingform_rubric .criterion .description, + .gradingform_rubric .criterion .levels, + .gradingform_rubric.editor .criterion .addlevel, + .gradingform_rubric .criterion .remark, + .gradingform_rubric .criterion .levels .level { + vertical-align: top; + } + + .gradingform_rubric.editor .criterion .controls, + .gradingform_rubric .criterion .description, + .gradingform_rubric.editor .criterion .addlevel, + .gradingform_rubric .criterion .remark, + .gradingform_rubric .criterion .levels .level { + padding: 3px; + } + + .gradingform_rubric .criteria { + height: 100%; + } + + .gradingform_rubric .criterion { + border: 1px solid $gray; + overflow: hidden; + } + + .gradingform_rubric .criterion.even { + background: $gray-lighter; + } + + .gradingform_rubric .criterion .description { + width: 150px; + font-weight: bold; + } + + .gradingform_rubric .criterion .levels table { + width: 100%; + height: 100%; + } + + .gradingform_rubric .criterion .levels, + .gradingform_rubric .criterion .levels table, + .gradingform_rubric .criterion .levels table tbody { + padding: 0; + margin: 0; + } + + .gradingform_rubric .criterion .levels .level { + border-left: 1px solid $gray; + max-width: 150px; + } + + .gradingform_rubric .criterion .levels .level .level-wrapper { + position: relative; + } + + .gradingform_rubric .criterion .levels .level.last { + border-right: 1px solid $gray; + } + + .gradingform_rubric .plainvalue.empty { + font-style: italic; + color: $gray-dark; + } + + .gradingform_rubric.editor .criterion .levels .level .delete { + position: absolute; + right: 0; + } + + .gradingform_rubric .criterion .levels .level .score { + font-style: italic; + color: $green; + font-weight: bold; + margin-top: 5px; + white-space: nowrap; + } + + .gradingform_rubric .criterion .levels .level .score .scorevalue { + padding-right: 5px; + } + + /* Make invisible the buttons 'Move up' for the first criterion and + 'Move down' for the last, because those buttons will make no change */ + .gradingform_rubric.editor .criterion.first .controls .moveup input, + .gradingform_rubric.editor .criterion.last .controls .movedown input { + display: none; + } + + /* evaluation */ + .gradingform_rubric .criterion .levels .level.currentchecked { + background: #fff0f0; + } + + .gradingform_rubric .criterion .levels .level.checked { + background: $green-light; + border: 1px solid $gray-darker; + } + + .gradingform_rubric .options .optionsheading { + font-weight: bold; + font-size: 1.1em; + padding-bottom: 5px; + } + + .gradingform_rubric .options .option { + padding-bottom: 2px; + } + + .gradingform_rubric .options .option label { + margin-left: 5px; + } + + .gradingform_rubric .options .option .value { + margin-left: 5px; + font-weight: bold; + } + + .gradingform_rubric .criterion .levels.error { + border: 1px solid $red; + } + + .gradingform_rubric .criterion .description.error, + .gradingform_rubric .criterion .levels .level .definition.error, + .gradingform_rubric .criterion .levels .level .score.error { + background: $gray-lighter; + } + + .gradingform_rubric-regrade { + padding: 10px; + background: $gray-lighter; + border: 1px solid $red-light; + margin-bottom: 10px; + } + + .gradingform_rubric-restored { + padding: 10px; + background: $yellow-light; + border: 1px solid $yellow; + margin-bottom: 10px; + } + + .gradingform_rubric-error { + color: $red; + font-weight: bold; + } + + /* special classes for elements created by rubriceditor.js */ + .gradingform_rubric.editor .hiddenelement { + display: none; + } + + .gradingform_rubric.editor .pseudotablink { + background-color: transparent; + border: 0 solid; + height: 1px; + width: 1px; + color: transparent; + padding: 0; + margin: 0; + position: relative; + float: right; + } + + .gradingform_rubric { + padding-bottom: 0; + max-width: none; + } + + .gradingform_rubric .criterion .description { + font-weight: 500; + min-width: 150px; + } + + .gradingform_rubric .criterion .levels { + background-color: $white; + } + + .gradingform_rubric .criterion, + .gradingform_rubric .criterion.even { + background-color: transparent; + } + + .gradingform_rubric.evaluate .criterion .levels .level:hover { + background-color: $green-light; + } + + .gradingform_rubric .criterion .levels .level.checked { + background-color: $green-light; + border: none; + border-left: 1px solid $gray; + } + + .gradingform_rubric .criterion .levels .level .score { + color: $green; + font-weight: 500; + font-style: normal; + margin-top: 20px; + } + + .gradingform_rubric .criterion .remark textarea { + margin-bottom: 0; + } +} diff --git a/src/addons/mod/assign/components/submission/submission.ts b/src/addons/mod/assign/components/submission/submission.ts new file mode 100644 index 000000000..e4e2114e5 --- /dev/null +++ b/src/addons/mod/assign/components/submission/submission.ts @@ -0,0 +1,1223 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, OnInit, OnDestroy, ViewChild, Optional, ViewChildren, QueryList } from '@angular/core'; +import { CoreEvents, CoreEventObserver } from '@singletons/events'; +import { CoreSites } from '@services/sites'; +import { + AddonModAssignProvider, + AddonModAssignAssign, + AddonModAssignSubmissionFeedback, + AddonModAssignSubmissionAttempt, + AddonModAssignSubmissionPreviousAttempt, + AddonModAssignPlugin, + AddonModAssign, + AddonModAssignGetSubmissionStatusWSResponse, + AddonModAssignSubmittedForGradingEventData, + AddonModAssignSavePluginData, +} from '../../services/assign'; +import { + AddonModAssignAutoSyncData, + AddonModAssignManualSyncData, + AddonModAssignSync, + AddonModAssignSyncProvider, +} from '../../services/assign-sync'; +import { CoreTabsComponent } from '@components/tabs/tabs'; +import { CoreTabComponent } from '@components/tabs/tab'; +import { CoreSplitViewComponent } from '@components/split-view/split-view'; +import { CoreGradesFormattedItem, CoreGradesHelper } from '@features/grades/services/grades-helper'; +import { CoreMenuItem, CoreUtils } from '@services/utils/utils'; +import { AddonModAssignHelper, AddonModAssignSubmissionFormatted } from '../../services/assign-helper'; +import { CoreDomUtils } from '@services/utils/dom'; +import { Translate } from '@singletons'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreCourse, CoreCourseModuleGradeInfo, CoreCourseModuleGradeOutcome } from '@features/course/services/course'; +import { AddonModAssignOffline } from '../../services/assign-offline'; +import { CoreUser, CoreUserProfile } from '@features/user/services/user'; +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreNavigator } from '@services/navigator'; +import { CoreApp } from '@services/app'; +import { CoreFileUploaderHelper } from '@features/fileuploader/services/fileuploader-helper'; +import { CoreLang } from '@services/lang'; +import { CoreError } from '@classes/errors/error'; +import { CoreGroups } from '@services/groups'; +import { CoreSync } from '@services/sync'; +import { AddonModAssignSubmissionPluginComponent } from '../submission-plugin/submission-plugin'; + +/** + * Component that displays an assignment submission. + */ +@Component({ + selector: 'addon-mod-assign-submission', + templateUrl: 'addon-mod-assign-submission.html', +}) +export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy { + + @ViewChild(CoreTabsComponent) tabs!: CoreTabsComponent; + @ViewChildren(AddonModAssignSubmissionPluginComponent) submissionComponents!: + QueryList; + + @Input() courseId!: number; // Course ID the submission belongs to. + @Input() moduleId!: number; // Module ID the submission belongs to. + @Input() submitId!: number; // User that did the submission. + @Input() blindId?: number; // Blinded user ID (if it's blinded). + @Input() showGrade = false; // Whether to display the grade tab at start. + + loaded = false; // Whether data has been loaded. + selectedTab = 'submission'; // Tab selected on start. + assign?: AddonModAssignAssign; // The assignment the submission belongs to. + userSubmission?: AddonModAssignSubmissionFormatted; // The submission object. + isSubmittedForGrading = false; // Whether the submission has been submitted for grading. + acceptStatement = false; // Statement accepted (for grading). + feedback?: AddonModAssignSubmissionFeedbackFormatted; // The feedback. + hasOffline = false; // Whether there is offline data. + submittedOffline = false; // Whether it was submitted in offline. + fromDate?: string; // Readable date when the assign started accepting submissions. + currentAttempt = 0; // The current attempt number. + maxAttemptsText: string; // The text for maximum attempts. + blindMarking = false; // Whether blind marking is enabled. + user?: CoreUserProfile; // The user. + lastAttempt?: AddonModAssignSubmissionAttemptFormatted; // The last attempt. + membersToSubmit: CoreUserProfile[] = []; // Team members that need to submit the assignment. + membersToSubmitBlind: number[] = []; // Team members that need to submit the assignment (blindmarking). + canSubmit = false; // Whether the user can submit for grading. + canEdit = false; // Whether the user can edit the submission. + submissionStatement?: string; // The submission statement. + showErrorStatementEdit = false; // Whether to show an error in edit due to submission statement. + showErrorStatementSubmit = false; // Whether to show an error in submit due to submission statement. + gradingStatusTranslationId?: string; // Key of the text to display for the grading status. + gradingColor = ''; // Color to apply to the grading status. + workflowStatusTranslationId?: string; // Key of the text to display for the workflow status. + submissionPlugins: AddonModAssignPlugin[] = []; // List of submission plugins. + timeRemaining = ''; // Message about time remaining. + timeRemainingClass = ''; // Class to apply to time remaining message. + statusTranslated?: string; // Status. + statusColor = ''; // Color to apply to the status. + unsupportedEditPlugins: string[] = []; // List of submission plugins that don't support edit. + grade: AddonModAssignSubmissionGrade = { + method: '', + modified: 0, + addAttempt : false, + applyToAll: false, + lang: 'en', + disabled: false, + }; // Data about the grade. + + grader?: CoreUserProfile; // Profile of the teacher that graded the submission. + gradeInfo?: AddonModAssignGradeInfo; // Grade data for the assignment, retrieved from the server. + isGrading = false; // Whether the user is grading. + canSaveGrades = false; // Whether the user can save the grades. + allowAddAttempt = false; // Allow adding a new attempt when grading. + gradeUrl?: string; // URL to grade in browser. + + // Some constants. + statusNew = AddonModAssignProvider.SUBMISSION_STATUS_NEW; + statusReopened = AddonModAssignProvider.SUBMISSION_STATUS_REOPENED; + attemptReopenMethodNone = AddonModAssignProvider.ATTEMPT_REOPEN_METHOD_NONE; + unlimitedAttempts = AddonModAssignProvider.UNLIMITED_ATTEMPTS; + + protected siteId: string; // Current site ID. + protected currentUserId: number; // Current user ID. + protected previousAttempt?: AddonModAssignSubmissionPreviousAttempt; // The previous attempt. + protected isPreviousAttemptEmpty = true; // Whether the previous attempt contains an empty submission. + protected submissionStatusAvailable = false; // Whether we were able to retrieve the submission status. + protected originalGrades: AddonModAssignSubmissionOriginalGrades = { + addAttempt: false, + applyToAll: false, + outcomes: {}, + }; // Object with the original grade data, to check for changes. + + protected isDestroyed = false; // Whether the component has been destroyed. + protected syncObserver: CoreEventObserver; + protected hasOfflineGrade = false; + + constructor( + @Optional() protected splitviewCtrl: CoreSplitViewComponent, + ) { + this.siteId = CoreSites.instance.getCurrentSiteId(); + this.currentUserId = CoreSites.instance.getCurrentSiteUserId(); + this.maxAttemptsText = Translate.instance.instant('addon.mod_assign.unlimitedattempts'); + + // Refresh data if this assign is synchronized and it's grading. + const events = [AddonModAssignSyncProvider.AUTO_SYNCED, AddonModAssignSyncProvider.MANUAL_SYNCED]; + this.syncObserver = CoreEvents.onMultiple( + events, + async (data) => { + // Check that user is grading and this grade wasn't blocked when sync was performed. + if (!this.loaded || !this.isGrading || data.gradesBlocked.indexOf(this.submitId) != -1) { + return; + } + + if ('context' in data && data.context == 'submission' && data.submitId == this.submitId) { + // Manual sync triggered by this same submission, ignore it. + return; + } + + // Don't refresh if the user has modified some data. + const hasDataToSave = await this.hasDataToSave(); + + if (!hasDataToSave) { + this.invalidateAndRefresh(false); + } + }, + this.siteId, + ); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.selectedTab = this.showGrade ? 'grade' : 'submission'; + this.isSubmittedForGrading = !!this.submitId; + + this.loadData(true); + } + + /** + * Calculate the time remaining message and class. + * + * @param response Response of get submission status. + */ + protected calculateTimeRemaining(response: AddonModAssignGetSubmissionStatusWSResponse): void { + if (this.assign!.duedate <= 0) { + this.timeRemaining = ''; + this.timeRemainingClass = ''; + + return; + } + + const time = CoreTimeUtils.instance.timestamp(); + const dueDate = response.lastattempt?.extensionduedate + ? response.lastattempt.extensionduedate + : this.assign!.duedate; + const timeRemaining = dueDate - time; + + if (timeRemaining > 0) { + this.timeRemaining = CoreTimeUtils.instance.formatDuration(timeRemaining, 3); + this.timeRemainingClass = ''; + + return; + } + + // Not submitted. + if (!this.userSubmission || this.userSubmission.status != AddonModAssignProvider.SUBMISSION_STATUS_SUBMITTED) { + + if (response.lastattempt?.submissionsenabled || response.gradingsummary?.submissionsenabled) { + this.timeRemaining = Translate.instance.instant( + 'addon.mod_assign.overdue', + { $a: CoreTimeUtils.instance.formatDuration(-timeRemaining, 3) }, + ); + this.timeRemainingClass = 'overdue'; + + return; + } + + this.timeRemaining = Translate.instance.instant('addon.mod_assign.duedatereached'); + this.timeRemainingClass = ''; + + return; + } + + const timeSubmittedDiff = this.userSubmission.timemodified - dueDate; + if (timeSubmittedDiff > 0) { + this.timeRemaining = Translate.instance.instant( + 'addon.mod_assign.submittedlate', + { $a: CoreTimeUtils.instance.formatDuration(timeSubmittedDiff, 2) }, + ); + this.timeRemainingClass = 'latesubmission'; + + return; + } + + this.timeRemaining = Translate.instance.instant( + 'addon.mod_assign.submittedearly', + { $a: CoreTimeUtils.instance.formatDuration(-timeSubmittedDiff, 2) }, + ); + this.timeRemainingClass = 'earlysubmission'; + } + + /** + * Check if the user can leave the view. If there are changes to be saved, it will ask for confirm. + * + * @return Promise resolved if can leave the view, rejected otherwise. + */ + async canLeave(): Promise { + // Check if there is data to save. + const modified = await this.hasDataToSave(); + + if (modified) { + // Modified, confirm user wants to go back. + try { + await CoreDomUtils.instance.showConfirm(Translate.instance.instant('core.confirmcanceledit')); + await this.discardDrafts(); + } catch { + // Cancelled by the user. + } + } + } + + /** + * Copy a previous attempt and then go to edit. + */ + async copyPrevious(): Promise { + if (!CoreApp.instance.isOnline()) { + CoreDomUtils.instance.showErrorModal('core.networkerrormsg', true); + + return; + } + + if (!this.previousAttempt?.submission) { + // Cannot access previous attempts, just go to edit. + return this.goToEdit(); + } + + const previousSubmission = this.previousAttempt.submission; + let modal = await CoreDomUtils.instance.showModalLoading(); + + const size = await CoreUtils.instance.ignoreErrors( + AddonModAssignHelper.instance.getSubmissionSizeForCopy(this.assign!, previousSubmission), + -1, + ); // Error calculating size, return -1. + + modal.dismiss(); + + try { + // Confirm action. + await CoreFileUploaderHelper.instance.confirmUploadFile(size, true); + } catch { + // Cancelled. + return; + } + + // User confirmed, copy the attempt. + modal = await CoreDomUtils.instance.showModalLoading('core.sending', true); + + try { + await AddonModAssignHelper.instance.copyPreviousAttempt(this.assign!, previousSubmission); + // Now go to edit. + this.goToEdit(); + + if (!this.assign!.submissiondrafts) { + // No drafts allowed, so it was submitted. Trigger event. + CoreEvents.trigger(AddonModAssignProvider.SUBMITTED_FOR_GRADING_EVENT, { + assignmentId: this.assign!.id, + submissionId: this.userSubmission!.id, + userId: this.currentUserId, + }, this.siteId); + } else { + // Invalidate and refresh data to update this view. + await this.invalidateAndRefresh(true); + } + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.error', true); + } finally { + modal.dismiss(); + } + } + + /** + * Discard feedback drafts. + * + * @return Promise resolved when done. + */ + protected async discardDrafts(): Promise { + if (this.feedback && this.feedback.plugins) { + await AddonModAssignHelper.instance.discardFeedbackPluginData(this.assign!.id, this.submitId, this.feedback); + } + } + + /** + * Go to the page to add or edit submission. + */ + goToEdit(): void { + CoreNavigator.instance.navigate('AddonModAssignEditPage', { + params: { + moduleId: this.moduleId, + courseId: this.courseId, + userId: this.submitId, + blindId: this.blindId, + } }); + } + + /** + * Check if there's data to save (grade). + * + * @param isSubmit Whether the user is about to submit the grade. + * @return Promise resolved with boolean: whether there's data to save. + */ + protected async hasDataToSave(isSubmit = false): Promise { + if (!this.canSaveGrades || !this.loaded) { + return false; + } + + if (isSubmit && this.hasOfflineGrade) { + // Always allow sending if the grade is saved in offline. + return true; + } + + // Check if numeric grade and toggles changed. + if (this.originalGrades.grade != this.grade.grade || this.originalGrades.addAttempt != this.grade.addAttempt || + this.originalGrades.applyToAll != this.grade.applyToAll) { + return true; + } + + // Check if outcomes changed. + if (this.gradeInfo && this.gradeInfo.outcomes) { + for (const x in this.gradeInfo.outcomes) { + const outcome = this.gradeInfo.outcomes[x]; + + if (this.originalGrades.outcomes[outcome.id] == 'undefined' || + this.originalGrades.outcomes[outcome.id] != outcome.selectedId) { + return true; + } + } + } + + if (!this.feedback?.plugins) { + return false; + } + + try { + return AddonModAssignHelper.instance.hasFeedbackDataChanged( + this.assign!, + this.userSubmission, + this.feedback, + this.submitId, + ); + } catch (error) { + // Error ocurred, consider there are no changes. + return false; + } + } + + /** + * User entered the page that contains the component. + */ + ionViewDidEnter(): void { + this.tabs?.ionViewDidEnter(); + } + + /** + * User left the page that contains the component. + */ + ionViewDidLeave(): void { + this.tabs?.ionViewDidLeave(); + } + + /** + * Invalidate and refresh data. + * + * @param sync Whether to try to synchronize data. + * @return Promise resolved when done. + */ + async invalidateAndRefresh(sync = false): Promise { + this.loaded = false; + + const promises: Promise[] = []; + + promises.push(AddonModAssign.instance.invalidateAssignmentData(this.courseId)); + if (this.assign) { + promises.push(AddonModAssign.instance.invalidateSubmissionStatusData( + this.assign!.id, + this.submitId, + undefined, + !!this.blindId, + )); + promises.push(AddonModAssign.instance.invalidateAssignmentUserMappingsData(this.assign!.id)); + promises.push(AddonModAssign.instance.invalidateListParticipantsData(this.assign!.id)); + } + promises.push(CoreGradesHelper.instance.invalidateGradeModuleItems(this.courseId, this.submitId)); + promises.push(CoreCourse.instance.invalidateModule(this.moduleId)); + + // Invalidate plugins. + if (this.submissionComponents && this.submissionComponents.length) { + this.submissionComponents.forEach((component) => { + promises.push(component.invalidate()); + }); + } + + await CoreUtils.instance.ignoreErrors(Promise.all(promises)); + + await this.loadData(sync); + } + + /** + * Load the data to render the submission. + * + * @param sync Whether to try to synchronize data. + * @return Promise resolved when done. + */ + protected async loadData(sync = false): Promise { + let isBlind = !!this.blindId; + + this.previousAttempt = undefined; + this.isPreviousAttemptEmpty = true; + + if (!this.submitId) { + this.submitId = this.currentUserId; + isBlind = false; + } + + try { + // Get the assignment. + this.assign = await AddonModAssign.instance.getAssignment(this.courseId, this.moduleId); + + if (this.submitId != this.currentUserId && sync) { + // Teacher viewing a student submission. Try to sync the assign, there could be offline grades stored. + try { + const result = await AddonModAssignSync.instance.syncAssign(this.assign.id); + + if (result && result.updated) { + CoreEvents.trigger(AddonModAssignSyncProvider.MANUAL_SYNCED, { + assignId: this.assign.id, + warnings: result.warnings, + gradesBlocked: result.gradesBlocked, + context: 'submission', + submitId: this.submitId, + }, this.siteId); + } + } catch (error) { + // Ignore errors, probably user is offline or sync is blocked. + } + } + + const time = CoreTimeUtils.instance.timestamp(); + let promises: Promise[] = []; + + if (this.assign.allowsubmissionsfromdate && this.assign.allowsubmissionsfromdate >= time) { + this.fromDate = CoreTimeUtils.instance.userDate(this.assign.allowsubmissionsfromdate * 1000); + } + + this.blindMarking = this.isSubmittedForGrading && !!this.assign.blindmarking && !this.assign.revealidentities; + + if (!this.blindMarking && this.submitId != this.currentUserId) { + promises.push(this.loadSubmissionUserProfile()); + } + + // Check if there's any offline data for this submission. + promises.push(this.loadSubmissionOfflineData()); + + await Promise.all(promises); + + // Get submission status. + const submissionStatus = + await AddonModAssign.instance.getSubmissionStatusWithRetry(this.assign, { userId: this.submitId, isBlind }); + + this.submissionStatusAvailable = true; + this.lastAttempt = submissionStatus.lastattempt; + this.membersToSubmit = []; + this.membersToSubmitBlind = []; + + // Search the previous attempt. + if (submissionStatus.previousattempts && submissionStatus.previousattempts.length > 0) { + const previousAttempts = submissionStatus.previousattempts.sort((a, b) => a.attemptnumber - b.attemptnumber); + this.previousAttempt = previousAttempts[previousAttempts.length - 1]; + this.isPreviousAttemptEmpty = + AddonModAssignHelper.instance.isSubmissionEmpty(this.assign, this.previousAttempt.submission); + } + + // Treat last attempt. + promises = this.treatLastAttempt(submissionStatus); + + // Calculate the time remaining. + this.calculateTimeRemaining(submissionStatus); + + // Load the feedback. + promises.push(this.loadFeedback(submissionStatus.feedback)); + + // Check if there's any unsupported plugin for editing. + if (!this.userSubmission || !this.userSubmission.plugins) { + // Submission not created yet, we have to use assign configs to detect the plugins used. + this.userSubmission = AddonModAssignHelper.instance.createEmptySubmission(); + this.userSubmission.plugins = AddonModAssignHelper.instance.getPluginsEnabled(this.assign, 'assignsubmission'); + } + + // Get the submission plugins that don't support editing. + promises.push(this.loadUnsupportedPlugins()); + + await Promise.all(promises); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'Error getting assigment data.'); + } finally { + this.loaded = true; + } + } + + /** + * Load profile of submission's user. + * + * @return Promise resolved when done. + */ + protected async loadSubmissionUserProfile(): Promise { + this.user = await CoreUser.instance.getProfile(this.submitId, this.courseId); + } + + /** + * Load offline data for the submission (not the submission grade). + * + * @return Promise resolved when done. + */ + protected async loadSubmissionOfflineData(): Promise { + try { + const submission = await AddonModAssignOffline.instance.getSubmission(this.assign!.id, this.submitId); + + this.hasOffline = submission && submission.plugindata && Object.keys(submission.plugindata).length > 0; + + this.submittedOffline = !!submission?.submitted; + } catch (error) { + // No offline data found. + this.hasOffline = false; + this.submittedOffline = false; + } + } + + /** + * Load the data to render the feedback and grade. + * + * @param feedback The feedback data from the submission status. + * @return Promise resolved when done. + */ + protected async loadFeedback(feedback?: AddonModAssignSubmissionFeedback): Promise { + this.grade = { + method: '', + modified: 0, + addAttempt : false, + applyToAll: false, + lang: '', + disabled: false, + }; + + this.originalGrades = { + addAttempt: false, + applyToAll: false, + outcomes: {}, + }; + + if (feedback) { + this.feedback = feedback; + + // If we have data about the grader, get its profile. + if (feedback.grade && feedback.grade.grader > 0) { + try { + this.grader = await CoreUser.instance.getProfile(feedback.grade.grader, this.courseId); + } catch { + // Ignore errors. + } + } else { + delete this.grader; + } + + // Check if the grade uses advanced grading. + if (feedback.gradefordisplay) { + const position = feedback.gradefordisplay.indexOf('class="advancedgrade"'); + if (position > -1) { + this.feedback.advancedgrade = true; + } + } + + // Do not override already loaded grade. + if (feedback.grade && feedback.grade.grade && !this.grade.grade) { + const parsedGrade = parseFloat(feedback.grade.grade); + + this.grade!.grade = parsedGrade >= 0 ? parsedGrade : undefined; + this.grade.gradebookGrade = CoreUtils.instance.formatFloat(this.grade.grade); + this.originalGrades.grade = this.grade.grade; + } + } else { + // If no feedback, always show Submission. + this.selectedTab = 'submission'; + this.tabs.selectTab(this.selectedTab); + } + + this.grade.gradingStatus = this.lastAttempt?.gradingstatus; + + // Get the grade for the assign. + this.gradeInfo = await CoreCourse.instance.getModuleBasicGradeInfo(this.moduleId); + + if (!this.gradeInfo) { + // It won't get gradeinfo on 3.1. + return; + } + + // Treat the grade info. + await this.treatGradeInfo(); + + const isManual = this.assign!.attemptreopenmethod == AddonModAssignProvider.ATTEMPT_REOPEN_METHOD_MANUAL; + const isUnlimited = this.assign!.maxattempts == AddonModAssignProvider.UNLIMITED_ATTEMPTS; + const isLessThanMaxAttempts = !!this.userSubmission && (this.userSubmission.attemptnumber < (this.assign!.maxattempts - 1)); + + this.allowAddAttempt = isManual && (!this.userSubmission || isUnlimited || isLessThanMaxAttempts); + + if (this.assign!.teamsubmission) { + this.grade.applyToAll = true; + this.originalGrades.applyToAll = true; + } + if (this.assign!.markingworkflow && this.grade.gradingStatus) { + this.workflowStatusTranslationId = + AddonModAssign.instance.getSubmissionGradingStatusTranslationId(this.grade.gradingStatus); + } + + if (this.lastAttempt?.gradingstatus == 'graded' && !this.assign!.markingworkflow) { + if (this.feedback!.gradeddate < this.lastAttempt!.submission!.timemodified) { + this.lastAttempt.gradingstatus = AddonModAssignProvider.GRADED_FOLLOWUP_SUBMIT; + + // Get grading text and color. + this.gradingStatusTranslationId = AddonModAssign.instance.getSubmissionGradingStatusTranslationId( + this.lastAttempt.gradingstatus, + ); + this.gradingColor = AddonModAssign.instance.getSubmissionGradingStatusColor(this.lastAttempt.gradingstatus); + + } + } + + if (!this.feedback || !this.feedback.plugins) { + // Feedback plugins not present, we have to use assign configs to detect the plugins used. + this.feedback = AddonModAssignHelper.instance.createEmptyFeedback(); + this.feedback.plugins = AddonModAssignHelper.instance.getPluginsEnabled(this.assign!, 'assignfeedback'); + } + + // Check if there's any offline data for this submission. + if (!this.canSaveGrades) { + // User cannot save grades in the app. Load the URL to grade it in browser. + const mod = await CoreCourse.instance.getModule(this.moduleId, this.courseId, undefined, true); + this.gradeUrl = mod.url + '&action=grader&userid=' + this.submitId; + + return; + } + + // Submission grades aren't identified by attempt number so it can retrieve the feedback for a previous attempt. + // The app will not treat that as an special case. + const submissionGrade = await CoreUtils.instance.ignoreErrors( + AddonModAssignOffline.instance.getSubmissionGrade(this.assign!.id, this.submitId), + ); + + this.hasOfflineGrade = false; + + // Load offline grades. + if (submissionGrade && (!feedback || !feedback.gradeddate || feedback.gradeddate < submissionGrade.timemodified)) { + // If grade has been modified from gradebook, do not use offline. + if ((this.grade.modified || 0) < submissionGrade.timemodified) { + this.hasOfflineGrade = true; + this.grade.grade = !this.grade.scale + ? CoreUtils.instance.formatFloat(submissionGrade.grade) + : submissionGrade.grade; + this.gradingStatusTranslationId = 'addon.mod_assign.gradenotsynced'; + this.gradingColor = ''; + this.originalGrades.grade = this.grade.grade; + } + + this.grade.applyToAll = !!submissionGrade.applytoall; + this.grade.addAttempt = !!submissionGrade.addattempt; + this.originalGrades.applyToAll = !!this.grade.applyToAll; + this.originalGrades.addAttempt = !!this.grade.addAttempt; + + if (submissionGrade.outcomes && Object.keys(submissionGrade.outcomes).length && this.gradeInfo?.outcomes) { + this.gradeInfo.outcomes.forEach((outcome) => { + if (typeof submissionGrade.outcomes[outcome.itemNumber!] != 'undefined') { + // If outcome has been modified from gradebook, do not use offline. + if (outcome.modified! < submissionGrade.timemodified) { + outcome.selectedId = submissionGrade.outcomes[outcome.itemNumber!]; + this.originalGrades.outcomes[outcome.id] = outcome.selectedId; + } + } + }); + } + } + } + + /** + * Get the submission plugins that don't support editing. + * + * @return Promise resolved when done. + */ + protected async loadUnsupportedPlugins(): Promise { + this.unsupportedEditPlugins = await AddonModAssign.instance.getUnsupportedEditPlugins(this.userSubmission?.plugins || []); + } + + /** + * Set the submission status name and class. + * + * @param status Submission status. + */ + protected setStatusNameAndClass(status: AddonModAssignGetSubmissionStatusWSResponse): void { + const translateService = Translate.instance; + + if (this.hasOffline || this.submittedOffline) { + // Offline data. + this.statusTranslated = translateService.instant('core.notsent'); + this.statusColor = 'warning'; + } else if (!this.assign!.teamsubmission) { + + // Single submission. + if (this.userSubmission && this.userSubmission.status != this.statusNew) { + this.statusTranslated = translateService.instant('addon.mod_assign.submissionstatus_' + this.userSubmission.status); + this.statusColor = AddonModAssign.instance.getSubmissionStatusColor(this.userSubmission.status); + } else { + if (!status.lastattempt?.submissionsenabled) { + this.statusTranslated = translateService.instant('addon.mod_assign.noonlinesubmissions'); + this.statusColor = AddonModAssign.instance.getSubmissionStatusColor('noonlinesubmissions'); + } else { + this.statusTranslated = translateService.instant('addon.mod_assign.noattempt'); + this.statusColor = AddonModAssign.instance.getSubmissionStatusColor('noattempt'); + } + } + } else { + + // Team submission. + if (!status.lastattempt?.submissiongroup && this.assign!.preventsubmissionnotingroup) { + this.statusTranslated = translateService.instant('addon.mod_assign.nosubmission'); + this.statusColor = AddonModAssign.instance.getSubmissionStatusColor('nosubmission'); + } else if (this.userSubmission && this.userSubmission.status != this.statusNew) { + this.statusTranslated = translateService.instant('addon.mod_assign.submissionstatus_' + this.userSubmission.status); + this.statusColor = AddonModAssign.instance.getSubmissionStatusColor(this.userSubmission.status); + } else { + if (!status.lastattempt?.submissionsenabled) { + this.statusTranslated = translateService.instant('addon.mod_assign.noonlinesubmissions'); + this.statusColor = AddonModAssign.instance.getSubmissionStatusColor('noonlinesubmissions'); + } else { + this.statusTranslated = translateService.instant('addon.mod_assign.nosubmission'); + this.statusColor = AddonModAssign.instance.getSubmissionStatusColor('nosubmission'); + } + } + } + } + + /** + * Show advanced grade. + */ + showAdvancedGrade(): void { + if (this.feedback && this.feedback.advancedgrade) { + CoreTextUtils.instance.viewText( + Translate.instance.instant('core.grades.grade'), + this.feedback.gradefordisplay, + { + component: AddonModAssignProvider.COMPONENT, + componentId: this.moduleId, + }, + ); + } + } + + /** + * Submit for grading. + * + * @param acceptStatement Whether the statement has been accepted. + */ + async submitForGrading(acceptStatement: boolean): Promise { + if (this.assign!.requiresubmissionstatement && !acceptStatement) { + CoreDomUtils.instance.showErrorModal('addon.mod_assign.acceptsubmissionstatement', true); + + return; + } + + try { + // Ask for confirmation. @todo plugin precheck_submission + await CoreDomUtils.instance.showConfirm(Translate.instance.instant('addon.mod_assign.confirmsubmission')); + + const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true); + + try { + await AddonModAssign.instance.submitForGrading( + this.assign!.id, + this.courseId, + acceptStatement, + this.userSubmission!.timemodified, + this.hasOffline, + ); + + // Submitted, trigger event. + CoreEvents.trigger(AddonModAssignProvider.SUBMITTED_FOR_GRADING_EVENT, { + assignmentId: this.assign!.id, + submissionId: this.userSubmission!.id, + userId: this.currentUserId, + }, this.siteId); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.error', true); + } finally { + modal.dismiss(); + } + } catch { + // Cancelled. + } + } + + /** + * Submit a grade and feedback. + * + * @return Promise resolved when done. + */ + async submitGrade(): Promise { + // Check if there's something to be saved. + const modified = await this.hasDataToSave(true); + if (!modified) { + return; + } + + const attemptNumber = this.userSubmission ? this.userSubmission.attemptnumber : -1; + const outcomes: Record = {}; + // Scale "no grade" uses -1 instead of 0. + const grade = this.grade.scale && this.grade.grade == 0 + ? -1 + : CoreUtils.instance.unformatFloat(this.grade.grade, true); + + if (grade === false) { + // Grade is invalid. + throw new CoreError(Translate.instance.instant('core.grades.badgrade')); + } + + const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true); + + (this.gradeInfo?.outcomes || []).forEach((outcome) => { + if (outcome.itemNumber) { + outcomes[outcome.itemNumber] = outcome.selectedId!; + } + }); + + let pluginData: AddonModAssignSavePluginData = {}; + try { + if (this.feedback && this.feedback.plugins) { + pluginData = + await AddonModAssignHelper.instance.prepareFeedbackPluginData(this.assign!.id, this.submitId, this.feedback); + } + + try { + // We have all the data, now send it. + await AddonModAssign.instance.submitGradingForm( + this.assign!.id, + this.submitId, + this.courseId, + grade || 0, + attemptNumber, + this.grade.addAttempt, + this.grade.gradingStatus || '', + this.grade.applyToAll, + outcomes, + pluginData, + ); + + // Data sent, discard draft. + await this.discardDrafts(); + } finally { + // Invalidate and refresh data. + this.invalidateAndRefresh(true); + + CoreEvents.trigger(AddonModAssignProvider.GRADED_EVENT, { + assignmentId: this.assign!.id, + submissionId: this.submitId, + userId: this.currentUserId, + }, this.siteId); + } + } finally { + // Select submission view. + this.tabs.selectTab('submission'); + modal.dismiss(); + } + } + + /** + * Treat the grade info. + * + * @return Promise resolved when done. + */ + protected async treatGradeInfo(): Promise { + if (!this.gradeInfo) { + return; + } + + this.isGrading = true; + + // Make sure outcomes is an array. + this.gradeInfo.outcomes = this.gradeInfo.outcomes || []; + + // Check if grading method is simple or not. + if (this.gradeInfo.advancedgrading && this.gradeInfo.advancedgrading[0] && + typeof this.gradeInfo.advancedgrading[0].method != 'undefined') { + this.grade.method = this.gradeInfo.advancedgrading[0].method || 'simple'; + } else { + this.grade.method = 'simple'; + } + + this.canSaveGrades = this.grade.method == 'simple'; // Grades can be saved if simple grading. + + if (this.gradeInfo.scale) { + this.grade.scale = + CoreUtils.instance.makeMenuFromList(this.gradeInfo.scale, Translate.instance.instant('core.nograde')); + } else { + // Format the grade. + this.grade.grade = CoreUtils.instance.formatFloat(this.grade.grade); + this.originalGrades.grade = this.grade.grade; + + // Get current language to format grade input field. + this.grade.lang = await CoreLang.instance.getCurrentLanguage(); + } + + // Treat outcomes. + if (this.gradeInfo.outcomes && AddonModAssign.instance.isOutcomesEditEnabled()) { + this.gradeInfo.outcomes.forEach((outcome) => { + if (outcome.scale) { + outcome.options = + CoreUtils.instance.makeMenuFromList( + outcome.scale, + Translate.instance.instant('core.grades.nooutcome'), + ); + } + outcome.selectedId = 0; + this.originalGrades.outcomes[outcome.id] = outcome.selectedId; + }); + } + + // Get grade items. + const grades = + await CoreGradesHelper.instance.getGradeModuleItems(this.courseId, this.moduleId, this.submitId); + + const outcomes: AddonModAssignGradeOutcome[] = []; + + grades.forEach((grade: CoreGradesFormattedItem) => { + if (!grade.outcomeid && !grade.scaleid) { + + const gradeFormatted = grade.gradeformatted || ''; + // Not using outcomes or scale, get the numeric grade. + if (this.grade.scale) { + this.grade.gradebookGrade = CoreUtils.instance.formatFloat( + CoreGradesHelper.instance.getGradeValueFromLabel(this.grade.scale, gradeFormatted), + ); + } else { + const parsedGrade = parseFloat(gradeFormatted); + this.grade.gradebookGrade = parsedGrade || parsedGrade == 0 + ? CoreUtils.instance.formatFloat(parsedGrade) + : undefined; + } + + this.grade.disabled = !!grade.gradeislocked || !!grade.gradeisoverridden; + this.grade.modified = grade.gradedategraded; + } else if (grade.outcomeid) { + + // Only show outcomes with info on it, outcomeid could be null if outcomes are disabled on site. + this.gradeInfo!.outcomes && this.gradeInfo!.outcomes.forEach((outcome) => { + if (outcome.id == String(grade.outcomeid)) { + outcome.selected = grade.gradeformatted; + outcome.modified = grade.gradedategraded; + if (outcome.options) { + outcome.selectedId = + CoreGradesHelper.instance.getGradeValueFromLabel(outcome.options, outcome.selected || ''); + this.originalGrades.outcomes[outcome.id] = outcome.selectedId; + outcome.itemNumber = grade.itemnumber; + } + outcomes.push(outcome); + } + }); + this.gradeInfo!.disabled = grade.gradeislocked || grade.gradeisoverridden; + } + }); + + this.gradeInfo.outcomes = outcomes; + } + + /** + * Treat the last attempt. + * + * @param submissionStatus Response of get submission status. + * @param promises List where to add the promises. + */ + protected treatLastAttempt(submissionStatus: AddonModAssignGetSubmissionStatusWSResponse): Promise[] { + const promises: Promise[] =[]; + + if (!submissionStatus.lastattempt) { + return []; + } + + const submissionStatementMissing = !!this.assign!.requiresubmissionstatement && + typeof this.assign!.submissionstatement == 'undefined'; + + this.canSubmit = !this.isSubmittedForGrading && !this.submittedOffline && (submissionStatus.lastattempt.cansubmit || + (this.hasOffline && AddonModAssign.instance.canSubmitOffline(this.assign!, submissionStatus))); + + this.canEdit = !this.isSubmittedForGrading && submissionStatus.lastattempt.canedit && + (!this.submittedOffline || !this.assign!.submissiondrafts); + + // Get submission statement if needed. + if (this.assign!.requiresubmissionstatement && this.assign!.submissiondrafts && this.submitId == this.currentUserId) { + this.submissionStatement = this.assign!.submissionstatement; + this.acceptStatement = false; + } else { + this.submissionStatement = undefined; + this.acceptStatement = true; // No submission statement, so it's accepted. + } + + // Show error if submission statement should be shown but it couldn't be retrieved. + this.showErrorStatementEdit = submissionStatementMissing && !this.assign!.submissiondrafts && + this.submitId == this.currentUserId; + + this.showErrorStatementSubmit = submissionStatementMissing && !!this.assign!.submissiondrafts; + + this.userSubmission = AddonModAssign.instance.getSubmissionObjectFromAttempt(this.assign!, submissionStatus.lastattempt); + + if (this.assign!.attemptreopenmethod != this.attemptReopenMethodNone && this.userSubmission) { + this.currentAttempt = this.userSubmission.attemptnumber + 1; + } + + this.setStatusNameAndClass(submissionStatus); + + if (this.assign!.teamsubmission) { + if (submissionStatus.lastattempt.submissiongroup) { + // Get the name of the group. + promises.push(CoreGroups.instance.getActivityAllowedGroups(this.assign!.cmid).then((result) => { + const group = result.groups.find((group) => group.id == submissionStatus.lastattempt!.submissiongroup); + if (group) { + this.lastAttempt!.submissiongroupname = group.name; + } + + return; + })); + } + + // Get the members that need to submit. + if (this.userSubmission && + this.userSubmission.status != this.statusNew && + submissionStatus.lastattempt.submissiongroupmemberswhoneedtosubmit + ) { + submissionStatus.lastattempt.submissiongroupmemberswhoneedtosubmit.forEach((member) => { + if (this.blindMarking) { + // Users not blinded! (Moodle < 3.1.1, 3.2). + promises.push(AddonModAssign.instance.getAssignmentUserMappings(this.assign!.id, member, { + cmId: this.moduleId, + }).then((blindId) => { + this.membersToSubmitBlind.push(blindId); + + return; + })); + } else { + promises.push(CoreUser.instance.getProfile(member, this.courseId).then((profile) => { + this.membersToSubmit.push(profile); + + return; + })); + } + }); + } + } + + // Get grading text and color. + this.gradingStatusTranslationId = AddonModAssign.instance.getSubmissionGradingStatusTranslationId( + submissionStatus.lastattempt.gradingstatus, + ); + this.gradingColor = AddonModAssign.instance.getSubmissionGradingStatusColor(submissionStatus.lastattempt.gradingstatus); + + // Get the submission plugins. + if (this.userSubmission) { + if (!this.assign!.teamsubmission || + !submissionStatus.lastattempt.submissiongroup || + !this.assign!.preventsubmissionnotingroup + ) { + if (this.previousAttempt && this.previousAttempt.submission!.plugins && + this.userSubmission.status == this.statusReopened) { + // Get latest attempt if avalaible. + this.submissionPlugins = this.previousAttempt.submission!.plugins; + } else { + this.submissionPlugins = this.userSubmission.plugins!; + } + } + } + + return promises; + } + + /** + * Block or unblock the automatic sync of the user grade. + * + * @param block Whether to block or unblock. + */ + protected setGradeSyncBlocked(block = false): void { + if (this.isDestroyed || !this.assign || !this.isGrading) { + return; + } + + const syncId = AddonModAssignSync.instance.getGradeSyncId(this.assign!.id, this.submitId); + + if (block) { + CoreSync.instance.blockOperation(AddonModAssignProvider.COMPONENT, syncId); + } else { + CoreSync.instance.unblockOperation(AddonModAssignProvider.COMPONENT, syncId); + } + } + + /** + * A certain tab has been selected, either manually or automatically. + * + * @param tab The tab that was selected. + */ + tabSelected(tab: CoreTabComponent): void { + // Block sync when selecting grade tab, unblock when leaving it. + this.setGradeSyncBlocked(tab.id === 'grade'); + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + this.setGradeSyncBlocked(false); + this.isDestroyed = true; + this.syncObserver?.off(); + } + +} + +/** + * Submission attempt with some calculated data. + */ +type AddonModAssignSubmissionAttemptFormatted = AddonModAssignSubmissionAttempt & { + submissiongroupname?: string; // Calculated in the app. Group name the attempt belongs to. +}; + +/** + * Feedback of an assign submission with some calculated data. + */ +type AddonModAssignSubmissionFeedbackFormatted = AddonModAssignSubmissionFeedback & { + advancedgrade?: boolean; // Calculated in the app. Whether it uses advanced grading. +}; + + +type AddonModAssignSubmissionGrade = { + method: string; + grade?: number | string; + gradebookGrade?: string; + modified?: number; + gradingStatus?: string; + addAttempt: boolean; + applyToAll: boolean; + scale?: CoreMenuItem[]; + lang: string; + disabled: boolean; +}; + +type AddonModAssignSubmissionOriginalGrades = { + grade?: number | string; + addAttempt: boolean; + applyToAll: boolean; + outcomes: Record; +}; + +type AddonModAssignGradeInfo = Omit & { + outcomes?: AddonModAssignGradeOutcome[]; + disabled?: boolean; +}; + +type AddonModAssignGradeOutcome = CoreCourseModuleGradeOutcome & { + selectedId?: number; + selected?: string; + modified?: number; + options?: CoreMenuItem[]; + itemNumber?: number; +}; diff --git a/src/addons/mod/assign/services/assign-helper.ts b/src/addons/mod/assign/services/assign-helper.ts index 08cc4c45f..fa84c6885 100644 --- a/src/addons/mod/assign/services/assign-helper.ts +++ b/src/addons/mod/assign/services/assign-helper.ts @@ -24,6 +24,8 @@ import { AddonModAssignParticipant, AddonModAssignSubmissionFeedback, AddonModAssign, + AddonModAssignPlugin, + AddonModAssignSavePluginData, } from './assign'; import { AddonModAssignOffline } from './assign-offline'; import { CoreUtils } from '@services/utils/utils'; @@ -98,7 +100,7 @@ export class AddonModAssignHelperProvider { * @return Promise resolved when done. */ async copyPreviousAttempt(assign: AddonModAssignAssign, previousSubmission: AddonModAssignSubmission): Promise { - const pluginData: any = {}; + const pluginData: AddonModAssignSavePluginData = {}; const promises = previousSubmission.plugins ? previousSubmission.plugins.map((plugin) => AddonModAssignSubmissionDelegate.instance.copyPluginSubmissionData(assign, plugin, pluginData)) @@ -121,8 +123,8 @@ export class AddonModAssignHelperProvider { createEmptyFeedback(): AddonModAssignSubmissionFeedback { return { grade: undefined, - gradefordisplay: undefined, - gradeddate: undefined, + gradefordisplay: '', + gradeddate: 0, }; } @@ -133,13 +135,13 @@ export class AddonModAssignHelperProvider { */ createEmptySubmission(): AddonModAssignSubmissionFormatted { return { - id: undefined, - userid: undefined, - attemptnumber: undefined, - timecreated: undefined, - timemodified: undefined, - status: undefined, - groupid: undefined, + id: 0, + userid: 0, + attemptnumber: 0, + timecreated: 0, + timemodified: 0, + status: '', + groupid: 0, }; } @@ -283,14 +285,15 @@ export class AddonModAssignHelperProvider { * @param subtype Subtype name (assignsubmission or assignfeedback) * @return List of enabled plugins for the assign. */ - getPluginsEnabled(assign: AddonModAssignAssign, subtype: string): AddonModAssignPluginsEnabled { - const enabled: AddonModAssignPluginsEnabled = []; + getPluginsEnabled(assign: AddonModAssignAssign, subtype: string): AddonModAssignPlugin[] { + const enabled: AddonModAssignPlugin[] = []; assign.configs.forEach((config) => { if (config.subtype == subtype && config.name == 'enabled' && parseInt(config.value, 10) === 1) { // Format the plugin objects. enabled.push({ type: config.plugin, + name: config.plugin, }); } }); @@ -564,7 +567,7 @@ export class AddonModAssignHelperProvider { userId: number, feedback: AddonModAssignSubmissionFeedback, siteId?: string, - ): Promise { + ): Promise { const pluginData = {}; const promises = feedback.plugins @@ -673,20 +676,22 @@ export class AddonModAssignHelperProvider { * @param siteId Site ID. If not defined, current site. * @return Promise resolved when done. */ - uploadOrStoreFiles( + async uploadOrStoreFiles( assignId: number, folderName: string, files: (CoreWSExternalFile | FileEntry)[], offline = false, userId?: number, siteId?: string, - ): Promise { + ): Promise { if (offline) { - return this.storeSubmissionFiles(assignId, folderName, files, userId, siteId); + await this.storeSubmissionFiles(assignId, folderName, files, userId, siteId); + + return; } - return this.uploadFiles(assignId, files, siteId); + await this.uploadFiles(assignId, files, siteId); } } @@ -697,14 +702,8 @@ export const AddonModAssignHelper = makeSingleton(AddonModAssignHelperProvider); * Assign submission with some calculated data. */ export type AddonModAssignSubmissionFormatted = - Omit & { - id?: number; // Submission id. + Omit & { userid?: number; // Student id. - attemptnumber?: number; // Attempt number. - timecreated?: number; // Submission creation time. - timemodified?: number; // Submission last modified time. - status?: string; // Submission status. - groupid?: number; // Group id. blindid?: number; // Calculated in the app. Blindid of the user that did the submission. submitid?: number; // Calculated in the app. Userid or blindid of the user that did the submission. userfullname?: string; // Calculated in the app. Full name of the user that did the submission. @@ -715,13 +714,6 @@ export type AddonModAssignSubmissionFormatted = }; /** - * Assingment subplugins type enabled. - */ -export type AddonModAssignPluginsEnabled = { - type: string; // Plugin type. -}[]; - -/** - * Assingment plugin config. + * Assignment plugin config. */ export type AddonModAssignPluginConfig = {[name: string]: string}; diff --git a/src/addons/mod/assign/services/assign-offline.ts b/src/addons/mod/assign/services/assign-offline.ts index 78da7092e..81a56a300 100644 --- a/src/addons/mod/assign/services/assign-offline.ts +++ b/src/addons/mod/assign/services/assign-offline.ts @@ -114,7 +114,7 @@ export class AddonModAssignOfflineProvider { * @return Promise resolved with submissions. */ async getAssignSubmissions(assignId: number, siteId?: string): Promise { - return this.getAssignSubmissionsFormatted({ assingid: assignId }, siteId); + return this.getAssignSubmissionsFormatted({ assignid: assignId }, siteId); } /** @@ -167,7 +167,7 @@ export class AddonModAssignOfflineProvider { assignId: number, siteId?: string, ): Promise { - return this.getAssignSubmissionsGradeFormatted({ assingid: assignId }, siteId); + return this.getAssignSubmissionsGradeFormatted({ assignid: assignId }, siteId); } /** diff --git a/src/addons/mod/assign/services/assign-sync.ts b/src/addons/mod/assign/services/assign-sync.ts index 5335cd234..cf78c7603 100644 --- a/src/addons/mod/assign/services/assign-sync.ts +++ b/src/addons/mod/assign/services/assign-sync.ts @@ -453,7 +453,7 @@ export class AddonModAssignSyncProvider extends CoreCourseActivitySyncBaseProvid const status = await AddonModAssign.instance.getSubmissionStatus(assign.id, options); - const timemodified = (status.feedback && (status.feedback.gradeddate || status.feedback.grade.timemodified)) || 0; + const timemodified = (status.feedback && (status.feedback.gradeddate || status.feedback.grade?.timemodified)) || 0; if (timemodified > offlineData.timemodified) { // The submission grade was modified in Moodle, discard it. @@ -480,7 +480,7 @@ export class AddonModAssignSyncProvider extends CoreCourseActivitySyncBaseProvid if (gradeInfo && gradeInfo.scale) { offlineData.grade = this.getSelectedScaleId(gradeInfo.scale, grade.grade || ''); } else { - offlineData.grade = parseFloat(grade.grade || '') || undefined; + offlineData.grade = parseFloat(grade.grade || ''); } } else if (gradeInfo && grade.outcomeid && AddonModAssign.instance.isOutcomesEditEnabled() && gradeInfo.outcomes) { gradeInfo.outcomes.forEach((outcome, index) => { diff --git a/src/addons/mod/assign/services/assign.ts b/src/addons/mod/assign/services/assign.ts index 8ac694051..2e793647f 100644 --- a/src/addons/mod/assign/services/assign.ts +++ b/src/addons/mod/assign/services/assign.ts @@ -1326,7 +1326,7 @@ export class AddonModAssignProvider { async submitGradingFormOnline( assignId: number, userId: number, - grade: number | undefined, + grade: number, attemptNumber: number, addAttempt: boolean, workflowState: string, @@ -1553,7 +1553,7 @@ export type AddonModAssignSubmissionPreviousAttempt = { * Feedback of an assign submission. */ export type AddonModAssignSubmissionFeedback = { - grade: AddonModAssignGrade; // Grade information. + grade?: AddonModAssignGrade; // Grade information. gradefordisplay: string; // Grade rendered into a format suitable for display. gradeddate: number; // The date the user was graded. plugins?: AddonModAssignPlugin[]; // Plugins info. @@ -1853,3 +1853,12 @@ type AddonModAssignSaveGradeWSParams = { * Assignment grade outcomes. */ export type AddonModAssignOutcomes = { [itemNumber: number]: number }; + +/** + * Data sent by SUBMITTED_FOR_GRADING_EVENT event. + */ +export type AddonModAssignSubmittedForGradingEventData = { + assignmentId: number; + submissionId: number; + userId: number; +}; diff --git a/src/addons/mod/assign/services/database/assign.ts b/src/addons/mod/assign/services/database/assign.ts index 703f3f27a..949a1e580 100644 --- a/src/addons/mod/assign/services/database/assign.ts +++ b/src/addons/mod/assign/services/database/assign.ts @@ -139,7 +139,7 @@ export type AddonModAssignSubmissionsGradingDBRecord = { assignid: number; // Primary key. userid: number; // Primary key. courseid: number; - grade?: number; // Real. + grade: number; // Real. attemptnumber: number; addattempt: number; workflowstate: string; diff --git a/src/addons/mod/assign/services/feedback-delegate.ts b/src/addons/mod/assign/services/feedback-delegate.ts index b21ca5f35..978739474 100644 --- a/src/addons/mod/assign/services/feedback-delegate.ts +++ b/src/addons/mod/assign/services/feedback-delegate.ts @@ -12,10 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Injectable } from '@angular/core'; +import { Injectable, Type } from '@angular/core'; import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate'; import { AddonModAssignDefaultFeedbackHandler } from './handlers/default-feedback'; -import { AddonModAssignAssign, AddonModAssignSubmission, AddonModAssignPlugin } from './assign'; +import { AddonModAssignAssign, AddonModAssignSubmission, AddonModAssignPlugin, AddonModAssignSavePluginData } from './assign'; import { makeSingleton } from '@singletons'; import { CoreWSExternalFile } from '@services/ws'; @@ -37,7 +37,7 @@ export interface AddonModAssignFeedbackHandler extends CoreDelegateHandler { * @param siteId Site ID. If not defined, current site. * @return If the function is async, it should return a Promise resolved when done. */ - discardDraft?(assignId: number, userId: number, siteId?: string): void | Promise; + discardDraft?(assignId: number, userId: number, siteId?: string): void | Promise; /** * Return the Component to use to display the plugin data. @@ -46,7 +46,8 @@ export interface AddonModAssignFeedbackHandler extends CoreDelegateHandler { * @param plugin The plugin object. * @return The component (or promise resolved with component) to use, undefined if not found. */ - getComponent?(plugin: AddonModAssignPlugin): any | Promise; + getComponent?(plugin: AddonModAssignPlugin): Type | undefined | Promise | undefined>; + /** * Return the draft saved data of the feedback plugin. @@ -126,7 +127,7 @@ export interface AddonModAssignFeedbackHandler extends CoreDelegateHandler { submission: AddonModAssignSubmission, plugin: AddonModAssignPlugin, siteId?: string, - ): Promise; + ): Promise; /** * Prepare and add to pluginData the data to send to the server based on the draft data saved. @@ -142,9 +143,9 @@ export interface AddonModAssignFeedbackHandler extends CoreDelegateHandler { assignId: number, userId: number, plugin: AddonModAssignPlugin, - pluginData: any, + pluginData: AddonModAssignSavePluginData, siteId?: string, - ): void | Promise; + ): void | Promise; /** * Save draft data of the feedback plugin. @@ -197,7 +198,7 @@ export class AddonModAssignFeedbackDelegateService extends CoreDelegate { + async getComponentForPlugin(plugin: AddonModAssignPlugin): Promise | undefined> { return await this.executeFunctionOnEnabled(plugin.type, 'getComponent', [plugin]); } @@ -317,7 +318,7 @@ export class AddonModAssignFeedbackDelegateService extends CoreDelegate { + ): Promise { return await this.executeFunctionOnEnabled(plugin.type, 'prefetch', [assign, submission, plugin, siteId]); } @@ -335,9 +336,9 @@ export class AddonModAssignFeedbackDelegateService extends CoreDelegate { + ): Promise { return await this.executeFunctionOnEnabled( plugin.type, diff --git a/src/addons/mod/assign/services/handlers/default-feedback.ts b/src/addons/mod/assign/services/handlers/default-feedback.ts index 5bfc76bd6..02451fd2c 100644 --- a/src/addons/mod/assign/services/handlers/default-feedback.ts +++ b/src/addons/mod/assign/services/handlers/default-feedback.ts @@ -13,6 +13,7 @@ // limitations under the License. import { Injectable } from '@angular/core'; +import { CoreWSExternalFile } from '@services/ws'; import { Translate } from '@singletons'; import { AddonModAssignPlugin } from '../assign'; import { AddonModAssignFeedbackHandler } from '../feedback-delegate'; @@ -35,16 +36,6 @@ export class AddonModAssignDefaultFeedbackHandler implements AddonModAssignFeedb // Nothing to do. } - /** - * Return the Component to use to display the plugin data. - * It's recommended to return the class of the component, but you can also return an instance of the component. - * - * @return The component (or promise resolved with component) to use, undefined if not found. - */ - getComponent(): void { - // Nothing to do. - } - /** * Return the draft saved data of the feedback plugin. * @@ -60,7 +51,7 @@ export class AddonModAssignDefaultFeedbackHandler implements AddonModAssignFeedb * * @return The files (or promise resolved with the files). */ - getPluginFiles(): any[] { + getPluginFiles(): CoreWSExternalFile[] { return []; } @@ -121,7 +112,7 @@ export class AddonModAssignDefaultFeedbackHandler implements AddonModAssignFeedb * * @return Promise resolved when done. */ - async prefetch(): Promise { + async prefetch(): Promise { return; } diff --git a/src/addons/mod/assign/services/handlers/default-submission.ts b/src/addons/mod/assign/services/handlers/default-submission.ts index a83032c1c..55e838efc 100644 --- a/src/addons/mod/assign/services/handlers/default-submission.ts +++ b/src/addons/mod/assign/services/handlers/default-submission.ts @@ -13,6 +13,7 @@ // limitations under the License. import { Injectable } from '@angular/core'; +import { CoreWSExternalFile } from '@services/ws'; import { Translate } from '@singletons'; import { AddonModAssignPlugin } from '../assign'; import { AddonModAssignSubmissionHandler } from '../submission-delegate'; @@ -72,23 +73,13 @@ export class AddonModAssignDefaultSubmissionHandler implements AddonModAssignSub // Nothing to do. } - /** - * Return the Component to use to display the plugin data, either in read or in edit mode. - * It's recommended to return the class of the component, but you can also return an instance of the component. - * - * @return The component (or promise resolved with component) to use, undefined if not found. - */ - getComponent(): void { - // Nothing to do. - } - /** * Get files used by this plugin. * The files returned by this function will be prefetched when the user prefetches the assign. * * @return The files (or promise resolved with the files). */ - getPluginFiles(): any[] { + getPluginFiles(): CoreWSExternalFile[] { return []; } @@ -176,7 +167,7 @@ export class AddonModAssignDefaultSubmissionHandler implements AddonModAssignSub * * @return Promise resolved when done. */ - async prefetch(): Promise { + async prefetch(): Promise { return; } diff --git a/src/addons/mod/assign/services/submission-delegate.ts b/src/addons/mod/assign/services/submission-delegate.ts index 81394a312..745ce1fff 100644 --- a/src/addons/mod/assign/services/submission-delegate.ts +++ b/src/addons/mod/assign/services/submission-delegate.ts @@ -12,10 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Injectable } from '@angular/core'; +import { Injectable, Type } from '@angular/core'; import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate'; import { AddonModAssignDefaultSubmissionHandler } from './handlers/default-submission'; -import { AddonModAssignAssign, AddonModAssignSubmission, AddonModAssignPlugin } from './assign'; +import { AddonModAssignAssign, AddonModAssignSubmission, AddonModAssignPlugin, AddonModAssignSavePluginData } from './assign'; import { makeSingleton } from '@singletons'; import { CoreWSExternalFile } from '@services/ws'; @@ -86,7 +86,7 @@ export interface AddonModAssignSubmissionHandler extends CoreDelegateHandler { copySubmissionData?( assign: AddonModAssignAssign, plugin: AddonModAssignPlugin, - pluginData: any, + pluginData: AddonModAssignSavePluginData, userId?: number, siteId?: string, ): void | Promise; @@ -120,7 +120,7 @@ export interface AddonModAssignSubmissionHandler extends CoreDelegateHandler { getComponent?( plugin: AddonModAssignPlugin, edit?: boolean, - ): any | Promise; + ): Type | undefined | Promise | undefined>; /** * Get files used by this plugin. @@ -233,7 +233,7 @@ export interface AddonModAssignSubmissionHandler extends CoreDelegateHandler { submission: AddonModAssignSubmission, plugin: AddonModAssignPlugin, inputData: any, - pluginData: any, + pluginData: AddonModAssignSavePluginData, offline?: boolean, userId?: number, siteId?: string, @@ -321,7 +321,7 @@ export class AddonModAssignSubmissionDelegateService extends CoreDelegate { @@ -363,7 +363,7 @@ export class AddonModAssignSubmissionDelegateService extends CoreDelegate { + async getComponentForPlugin(plugin: AddonModAssignPlugin, edit?: boolean): Promise | undefined> { return await this.executeFunctionOnEnabled(plugin.type, 'getComponent', [plugin, edit]); }