// (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, AddonModAssignSavePluginData, AddonModAssignGradingStates, AddonModAssignSubmissionStatusValues, AddonModAssignAttemptReopenMethodValues, } 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 { CoreNetwork } from '@services/network'; 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'; import { AddonModAssignModuleHandlerService } from '../../services/handlers/module'; import { CanLeave } from '@guards/can-leave'; import { CoreTime } from '@singletons/time'; import { isSafeNumber, SafeNumber } from '@/core/utils/types'; /** * Component that displays an assignment submission. */ @Component({ selector: 'addon-mod-assign-submission', templateUrl: 'addon-mod-assign-submission.html', styleUrls: ['submission.scss'], }) export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy, CanLeave { @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). 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. timeLimitEndTime = 0; // If time limit is enabled and submission is ongoing, the end time for the timer. 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. isPreviousAttemptEmpty = true; // Whether the previous attempt contains an empty submission. showDates = false; // Whether to show some dates. timeLimitFinished = false; // Whether there is a time limit and it finished, so the user will submit late. // Some constants. statusNew = AddonModAssignSubmissionStatusValues.NEW; statusReopened = AddonModAssignSubmissionStatusValues.REOPENED; attemptReopenMethodNone = AddonModAssignAttemptReopenMethodValues.NONE; unlimitedAttempts = AddonModAssignProvider.UNLIMITED_ATTEMPTS; protected siteId: string; // Current site ID. protected currentUserId: number; // Current user ID. protected previousAttempt?: AddonModAssignSubmissionPreviousAttempt; // The previous attempt. 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.getCurrentSiteId(); this.currentUserId = CoreSites.getCurrentSiteUserId(); this.maxAttemptsText = Translate.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, ); } /** * @inheritdoc */ ngOnInit(): void { this.isSubmittedForGrading = !!this.submitId; this.showDates = !CoreSites.getCurrentSite()?.isVersionGreaterEqualThan('3.11'); 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) { return; } const submissionStarted = !!this.userSubmission?.timestarted; this.timeLimitEndTime = 0; this.timeLimitFinished = false; if (this.assign.duedate <= 0 && !submissionStarted) { // No due date and no countdown. this.timeRemaining = ''; this.timeRemainingClass = ''; return; } const time = CoreTimeUtils.timestamp(); const timeLimitEnabled = this.assign.timelimit && submissionStarted; const dueDateReached = this.assign.duedate > 0 && this.assign.duedate - time <= 0; const timeLimitEnabledBeforeDueDate = timeLimitEnabled && !dueDateReached; if (this.userSubmission && this.userSubmission.status === AddonModAssignSubmissionStatusValues.SUBMITTED) { // Submitted, display the relevant early/late message. const lateCalculation = this.userSubmission.timemodified - (timeLimitEnabledBeforeDueDate ? this.userSubmission.timecreated : 0); const lateThreshold = timeLimitEnabledBeforeDueDate ? this.assign.timelimit || 0 : this.assign.duedate; const earlyString = timeLimitEnabledBeforeDueDate ? 'submittedundertime' : 'submittedearly'; const lateString = timeLimitEnabledBeforeDueDate ? 'submittedovertime' : 'submittedlate'; const onTime = lateCalculation <= lateThreshold; this.timeRemaining = Translate.instant( 'addon.mod_assign.' + (onTime ? earlyString : lateString), { $a: CoreTime.formatTime(Math.abs(lateCalculation - lateThreshold)) }, ); this.timeRemainingClass = onTime ? 'earlysubmission' : 'latesubmission'; return; } if (dueDateReached) { // There is no submission, due date has passed, show assignment is overdue. const submissionsEnabled = response.lastattempt?.submissionsenabled || response.gradingsummary?.submissionsenabled; this.timeRemaining = Translate.instant( 'addon.mod_assign.' + (submissionsEnabled ? 'overdue' : 'duedatereached'), { $a: CoreTime.formatTime(time - this.assign.duedate) }, ); this.timeRemainingClass = 'overdue'; this.timeLimitFinished = true; return; } if (timeLimitEnabled && submissionStarted) { // An attempt has started and there is a time limit, display the time limit. this.timeRemaining = ''; this.timeRemainingClass = 'timeremaining'; this.timeLimitEndTime = AddonModAssignHelper.calculateEndTime(this.assign, this.userSubmission); return; } // Assignment is not overdue, and no submission has been made. Just display the due date. this.timeRemaining = CoreTime.formatTime(this.assign.duedate - time); this.timeRemainingClass = 'timeremaining'; } /** * Check if the user can leave the view. If there are changes to be saved, it will ask for confirm. * * @returns Promise resolved with true 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. await CoreDomUtils.showConfirm(Translate.instant('core.confirmcanceledit')); await this.discardDrafts(); } return true; } /** * Copy a previous attempt and then go to edit. * * @returns Promise resolved when done. */ async copyPrevious(): Promise { if (!this.assign) { return; } if (!CoreNetwork.isOnline()) { CoreDomUtils.showErrorModal('core.networkerrormsg', true); return; } if (!this.previousAttempt?.submission) { // Cannot access previous attempts, just go to edit. return this.goToEdit(true); } const previousSubmission = this.previousAttempt.submission; let modal = await CoreDomUtils.showModalLoading(); const size = await CoreUtils.ignoreErrors( AddonModAssignHelper.getSubmissionSizeForCopy(this.assign, previousSubmission), -1, ); // Error calculating size, return -1. modal.dismiss(); try { // Confirm action. await CoreFileUploaderHelper.confirmUploadFile(size, true); } catch { // Cancelled. return; } // User confirmed, copy the attempt. modal = await CoreDomUtils.showModalLoading('core.sending', true); try { await AddonModAssignHelper.copyPreviousAttempt(this.assign, previousSubmission); // Now go to edit. this.goToEdit(true); if (!this.assign.submissiondrafts && this.userSubmission) { // 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.showErrorModalDefault(error, 'core.error', true); } finally { modal.dismiss(); } } /** * Discard feedback drafts. * * @returns Promise resolved when done. */ protected async discardDrafts(): Promise { if (this.assign && this.feedback && this.feedback.plugins) { await AddonModAssignHelper.discardFeedbackPluginData(this.assign.id, this.submitId, this.feedback); } } /** * Go to the page to add or edit submission. * * @param afterCopyPrevious Whether the user has just copied the previous submission. */ async goToEdit(afterCopyPrevious = false): Promise { if (!afterCopyPrevious && this.assign?.timelimit && (!this.userSubmission || !this.userSubmission.timestarted)) { try { await CoreDomUtils.showConfirm( Translate.instant('addon.mod_assign.confirmstart', { $a: CoreTime.formatTime(this.assign.timelimit), }), undefined, Translate.instant('addon.mod_assign.beginassignment'), ); } catch { return; // User canceled. } } CoreNavigator.navigateToSitePath( AddonModAssignModuleHandlerService.PAGE_NAME + '/' + this.courseId + '/' + this.moduleId + '/edit', { params: { blindId: this.blindId, }, }, ); } /** * Check if there's data to save (grade). * * @param isSubmit Whether the user is about to submit the grade. * @returns Promise resolved with boolean: whether there's data to save. */ protected async hasDataToSave(isSubmit = false): Promise { if (!this.canSaveGrades || !this.loaded || !this.assign) { 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?.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.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. * @returns Promise resolved when done. */ async invalidateAndRefresh(sync = false): Promise { this.loaded = false; const promises: Promise[] = []; promises.push(AddonModAssign.invalidateAssignmentData(this.courseId)); if (this.assign) { promises.push(AddonModAssign.invalidateSubmissionStatusData( this.assign.id, this.submitId, undefined, !!this.blindId, )); promises.push(AddonModAssign.invalidateAssignmentUserMappingsData(this.assign.id)); promises.push(AddonModAssign.invalidateListParticipantsData(this.assign.id)); promises.push(AddonModAssign.invalidateAssignmentGradesData(this.assign.id)); } promises.push(CoreGradesHelper.invalidateGradeModuleItems(this.courseId, this.submitId)); promises.push(CoreCourse.invalidateModule(this.moduleId)); // Invalidate plugins. if (this.submissionComponents && this.submissionComponents.length) { this.submissionComponents.forEach((component) => { promises.push(component.invalidate()); }); } await CoreUtils.ignoreErrors(Promise.all(promises)); await this.loadData(sync); } /** * Load the data to render the submission. * * @param sync Whether to try to synchronize data. * @returns 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.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.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 { // Ignore errors, probably user is offline or sync is blocked. } } const time = CoreTimeUtils.timestamp(); let promises: Promise[] = []; if (this.assign.allowsubmissionsfromdate && this.assign.allowsubmissionsfromdate >= time) { this.fromDate = CoreTimeUtils.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.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.isSubmissionEmpty(this.assign, this.previousAttempt.submission); } // Treat last attempt. promises = this.treatLastAttempt(submissionStatus, this.lastAttempt); // Calculate the time remaining. this.calculateTimeRemaining(submissionStatus); // Load the feedback. promises.push(this.loadFeedback(this.assign, 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.createEmptySubmission(); this.userSubmission.plugins = AddonModAssignHelper.getPluginsEnabled(this.assign, 'assignsubmission'); } // Get the submission plugins that don't support editing. promises.push(this.loadUnsupportedPlugins()); await Promise.all(promises); } catch (error) { CoreDomUtils.showErrorModalDefault(error, 'Error getting assigment data.'); } finally { this.loaded = true; } } /** * Load profile of submission's user. * * @returns Promise resolved when done. */ protected async loadSubmissionUserProfile(): Promise { this.user = await CoreUser.getProfile(this.submitId, this.courseId); } /** * Load offline data for the submission (not the submission grade). * * @returns Promise resolved when done. */ protected async loadSubmissionOfflineData(): Promise { if (!this.assign) { return; } try { const submission = await AddonModAssignOffline.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 assign Assign data. * @param feedback The feedback data from the submission status. * @returns Promise resolved when done. */ protected async loadFeedback(assign: AddonModAssignAssign, 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) { this.grader = await CoreUtils.ignoreErrors(CoreUser.getProfile(feedback.grade.grader, this.courseId)); } 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.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.getModuleBasicGradeInfo(this.moduleId); if (!this.gradeInfo) { return; } // Treat the grade info. await this.treatGradeInfo(assign); const isManual = assign.attemptreopenmethod == AddonModAssignAttemptReopenMethodValues.MANUAL; const isUnlimited = assign.maxattempts == AddonModAssignProvider.UNLIMITED_ATTEMPTS; const isLessThanMaxAttempts = !!this.userSubmission && (this.userSubmission.attemptnumber < (assign.maxattempts - 1)); this.allowAddAttempt = isManual && (!this.userSubmission || isUnlimited || isLessThanMaxAttempts); if (assign.teamsubmission) { this.grade.applyToAll = true; this.originalGrades.applyToAll = true; } if (assign.markingworkflow && this.grade.gradingStatus) { this.workflowStatusTranslationId = AddonModAssign.getSubmissionGradingStatusTranslationId(this.grade.gradingStatus); } if ( this.lastAttempt?.gradingstatus === AddonModAssignGradingStates.GRADED && !assign.markingworkflow && this.userSubmission && feedback ) { if (feedback.gradeddate < this.userSubmission.timemodified) { this.lastAttempt.gradingstatus = AddonModAssignGradingStates.GRADED_FOLLOWUP_SUBMIT; // Get grading text and color. this.gradingStatusTranslationId = AddonModAssign.getSubmissionGradingStatusTranslationId( this.lastAttempt.gradingstatus, ); this.gradingColor = AddonModAssign.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.createEmptyFeedback(); this.feedback.plugins = AddonModAssignHelper.getPluginsEnabled(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.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.ignoreErrors( AddonModAssignOffline.getSubmissionGrade(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.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 (outcome.itemNumber !== undefined && submissionGrade.outcomes[outcome.itemNumber] !== undefined) { // If outcome has been modified from gradebook, do not use offline. if ((outcome.modified || 0) < submissionGrade.timemodified) { outcome.selectedId = submissionGrade.outcomes[outcome.itemNumber]; this.originalGrades.outcomes[outcome.id] = outcome.selectedId; } } }); } } } /** * Get the submission plugins that don't support editing. * * @returns Promise resolved when done. */ protected async loadUnsupportedPlugins(): Promise { this.unsupportedEditPlugins = await AddonModAssign.getUnsupportedEditPlugins(this.userSubmission?.plugins || []); } /** * Set the submission status name and class. * * @param status Submission status. */ protected setStatusNameAndClass(status: AddonModAssignGetSubmissionStatusWSResponse): void { if (!this.assign) { return; } if (this.hasOffline || this.submittedOffline) { // Offline data. this.statusTranslated = Translate.instant('core.notsent'); this.statusColor = 'warning'; } else if (!this.assign.teamsubmission) { // Single submission. if (this.userSubmission && this.userSubmission.status != this.statusNew) { this.statusTranslated = Translate.instant('addon.mod_assign.submissionstatus_' + this.userSubmission.status); this.statusColor = AddonModAssign.getSubmissionStatusColor(this.userSubmission.status); } else { if (!status.lastattempt?.submissionsenabled) { this.statusTranslated = Translate.instant('addon.mod_assign.noonlinesubmissions'); this.statusColor = AddonModAssign.getSubmissionStatusColor(AddonModAssignSubmissionStatusValues.NO_ONLINE_SUBMISSIONS); } else { this.statusTranslated = Translate.instant('addon.mod_assign.noattempt'); this.statusColor = AddonModAssign.getSubmissionStatusColor(AddonModAssignSubmissionStatusValues.NO_ATTEMPT); } } } else { // Team submission. if (!status.lastattempt?.submissiongroup && this.assign.preventsubmissionnotingroup) { this.statusTranslated = Translate.instant('addon.mod_assign.nosubmission'); this.statusColor = AddonModAssign.getSubmissionStatusColor(AddonModAssignSubmissionStatusValues.NO_SUBMISSION); } else if (this.userSubmission && this.userSubmission.status != this.statusNew) { this.statusTranslated = Translate.instant('addon.mod_assign.submissionstatus_' + this.userSubmission.status); this.statusColor = AddonModAssign.getSubmissionStatusColor(this.userSubmission.status); } else { if (!status.lastattempt?.submissionsenabled) { this.statusTranslated = Translate.instant('addon.mod_assign.noonlinesubmissions'); this.statusColor = AddonModAssign.getSubmissionStatusColor(AddonModAssignSubmissionStatusValues.NO_ONLINE_SUBMISSIONS); } else { this.statusTranslated = Translate.instant('addon.mod_assign.nosubmission'); this.statusColor = AddonModAssign.getSubmissionStatusColor(AddonModAssignSubmissionStatusValues.NO_SUBMISSION); } } } } /** * Show advanced grade. */ showAdvancedGrade(): void { if (this.feedback && this.feedback.advancedgrade) { CoreTextUtils.viewText( Translate.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 || !this.userSubmission) { return; } if (this.assign.requiresubmissionstatement && !acceptStatement) { CoreDomUtils.showErrorModal('addon.mod_assign.acceptsubmissionstatement', true); return; } try { // Ask for confirmation. @todo plugin precheck_submission await CoreDomUtils.showConfirm(Translate.instant('addon.mod_assign.confirmsubmission')); const modal = await CoreDomUtils.showModalLoading('core.sending', true); try { await AddonModAssign.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.showErrorModalDefault(error, 'core.error', true); } finally { modal.dismiss(); } } catch { // Cancelled. } } /** * Submit a grade and feedback. * * @returns Promise resolved when done. */ async submitGrade(): Promise { // Check if there's something to be saved. const modified = await this.hasDataToSave(true); if (!modified || !this.assign) { 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.unformatFloat(this.grade.grade, true); if (grade === false) { // Grade is invalid. throw new CoreError(Translate.instant('core.grades.badgrade')); } const modal = await CoreDomUtils.showModalLoading('core.sending', true); (this.gradeInfo?.outcomes || []).forEach((outcome) => { if (outcome.itemNumber && outcome.selectedId) { outcomes[outcome.itemNumber] = outcome.selectedId; } }); let pluginData: AddonModAssignSavePluginData = {}; try { if (this.feedback && this.feedback.plugins) { pluginData = await AddonModAssignHelper.prepareFeedbackPluginData(this.assign.id, this.submitId, this.feedback); } try { // We have all the data, now send it. await AddonModAssign.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. * * @param assign Assign info. * @returns Promise resolved when done. */ protected async treatGradeInfo(assign: AddonModAssignAssign): Promise { if (!this.gradeInfo) { return; } this.isGrading = true; // Make sure outcomes is an array. const gradeInfo = this.gradeInfo; gradeInfo.outcomes = gradeInfo.outcomes || []; // Check if grading method is simple or not. if (gradeInfo.advancedgrading && gradeInfo.advancedgrading[0] && gradeInfo.advancedgrading[0].method !== undefined) { this.grade.method = gradeInfo.advancedgrading[0].method || 'simple'; } else { this.grade.method = 'simple'; } this.canSaveGrades = this.grade.method == 'simple'; // Grades can be saved if simple grading. const gradeNotReleased = assign.markingworkflow && this.grade.gradingStatus !== AddonModAssignGradingStates.MARKING_WORKFLOW_STATE_RELEASED; const [gradebookGrades, assignGrades] = await Promise.all([ CoreGradesHelper.getGradeModuleItems(this.courseId, this.moduleId, this.submitId), gradeNotReleased ? CoreUtils.ignoreErrors(AddonModAssign.getAssignmentGrades(assign.id, { cmId: assign.cmid })) : undefined, ]); const unreleasedGrade = Number(assignGrades?.find(grade => grade.userid === this.submitId)?.grade); this.grade.unreleasedGrade = undefined; if (gradeInfo.scale) { this.grade.scale = CoreUtils.makeMenuFromList(gradeInfo.scale, Translate.instant('core.nograde')); if (isSafeNumber(unreleasedGrade)) { const scaleItem = this.grade.scale.find(scaleItem => scaleItem.value === unreleasedGrade); this.grade.unreleasedGrade = scaleItem?.label; this.grade.grade = (scaleItem ?? this.grade.scale[0])?.value; this.originalGrades.grade = this.grade.grade; } } else { this.grade.unreleasedGrade = isSafeNumber(unreleasedGrade) ? unreleasedGrade : undefined; // Format the grade. this.grade.grade = CoreUtils.formatFloat(this.grade.unreleasedGrade ?? this.grade.grade); this.originalGrades.grade = this.grade.grade; // Get current language to format grade input field. this.grade.lang = await CoreLang.getCurrentLanguage(); } // Treat outcomes. if (gradeInfo.outcomes) { gradeInfo.outcomes.forEach((outcome) => { if (outcome.scale) { outcome.options = CoreUtils.makeMenuFromList( outcome.scale, Translate.instant('core.grades.nooutcome'), ); } outcome.selectedId = 0; this.originalGrades.outcomes[outcome.id] = outcome.selectedId; }); } const outcomes: AddonModAssignGradeOutcome[] = []; gradebookGrades.forEach((grade: CoreGradesFormattedItem) => { if (!grade.outcomeid && !grade.scaleid) { // Clean HTML tags, grade can contain an icon. const gradeFormatted = CoreTextUtils.cleanTags(grade.gradeformatted || ''); // Not using outcomes or scale, get the numeric grade. if (this.grade.scale) { this.grade.gradebookGrade = CoreUtils.formatFloat( CoreGradesHelper.getGradeValueFromLabel(this.grade.scale, gradeFormatted), ); } else { const parsedGrade = parseFloat(gradeFormatted); this.grade.gradebookGrade = parsedGrade || parsedGrade == 0 ? CoreUtils.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. gradeInfo.outcomes?.forEach((outcome) => { if (outcome.id == String(grade.outcomeid)) { outcome.selected = grade.gradeformatted; outcome.modified = grade.gradedategraded; if (outcome.options) { outcome.selectedId = CoreGradesHelper.getGradeValueFromLabel(outcome.options, outcome.selected || ''); this.originalGrades.outcomes[outcome.id] = outcome.selectedId; outcome.itemNumber = grade.itemnumber; } outcomes.push(outcome); } }); gradeInfo.disabled = grade.gradeislocked || grade.gradeisoverridden; } }); gradeInfo.outcomes = outcomes; } /** * Treat the last attempt. * * @param submissionStatus Response of get submission status. * @param lastAttempt Last attempt (if any). * @returns Promises resolved when done. */ protected treatLastAttempt( submissionStatus: AddonModAssignGetSubmissionStatusWSResponse, lastAttempt?: AddonModAssignSubmissionAttemptFormatted, ): Promise[] { const promises: Promise[] =[]; if (!lastAttempt || !this.assign) { return []; } const submissionStatementMissing = !!this.assign.requiresubmissionstatement && this.assign.submissionstatement === undefined; this.canSubmit = !this.isSubmittedForGrading && !this.submittedOffline && (lastAttempt.cansubmit || (this.hasOffline && AddonModAssign.canSubmitOffline(this.assign, submissionStatus))); this.canEdit = !this.isSubmittedForGrading && 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.getSubmissionObjectFromAttempt(this.assign, lastAttempt); if (this.assign.attemptreopenmethod != this.attemptReopenMethodNone && this.userSubmission) { this.currentAttempt = this.userSubmission.attemptnumber + 1; } this.setStatusNameAndClass(submissionStatus); if (this.assign.teamsubmission) { if (lastAttempt.submissiongroup) { // Get the name of the group. promises.push(CoreGroups.getActivityAllowedGroups(this.assign.cmid).then((result) => { const group = result.groups.find((group) => group.id === lastAttempt.submissiongroup); if (group) { lastAttempt.submissiongroupname = group.name; } return; })); } // Get the members that need to submit. if (this.userSubmission && this.userSubmission.status != this.statusNew && lastAttempt.submissiongroupmemberswhoneedtosubmit ) { lastAttempt.submissiongroupmemberswhoneedtosubmit.forEach((member) => { if (!this.blindMarking) { promises.push(CoreUser.getProfile(member, this.courseId).then((profile) => { this.membersToSubmit.push(profile); return; })); } }); } } // Get grading text and color. this.gradingStatusTranslationId = AddonModAssign.getSubmissionGradingStatusTranslationId(lastAttempt.gradingstatus); this.gradingColor = AddonModAssign.getSubmissionGradingStatusColor(lastAttempt.gradingstatus); // Get the submission plugins. if (this.userSubmission) { if (!this.assign.teamsubmission || !lastAttempt.submissiongroup || !this.assign.preventsubmissionnotingroup ) { if (this.previousAttempt?.submission?.plugins && this.userSubmission.status === this.statusReopened) { // Get latest attempt if available. 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.getGradeSyncId(this.assign.id, this.submitId); if (block) { CoreSync.blockOperation(AddonModAssignProvider.COMPONENT, syncId); } else { CoreSync.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 { this.selectedTab = tab.id; // Block sync when selecting grade tab, unblock when leaving it. this.setGradeSyncBlocked(tab.id === 'grade'); } /** * Function called when the time is up. */ timeUp(): void { this.timeLimitFinished = true; } /** * @inheritdoc */ 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?: AddonModAssignGradingStates; addAttempt: boolean; applyToAll: boolean; scale?: CoreMenuItem[]; lang: string; disabled: boolean; unreleasedGrade?: SafeNumber | string; }; 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; };