From f3ae04600fed921da50c57de01aefbaa4910f8d5 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Fri, 13 Apr 2018 08:16:14 +0200 Subject: [PATCH] MOBILE-2334 assign: Implement submission component --- .../assign/components/components.module.ts | 9 +- .../mod/assign/components/index/index.html | 3 +- .../mod/assign/components/index/index.ts | 13 +- .../components/submission/submission.html | 247 +++++ .../components/submission/submission.scss | 18 + .../components/submission/submission.ts | 924 ++++++++++++++++++ src/addon/mod/assign/providers/assign-sync.ts | 1 - src/components/tabs/tabs.html | 2 +- src/core/grades/lang/en.json | 2 + src/core/grades/providers/helper.ts | 44 + src/providers/utils/utils.ts | 29 + 11 files changed, 1280 insertions(+), 12 deletions(-) create mode 100644 src/addon/mod/assign/components/submission/submission.html create mode 100644 src/addon/mod/assign/components/submission/submission.scss create mode 100644 src/addon/mod/assign/components/submission/submission.ts diff --git a/src/addon/mod/assign/components/components.module.ts b/src/addon/mod/assign/components/components.module.ts index b1911094b..e45d5d817 100644 --- a/src/addon/mod/assign/components/components.module.ts +++ b/src/addon/mod/assign/components/components.module.ts @@ -18,12 +18,15 @@ import { IonicModule } from 'ionic-angular'; import { TranslateModule } from '@ngx-translate/core'; import { CoreComponentsModule } from '@components/components.module'; import { CoreDirectivesModule } from '@directives/directives.module'; +import { CorePipesModule } from '@pipes/pipes.module'; import { CoreCourseComponentsModule } from '@core/course/components/components.module'; import { AddonModAssignIndexComponent } from './index/index'; +import { AddonModAssignSubmissionComponent } from './submission/submission'; @NgModule({ declarations: [ - AddonModAssignIndexComponent + AddonModAssignIndexComponent, + AddonModAssignSubmissionComponent ], imports: [ CommonModule, @@ -31,12 +34,14 @@ import { AddonModAssignIndexComponent } from './index/index'; TranslateModule.forChild(), CoreComponentsModule, CoreDirectivesModule, + CorePipesModule, CoreCourseComponentsModule ], providers: [ ], exports: [ - AddonModAssignIndexComponent + AddonModAssignIndexComponent, + AddonModAssignSubmissionComponent ], entryComponents: [ AddonModAssignIndexComponent diff --git a/src/addon/mod/assign/components/index/index.html b/src/addon/mod/assign/components/index/index.html index c92079a16..8f0e96a3a 100644 --- a/src/addon/mod/assign/components/index/index.html +++ b/src/addon/mod/assign/components/index/index.html @@ -82,6 +82,7 @@ - + + diff --git a/src/addon/mod/assign/components/index/index.ts b/src/addon/mod/assign/components/index/index.ts index ea7497ff5..2a132806f 100644 --- a/src/addon/mod/assign/components/index/index.ts +++ b/src/addon/mod/assign/components/index/index.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, Optional, Injector } from '@angular/core'; +import { Component, Optional, Injector, ViewChild } from '@angular/core'; import { Content, NavController } from 'ionic-angular'; import { CoreGroupsProvider } from '@providers/groups'; import { CoreTimeUtilsProvider } from '@providers/utils/time'; @@ -22,6 +22,7 @@ import { AddonModAssignHelperProvider } from '../../providers/helper'; import { AddonModAssignOfflineProvider } from '../../providers/assign-offline'; import { AddonModAssignSyncProvider } from '../../providers/assign-sync'; import * as moment from 'moment'; +import { AddonModAssignSubmissionComponent } from '../submission/submission'; /** * Component that displays an assignment. @@ -31,6 +32,8 @@ import * as moment from 'moment'; templateUrl: 'index.html', }) export class AddonModAssignIndexComponent extends CoreCourseModuleMainActivityComponent { + @ViewChild(AddonModAssignSubmissionComponent) submissionComponent: AddonModAssignSubmissionComponent; + component = AddonModAssignProvider.COMPONENT; moduleName = 'assign'; @@ -238,11 +241,7 @@ export class AddonModAssignIndexComponent extends CoreCourseModuleMainActivityCo */ protected hasSyncSucceed(result: any): boolean { if (result.updated) { - // Sync done, trigger event. - this.eventsProvider.trigger(AddonModAssignSyncProvider.MANUAL_SYNCED, { - assignId: this.assign.id, - warnings: result.warnings - }, this.siteId); + this.submissionComponent && this.submissionComponent.invalidateAndRefresh(); } return result.updated; @@ -267,7 +266,7 @@ export class AddonModAssignIndexComponent extends CoreCourseModuleMainActivityCo } return Promise.all(promises).finally(() => { - // @todo $scope.$broadcast(mmaModAssignSubmissionInvalidatedEvent); + this.submissionComponent && this.submissionComponent.invalidateAndRefresh(); }); } diff --git a/src/addon/mod/assign/components/submission/submission.html b/src/addon/mod/assign/components/submission/submission.html new file mode 100644 index 000000000..81dbcf04a --- /dev/null +++ b/src/addon/mod/assign/components/submission/submission.html @@ -0,0 +1,247 @@ + + + + + + + +

{{ user.fullname }}

+ +
+ + + +

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

+ +
+ + + +

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

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

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

+

{{ userSubmission.timemodified * 1000 | coreFormatDate:"dfmediumdate" }}

+
+ + +

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

+

+
+ + +

+

+
+ + +

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

+

{{ assign.duedate * 1000 | coreFormatDate:"dfmediumdate" }}

+

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

+
+ + +

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

+

{{ assign.cutoffdate * 1000 | coreFormatDate:"dfmediumdate" }}

+
+ + +

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

+

{{ lastAttempt.extensionduedate * 1000 | coreFormatDate:"dfmediumdate" }}

+
+ + +

{{ '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.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 }}

+
+ + + + +

{{ user.fullname }}

+
+

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

+
+
+ + + +

{{ '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.grade' | translate }} + + {{grade.label}} + + + + + + {{ outcome.name }} + + {{grade.label}} + +

{{ outcome.selected }}

+
+ + + + + +

{{ '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:"dfmediumdate" }}

+
+
+ + +
+ +

{{ 'addon.mod_assign.cannotgradefromapp' | translate:{$a: moduleName} }}

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

+ {{lastAttempt.submissiongroupname}} + {{ 'addon.mod_assign.noteam' | translate }} + {{ 'addon.mod_assign.multipleteams' | translate }} + {{ 'addon.mod_assign.defaultteam' | translate }} +

+ + {{ statusTranslated }} + + + {{ gradingStatusTranslationId | translate }} + +
diff --git a/src/addon/mod/assign/components/submission/submission.scss b/src/addon/mod/assign/components/submission/submission.scss new file mode 100644 index 000000000..90c6890e8 --- /dev/null +++ b/src/addon/mod/assign/components/submission/submission.scss @@ -0,0 +1,18 @@ +addon-mod-assign-submission { + div.latesubmission, + div.overdue { + // @extend .core-danger-item; + } + + div.earlysubmission { + // @extend .core-success-item; + } + + div.submissioneditable p { + color: $red; + } + + .core-grading-summary .advancedgrade { + display: none; + } +} diff --git a/src/addon/mod/assign/components/submission/submission.ts b/src/addon/mod/assign/components/submission/submission.ts new file mode 100644 index 000000000..9383ea511 --- /dev/null +++ b/src/addon/mod/assign/components/submission/submission.ts @@ -0,0 +1,924 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 } from '@angular/core'; +import { NavController } from 'ionic-angular'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreAppProvider } from '@providers/app'; +import { CoreEventsProvider } from '@providers/events'; +import { CoreGroupsProvider } from '@providers/groups'; +import { CoreLangProvider } from '@providers/lang'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreSyncProvider } from '@providers/sync'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreTimeUtilsProvider } from '@providers/utils/time'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreCourseProvider } from '@core/course/providers/course'; +import { CoreFileUploaderHelperProvider } from '@core/fileuploader/providers/helper'; +import { CoreGradesHelperProvider } from '@core/grades/providers/helper'; +import { CoreUserProvider } from '@core/user/providers/user'; +import { AddonModAssignProvider } from '../../providers/assign'; +import { AddonModAssignHelperProvider } from '../../providers/helper'; +import { AddonModAssignOfflineProvider } from '../../providers/assign-offline'; +import * as moment from 'moment'; +import { CoreTabsComponent } from '@components/tabs/tabs'; +import { CoreSplitViewComponent } from '@components/split-view/split-view'; + +/** + * Component that displays an assignment submission. + */ +@Component({ + selector: 'addon-mod-assign-submission', + templateUrl: 'submission.html', +}) +export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy { + @ViewChild(CoreTabsComponent) tabs: CoreTabsComponent; + + @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: boolean | string; // Whether to display the grade tab at start. + + loaded: boolean; // Whether data has been loaded. + selectedTab: number; // Tab selected on start. + assign: any; // The assignment the submission belongs to. + userSubmission: any; // The submission object. + isSubmittedForGrading: boolean; // Whether the submission has been submitted for grading. + submitModel: any = {}; // Model where to store the data to submit (for grading). + feedback: any; // The feedback. + hasOffline: boolean; // Whether there is offline data. + submittedOffline: boolean; // Whether it was submitted in offline. + fromDate: string; // Readable date when the assign started accepting submissions. + currentAttempt: number; // The current attempt number. + maxAttemptsText: string; // The text for maximum attempts. + blindMarking: boolean; // Whether blind marking is enabled. + user: any; // The user. + lastAttempt: any; // The last attempt. + membersToSubmit: any[]; // Team members that need to submit the assignment. + canSubmit: boolean; // Whether the user can submit for grading. + canEdit: boolean; // Whether the user can edit the submission. + submissionStatement: string; // The submission statement. + showErrorStatementEdit: boolean; // Whether to show an error in edit due to submission statement. + showErrorStatementSubmit: boolean; // Whether to show an error in submit due to submission statement. + gradingStatusTranslationId: string; // Key of the text to display for the grading status. + gradingColor: string; // Color to apply to the grading status. + workflowStatusTranslationId: string; // Key of the text to display for the workflow status. + submissionPlugins: string[]; // List of submission plugins names. + timeRemaining: string; // Message about time remaining. + timeRemainingClass: string; // Class to apply to time remaining message. + statusTranslated: string; // Status. + statusColor: string; // Color to apply to the status. + unsupportedEditPlugins: string[]; // List of submission plugins that don't support edit. + grade: any; // Data about the grade. + grader: any; // Profile of the teacher that graded the submission. + gradeInfo: any; // Grade data for the assignment, retrieved from the server. + isGrading: boolean; // Whether the user is grading. + canSaveGrades: boolean; // Whether the user can save the grades. + allowAddAttempt: boolean; // 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: any; // The previous attempt. + protected submissionStatusAvailable: boolean; // Whether we were able to retrieve the submission status. + protected originalGrades: any = {}; // Object with the original grade data, to check for changes. + protected isDestroyed: boolean; // Whether the component has been destroyed. + + constructor(protected navCtrl: NavController, protected appProvider: CoreAppProvider, protected domUtils: CoreDomUtilsProvider, + sitesProvider: CoreSitesProvider, protected syncProvider: CoreSyncProvider, protected timeUtils: CoreTimeUtilsProvider, + protected textUtils: CoreTextUtilsProvider, protected translate: TranslateService, protected utils: CoreUtilsProvider, + protected eventsProvider: CoreEventsProvider, protected courseProvider: CoreCourseProvider, + protected fileUploaderHelper: CoreFileUploaderHelperProvider, protected gradesHelper: CoreGradesHelperProvider, + protected userProvider: CoreUserProvider, protected groupsProvider: CoreGroupsProvider, + protected langProvider: CoreLangProvider, protected assignProvider: AddonModAssignProvider, + protected assignHelper: AddonModAssignHelperProvider, protected assignOfflineProvider: AddonModAssignOfflineProvider, + @Optional() protected splitviewCtrl: CoreSplitViewComponent) { + + this.siteId = sitesProvider.getCurrentSiteId(); + this.currentUserId = sitesProvider.getCurrentSiteUserId(); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.selectedTab = this.showGrade && this.showGrade !== 'false' ? 1 : 0; + this.isSubmittedForGrading = !!this.submitId; + + this.loadData(); + } + + /** + * Calculate the time remaining message and class. + * + * @param {any} response Response of get submission status. + */ + protected calculateTimeRemaining(response: any): void { + if (this.assign.duedate > 0) { + const time = this.timeUtils.timestamp(), + dueDate = response.lastattempt && response.lastattempt.extensionduedate ? + response.lastattempt.extensionduedate : this.assign.duedate, + timeRemaining = dueDate - time; + + if (timeRemaining <= 0) { + if (!this.userSubmission || this.userSubmission.status != AddonModAssignProvider.SUBMISSION_STATUS_SUBMITTED) { + + if ((response.lastattempt && response.lastattempt.submissionsenabled) || + (response.gradingsummary && response.gradingsummary.submissionsenabled)) { + this.timeRemaining = this.translate.instant('addon.mod_assign.overdue', + {$a: this.timeUtils.formatDuration(-timeRemaining, 3) }); + this.timeRemainingClass = 'overdue'; + } else { + this.timeRemaining = this.translate.instant('addon.mod_assign.duedatereached'); + this.timeRemainingClass = ''; + } + } else { + + const timeSubmittedDiff = this.userSubmission.timemodified - dueDate; + if (timeSubmittedDiff > 0) { + this.timeRemaining = this.translate.instant('addon.mod_assign.submittedlate', + {$a: this.timeUtils.formatDuration(timeSubmittedDiff, 2) }); + this.timeRemainingClass = 'latesubmission'; + } else { + this.timeRemaining = this.translate.instant('addon.mod_assign.submittedearly', + {$a: this.timeUtils.formatDuration(-timeSubmittedDiff, 2) }); + this.timeRemainingClass = 'earlysubmission'; + } + } + } else { + this.timeRemaining = this.timeUtils.formatDuration(timeRemaining, 3); + this.timeRemainingClass = ''; + } + } else { + this.timeRemaining = ''; + this.timeRemainingClass = ''; + } + } + + /** + * Check if the user can leave the view. If there are changes to be saved, it will ask for confirm. + * + * @return {Promise} Promise resolved if can leave the view, rejected otherwise. + */ + canLeave(): Promise { + // Check if there is data to save. + return this.hasDataToSave().then((modified) => { + if (modified) { + // Modified, confirm user wants to go back. + return this.domUtils.showConfirm(this.translate.instant('core.confirmcanceledit')).then(() => { + return this.discardDrafts().catch(() => { + // Ignore errors. + }); + }); + } + }); + } + + /** + * Copy a previous attempt and then go to edit. + */ + copyPrevious(): void { + if (!this.appProvider.isOnline()) { + this.domUtils.showErrorModal('mm.core.networkerrormsg', true); + + return; + } + + if (!this.previousAttempt) { + // Cannot access previous attempts, just go to edit. + return this.goToEdit(); + } + + const previousSubmission = this.assignProvider.getSubmissionObjectFromAttempt(this.assign, this.previousAttempt); + let modal = this.domUtils.showModalLoading(); + + this.assignHelper.getSubmissionSizeForCopy(this.assign, previousSubmission).catch(() => { + // Error calculating size, return -1. + return -1; + }).then((size) => { + modal.dismiss(); + + // Confirm action. + return this.fileUploaderHelper.confirmUploadFile(size, true); + }).then(() => { + // User confirmed, copy the attempt. + modal = this.domUtils.showModalLoading('core.sending', true); + + this.assignHelper.copyPreviousAttempt(this.assign, previousSubmission).then(() => { + // Now go to edit. + this.goToEdit(); + + // Invalidate and refresh data to update this view. + this.invalidateAndRefresh(); + + if (!this.assign.submissiondrafts) { + // No drafts allowed, so it was submitted. Trigger event. + this.eventsProvider.trigger(AddonModAssignProvider.SUBMITTED_FOR_GRADING_EVENT, { + assignmentId: this.assign.id, + submissionId: this.userSubmission.id, + userId: this.currentUserId + }, this.siteId); + } + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'core.error', true); + }).finally(() => { + modal.dismiss(); + }); + }); + } + + /** + * Discard feedback drafts. + * + * @return {Promise} Promise resolved when done. + */ + protected discardDrafts(): Promise { + if (this.feedback && this.feedback.plugins) { + return this.assignHelper.discardFeedbackPluginData(this.assign.id, this.submitId, this.feedback); + } + + return Promise.resolve(); + } + + /** + * Go to the page to add or edit submission. + */ + goToEdit(): void { + this.navCtrl.push('AddonModAssignEditPage', { + moduleId: this.moduleId, + courseId: this.courseId, + userId: this.submitId, + blindId: this.blindId + }); + } + + /** + * Check if there's data to save (grade). + * + * @return {Promise} Promise resolved with boolean: whether there's data to save. + */ + protected hasDataToSave(): Promise { + if (!this.canSaveGrades || !this.loaded) { + return Promise.resolve(false); + } + + // 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 Promise.resolve(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 Promise.resolve(true); + } + } + } + + if (this.feedback && this.feedback.plugins) { + return this.assignHelper.hasFeedbackDataChanged(this.assign, this.submitId, this.feedback).catch(() => { + // Error ocurred, consider there are no changes. + return false; + }); + } + + return Promise.resolve(false); + } + + /** + * Invalidate and refresh data. + * + * @return {Promise} Promise resolved when done. + */ + invalidateAndRefresh(): Promise { + this.loaded = false; + + const promises = []; + + promises.push(this.assignProvider.invalidateAssignmentData(this.courseId)); + if (this.assign) { + promises.push(this.assignProvider.invalidateSubmissionStatusData(this.assign.id, this.submitId, !!this.blindId)); + promises.push(this.assignProvider.invalidateAssignmentUserMappingsData(this.assign.id)); + promises.push(this.assignProvider.invalidateListParticipantsData(this.assign.id)); + } + promises.push(this.gradesHelper.invalidateGradeModuleItems(this.courseId, this.submitId)); + promises.push(this.courseProvider.invalidateModule(this.moduleId)); + + return Promise.all(promises).catch(() => { + // Ignore errors. + }).then(() => { + return this.loadData(); + }); + } + + /** + * Load the data to render the submission. + * + * @return {Promise} Promise resolved when done. + */ + protected loadData(): Promise { + let isBlind = !!this.blindId; + + this.previousAttempt = undefined; + + if (!this.submitId) { + this.submitId = this.currentUserId; + isBlind = false; + } + + // Get the assignment. + return this.assignProvider.getAssignment(this.courseId, this.moduleId).then((assign) => { + const time = this.timeUtils.timestamp(), + promises = []; + + this.assign = assign; + + if (assign.allowsubmissionsfromdate && assign.allowsubmissionsfromdate >= time) { + this.fromDate = moment(assign.allowsubmissionsfromdate * 1000).format(this.translate.instant('core.dfmediumdate')); + } + + this.currentAttempt = 0; + this.maxAttemptsText = this.translate.instant('addon.mod_assign.unlimitedattempts'); + this.blindMarking = this.isSubmittedForGrading && assign.blindmarking && !assign.revealidentities; + + if (!this.blindMarking && this.submitId != this.currentUserId) { + promises.push(this.userProvider.getProfile(this.submitId, this.courseId).then((profile) => { + this.user = profile; + })); + } + + // Check if there's any offline data for this submission. + promises.push(this.assignOfflineProvider.getSubmission(assign.id, this.submitId).then((data) => { + this.hasOffline = data && data.plugindata && Object.keys(data.plugindata).length > 0; + this.submittedOffline = data && data.submitted; + }).catch(() => { + // No offline data found. + this.hasOffline = false; + this.submittedOffline = false; + })); + + return Promise.all(promises); + }).then(() => { + // Get submission status. + return this.assignProvider.getSubmissionStatus(this.assign.id, this.submitId, isBlind); + }).then((response) => { + + const promises = []; + + this.submissionStatusAvailable = true; + this.lastAttempt = response.lastattempt; + this.membersToSubmit = []; + + // Search the previous attempt. + if (response.previousattempts && response.previousattempts.length > 0) { + const previousAttempts = response.previousattempts.sort((a, b) => { + return a.attemptnumber - b.attemptnumber; + }); + this.previousAttempt = previousAttempts[previousAttempts.length - 1]; + } + + // Treat last attempt. + this.treatLastAttempt(response, promises); + + // Calculate the time remaining. + this.calculateTimeRemaining(response); + + // Load the feedback. + promises.push(this.loadFeedback(response.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 = {}; + this.userSubmission.plugins = this.assignHelper.getPluginsEnabled(this.assign, 'assignsubmission'); + } + + // Get the submission plugins that don't support editing. + promises.push(this.assignProvider.getUnsupportedEditPlugins(this.userSubmission.plugins).then((list) => { + this.unsupportedEditPlugins = list; + })); + + return Promise.all(promises); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error getting assigment data.'); + }).finally(() => { + this.loaded = true; + }); + } + + /** + * Load the data to render the feedback and grade. + * + * @param {any} feedback The feedback data from the submission status. + * @return {Promise} Promise resolved when done. + */ + protected loadFeedback(feedback: any): Promise { + this.grade = { + method: false, + grade: false, + modified: 0, + gradingStatus: false, + addAttempt : false, + applyToAll: false, + scale: false, + lang: false + }; + + this.originalGrades = { + grade: false, + 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) { + this.userProvider.getProfile(feedback.grade.grader, this.courseId).then((profile) => { + this.grader = profile; + }).catch(() => { + // Ignore errors. + }); + } + + // 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 || parsedGrade == 0 ? parsedGrade : null; + } + } else { + // If no feedback, always show Submission. + this.selectedTab = 0; + this.tabs.selectTab(0); + } + + this.grade.gradingStatus = this.lastAttempt && this.lastAttempt.gradingstatus; + + // Get the grade for the assign. + return this.courseProvider.getModuleBasicGradeInfo(this.moduleId).then((gradeInfo) => { + this.gradeInfo = gradeInfo; + + if (!gradeInfo) { + return; + } + + if (!this.isDestroyed) { + // Block the assignment. + this.syncProvider.blockOperation(AddonModAssignProvider.COMPONENT, this.assign.id); + } + + // Treat the grade info. + return this.treatGradeInfo(); + }).then(() => { + if (!this.isGrading) { + return; + } + + const isManual = this.assign.attemptreopenmethod == AddonModAssignProvider.ATTEMPT_REOPEN_METHOD_MANUAL, + isUnlimited = this.assign.maxattempts == AddonModAssignProvider.UNLIMITED_ATTEMPTS, + 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 = + this.assignProvider.getSubmissionGradingStatusTranslationId(this.grade.gradingStatus); + } + + if (!this.feedback || !this.feedback.plugins) { + // Feedback plugins not present, we have to use assign configs to detect the plugins used. + this.feedback = {}; + this.feedback.plugins = this.assignHelper.getPluginsEnabled(this.assign, 'assignfeedback'); + } + + // Check if there's any offline data for this submission. + if (this.canSaveGrades) { + // 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. + return this.assignOfflineProvider.getSubmissionGrade(this.assign.id, this.submitId).catch(() => { + // Grade not found. + }).then((data) => { + + // Load offline grades. + if (data && (!feedback || !feedback.gradeddate || feedback.gradeddate < data.timemodified)) { + // If grade has been modified from gradebook, do not use offline. + if (this.grade.modified < data.timemodified) { + this.grade.grade = data.grade; + this.gradingStatusTranslationId = 'addon.mod_assign.gradenotsynced'; + this.gradingColor = ''; + this.originalGrades.grade = this.grade.grade; + } + + this.grade.applyToAll = data.applytoall; + this.grade.addAttempt = data.addattempt; + this.originalGrades.applyToAll = this.grade.applyToAll; + this.originalGrades.addAttempt = this.grade.addAttempt; + + if (data.outcomes && Object.keys(data.outcomes).length) { + this.gradeInfo.outcomes.forEach((outcome) => { + if (typeof data.outcomes[outcome.itemNumber] != 'undefined') { + // If outcome has been modified from gradebook, do not use offline. + if (outcome.modified < data.timemodified) { + outcome.selectedId = data.outcomes[outcome.itemNumber]; + this.originalGrades.outcomes[outcome.id] = outcome.selectedId; + } + } + }); + } + } + }); + } else { + // User cannot save grades in the app. Load the URL to grade it in browser. + return this.courseProvider.getModule(this.moduleId, this.courseId, undefined, true).then((mod) => { + this.gradeUrl = mod.url + '&action=grader&userid=' + this.submitId; + }); + } + }); + } + + /** + * Open a user profile. + * + * @param {number} userId User to open. + */ + openUserProfile(userId: number): void { + // Open a user profile. If this component is inside a split view, use the master nav to open it. + const navCtrl = this.splitviewCtrl ? this.splitviewCtrl.getMasterNav() : this.navCtrl; + navCtrl.push('CoreUserProfilePage', { userId: userId, courseId: this.courseId }); + } + + /** + * Set the submission status name and class. + * + * @param {any} status Submission status. + */ + protected setStatusNameAndClass(status: any): void { + if (this.hasOffline) { + // Offline data. + this.statusTranslated = this.translate.instant('core.notsent'); + this.statusColor = 'warning'; + } else if (!this.assign.teamsubmission) { + + // Single submission. + if (this.userSubmission && this.userSubmission.status != this.statusNew) { + this.statusTranslated = this.translate.instant('addon.mod_assign.submissionstatus_' + this.userSubmission.status); + this.statusColor = this.assignProvider.getSubmissionStatusColor(this.userSubmission.status); + } else { + if (!status.lastattempt.submissionsenabled) { + this.statusTranslated = this.translate.instant('addon.mod_assign.noonlinesubmissions'); + this.statusColor = this.assignProvider.getSubmissionStatusColor('noonlinesubmissions'); + } else { + this.statusTranslated = this.translate.instant('addon.mod_assign.noattempt'); + this.statusColor = this.assignProvider.getSubmissionStatusColor('noattempt'); + } + } + } else { + + // Team submission. + if (!status.lastattempt.submissiongroup && this.assign.preventsubmissionnotingroup) { + this.statusTranslated = this.translate.instant('addon.mod_assign.nosubmission'); + this.statusColor = this.assignProvider.getSubmissionStatusColor('nosubmission'); + } else if (this.userSubmission && this.userSubmission.status != this.statusNew) { + this.statusTranslated = this.translate.instant('addon.mod_assign.submissionstatus_' + this.userSubmission.status); + this.statusColor = this.assignProvider.getSubmissionStatusColor(this.userSubmission.status); + } else { + if (!status.lastattempt.submissionsenabled) { + this.statusTranslated = this.translate.instant('addon.mod_assign.noonlinesubmissions'); + this.statusColor = this.assignProvider.getSubmissionStatusColor('noonlinesubmissions'); + } else { + this.statusTranslated = this.translate.instant('addon.mod_assign.nosubmission'); + this.statusColor = this.assignProvider.getSubmissionStatusColor('nosubmission'); + } + } + } + } + + /** + * Show advanced grade. + */ + showAdvancedGrade(): void { + if (this.feedback && this.feedback.advancedgrade) { + this.textUtils.expandText(this.translate.instant('core.grades.grade'), this.feedback.gradefordisplay, + AddonModAssignProvider.COMPONENT, this.moduleId); + } + } + + /** + * Submit for grading. + * + * @param {boolean} acceptStatement Whether the statement has been accepted. + */ + submitForGrading(acceptStatement: boolean): void { + if (this.assign.requiresubmissionstatement && !acceptStatement) { + this.domUtils.showErrorModal('addon.mod_assign.acceptsubmissionstatement', true); + + return; + } + + // Ask for confirmation. @todo plugin precheck_submission + this.domUtils.showConfirm(this.translate.instant('addon.mod_assign.confirmsubmission')).then(() => { + const modal = this.domUtils.showModalLoading('core.sending', true); + + this.assignProvider.submitForGrading(this.assign.id, this.courseId, acceptStatement, this.userSubmission.timemodified, + this.hasOffline).then(() => { + + // Invalidate and refresh data. + this.invalidateAndRefresh(); + + // Submitted, trigger event. + this.eventsProvider.trigger(AddonModAssignProvider.SUBMITTED_FOR_GRADING_EVENT, { + assignmentId: this.assign.id, + submissionId: this.userSubmission.id, + userId: this.currentUserId + }, this.siteId); + }).finally(() => { + modal.dismiss(); + }); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'core.error', true); + }); + } + + /** + * Submit a grade and feedback. + * + * @return {Promise} Promise resolved when done. + */ + submitGrade(): Promise { + // Check if there's something to be saved. + return this.hasDataToSave().then((modified) => { + if (!modified) { + return; + } + + const attemptNumber = this.userSubmission ? this.userSubmission.attemptnumber : -1, + outcomes = {}, + // Scale "no grade" uses -1 instead of 0. + grade = this.grade.scale && this.grade.grade == 0 ? -1 : this.utils.unformatFloat(this.grade.grade); + + if (grade === false) { + // Grade is invalid. + return Promise.reject(this.translate.instant('core.grades.badgrade')); + } + + const modal = this.domUtils.showModalLoading('core.sending', true); + let pluginPromise; + + this.gradeInfo.outcomes.forEach((outcome) => { + if (outcome.itemNumber) { + outcomes[outcome.itemNumber] = outcome.selectedId; + } + }); + + if (this.feedback && this.feedback.plugins) { + pluginPromise = this.assignHelper.prepareFeedbackPluginData(this.assign.id, this.submitId, this.feedback); + } else { + pluginPromise = Promise.resolve({}); + } + + return pluginPromise.then((pluginData) => { + // We have all the data, now send it. + return this.assignProvider.submitGradingForm(this.assign.id, this.submitId, this.courseId, grade, attemptNumber, + this.grade.addAttempt, this.grade.gradingStatus, this.grade.applyToAll, outcomes, pluginData).then(() => { + + // Data sent, discard draft. + return this.discardDrafts(); + }).finally(() => { + // Invalidate and refresh data. + this.invalidateAndRefresh(); + + this.eventsProvider.trigger(AddonModAssignProvider.GRADED_EVENT, { + assignmentId: this.assign.id, + submissionId: this.submitId, + userId: this.currentUserId + }, this.siteId); + }); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'core.error', true); + }).finally(() => { + // Select submission view. + this.tabs.selectTab(0); + modal.dismiss(); + }); + }); + } + + /** + * Treat the grade info. + * + * @return {Promise} Promise resolved when done. + */ + protected treatGradeInfo(): Promise { + // 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.isGrading = true; + this.canSaveGrades = this.grade.method == 'simple'; // Grades can be saved if simple grading. + + if (this.gradeInfo.scale) { + this.grade.scale = this.utils.makeMenuFromList(this.gradeInfo.scale, this.translate.instant('core.nograde')); + } else { + // Get current language to format grade input field. + this.langProvider.getCurrentLanguage().then((lang) => { + this.grade.lang = lang; + }); + } + + // Treat outcomes. + if (this.assignProvider.isOutcomesEditEnabled()) { + this.gradeInfo.outcomes.forEach((outcome) => { + if (outcome.scale) { + outcome.options = + this.utils.makeMenuFromList(outcome.scale, this.translate.instant('core.grades.nooutcome')); + } + outcome.selectedId = 0; + this.originalGrades.outcomes[outcome.id] = outcome.selectedId; + }); + } + + // Get grade items. + return this.gradesHelper.getGradeModuleItems(this.courseId, this.moduleId, this.submitId).then((grades) => { + const outcomes = []; + + grades.forEach((grade) => { + if (!grade.outcomeid && !grade.scaleid) { + + // Not using outcomes or scale, get the numeric grade. + if (this.grade.scale) { + this.grade.grade = this.gradesHelper.getGradeValueFromLabel(this.grade.scale, grade.gradeformatted); + } else { + const parsedGrade = parseFloat(grade.gradeformatted); + this.grade.grade = parsedGrade || parsedGrade == 0 ? parsedGrade : null; + } + + this.grade.modified = grade.gradedategraded; + this.originalGrades.grade = this.grade.grade; + } else if (grade.outcomeid) { + + // Only show outcomes with info on it, outcomeid could be null if outcomes are disabled on site. + this.gradeInfo.outcomes.forEach((outcome) => { + if (outcome.id == grade.outcomeid) { + outcome.selected = grade.gradeformatted; + outcome.modified = grade.gradedategraded; + if (outcome.options) { + outcome.selectedId = this.gradesHelper.getGradeValueFromLabel(outcome.options, outcome.selected); + this.originalGrades.outcomes[outcome.id] = outcome.selectedId; + outcome.itemNumber = grade.itemnumber; + } + outcomes.push(outcome); + } + }); + } + }); + + this.gradeInfo.outcomes = outcomes; + }); + } + + /** + * Treat the last attempt. + * + * @param {any} response Response of get submission status. + * @param {any[]} promises List where to add the promises. + */ + protected treatLastAttempt(response: any, promises: any[]): void { + if (!response.lastattempt) { + return; + } + + const submissionStatementMissing = this.assign.requiresubmissionstatement && + typeof this.assign.submissionstatement == 'undefined'; + + this.canSubmit = !this.isSubmittedForGrading && !this.submittedOffline && (response.lastattempt.cansubmit || + (this.hasOffline && this.assignProvider.canSubmitOffline(this.assign, response))); + this.canEdit = !this.isSubmittedForGrading && response.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.submitModel.submissionStatement = false; + } else { + this.submissionStatement = undefined; + this.submitModel.submissionStatement = 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 = this.assignProvider.getSubmissionObjectFromAttempt(this.assign, response.lastattempt); + + if (this.assign.attemptreopenmethod != this.attemptReopenMethodNone && this.userSubmission) { + this.currentAttempt = this.userSubmission.attemptnumber + 1; + } + + this.setStatusNameAndClass(response); + + if (this.assign.teamsubmission) { + if (response.lastattempt.submissiongroup) { + // Get the name of the group. + promises.push(this.groupsProvider.getActivityAllowedGroups(this.assign.cmid).then((groups) => { + groups.forEach((group) => { + if (group.id == response.lastattempt.submissiongroup) { + this.lastAttempt.submissiongroupname = group.name; + } + }); + })); + } + + // Get the members that need to submit. + if (this.userSubmission && this.userSubmission.status != this.statusNew) { + response.lastattempt.submissiongroupmemberswhoneedtosubmit.forEach((member) => { + if (this.blindMarking) { + // Users not blinded! (Moodle < 3.1.1, 3.2). + promises.push(this.assignProvider.getAssignmentUserMappings(this.assign.id, member).then((blindId) => { + this.membersToSubmit.push(blindId); + })); + } else { + promises.push(this.userProvider.getProfile(member, this.courseId).then((profile) => { + this.membersToSubmit.push(profile); + })); + } + }); + + response.lastattempt.submissiongroupmemberswhoneedtosubmitblind.forEach((member) => { + this.membersToSubmit.push(member); + }); + } + } + + // Get grading text and color. + this.gradingStatusTranslationId = this.assignProvider.getSubmissionGradingStatusTranslationId( + response.lastattempt.gradingstatus); + this.gradingColor = this.assignProvider.getSubmissionGradingStatusColor(response.lastattempt.gradingstatus); + + // Get the submission plugins. + if (this.userSubmission) { + if (!this.assign.teamsubmission || !response.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; + } + } + } + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + this.isDestroyed = true; + + if (this.assign && this.isGrading) { + this.syncProvider.unblockOperation(AddonModAssignProvider.COMPONENT, this.assign.id); + } + } +} diff --git a/src/addon/mod/assign/providers/assign-sync.ts b/src/addon/mod/assign/providers/assign-sync.ts index 1f43c7dde..af891b29c 100644 --- a/src/addon/mod/assign/providers/assign-sync.ts +++ b/src/addon/mod/assign/providers/assign-sync.ts @@ -52,7 +52,6 @@ export interface AddonModAssignSyncResult { export class AddonModAssignSyncProvider extends CoreSyncBaseProvider { static AUTO_SYNCED = 'addon_mod_assign_autom_synced'; - static MANUAL_SYNCED = 'addon_mod_assign_manual_synced'; static SYNC_TIME = 300000; protected componentTranslate: string; diff --git a/src/components/tabs/tabs.html b/src/components/tabs/tabs.html index 51e2fe3ba..7c9aea9a6 100644 --- a/src/components/tabs/tabs.html +++ b/src/components/tabs/tabs.html @@ -1,5 +1,5 @@ -
+
diff --git a/src/core/grades/lang/en.json b/src/core/grades/lang/en.json index 720a51127..e6eb0bba2 100644 --- a/src/core/grades/lang/en.json +++ b/src/core/grades/lang/en.json @@ -1,5 +1,6 @@ { "average": "Average", + "badgrade": "Supplied grade is invalid", "contributiontocoursetotal": "Contribution to course total", "feedback": "Feedback", "grade": "Grade", @@ -7,6 +8,7 @@ "grades": "Grades", "lettergrade": "Letter grade", "nogradesreturned": "No grades returned", + "nooutcome": "No outcome", "percentage": "Percentage", "range": "Range", "rank": "Rank", diff --git a/src/core/grades/providers/helper.ts b/src/core/grades/providers/helper.ts index a91a9d8bc..4f40b6caf 100644 --- a/src/core/grades/providers/helper.ts +++ b/src/core/grades/providers/helper.ts @@ -234,6 +234,29 @@ export class CoreGradesHelperProvider { }); } + /** + * Returns the label of the selected grade. + * + * @param {any[]} grades Array with objects with value and label. + * @param {number} selectedGrade Selected grade value. + * @return {string} Selected grade label. + */ + getGradeLabelFromValue(grades: any[], selectedGrade: number): string { + selectedGrade = Number(selectedGrade); + + if (!grades || !selectedGrade || selectedGrade <= 0) { + return ''; + } + + for (const x in grades) { + if (grades[x].value == selectedGrade) { + return grades[x].label; + } + } + + return ''; + } + /** * Get the grade items for a certain module. Keep in mind that may have more than one item to include outcomes and scales. * @@ -266,6 +289,27 @@ export class CoreGradesHelperProvider { }); } + /** + * Returns the value of the selected grade. + * + * @param {any[]} grades Array with objects with value and label. + * @param {string} selectedGrade Selected grade label. + * @return {number} Selected grade value. + */ + getGradeValueFromLabel(grades: any[], selectedGrade: string): number { + if (!grades || !selectedGrade) { + return 0; + } + + for (const x in grades) { + if (grades[x].label == selectedGrade) { + return grades[x].value < 0 ? 0 : grades[x].value; + } + } + + return 0; + } + /** * Gets the link to the module for the selected grade. * diff --git a/src/providers/utils/utils.ts b/src/providers/utils/utils.ts index 5b56e841b..598415aa9 100644 --- a/src/providers/utils/utils.ts +++ b/src/providers/utils/utils.ts @@ -629,6 +629,35 @@ export class CoreUtilsProvider { return typeof error.errorcode == 'undefined' && typeof error.warningcode == 'undefined'; } + /** + * Given a list (e.g. a,b,c,d,e) this function returns an array of 1->a, 2->b, 3->c etc. + * Taken from make_menu_from_list on moodlelib.php (not the same but similar). + * + * @param {string} list The string to explode into array bits + * @param {string} [defaultLabel] Element that will become default option, if not defined, it won't be added. + * @param {string} [separator] The separator used within the list string. Default ','. + * @param {any} [defaultValue] Element that will become default option value. Default 0. + * @return {any[]} The now assembled array + */ + makeMenuFromList(list: string, defaultLabel?: string, separator: string = ',', defaultValue?: any): any[] { + // Split and format the list. + const split = list.split(separator).map((label, index) => { + return { + label: label.trim(), + value: index + 1 + }; + }); + + if (defaultLabel) { + split.unshift({ + label: defaultLabel, + value: defaultValue || 0 + }); + } + + return split; + } + /** * Merge two arrays, removing duplicate values. *