// (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, AddonModAssignGradedEventData, } 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'; import { AddonModAssignModuleHandlerService } from '../../services/handlers/module'; /** * 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 { @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. 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. // 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 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, ); } /** * Component being initialized. */ ngOnInit(): void { 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.timestamp(); const dueDate = response.lastattempt?.extensionduedate ? response.lastattempt.extensionduedate : this.assign!.duedate; const timeRemaining = dueDate - time; if (timeRemaining > 0) { this.timeRemaining = CoreTimeUtils.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.instant( 'addon.mod_assign.overdue', { $a: CoreTimeUtils.formatDuration(-timeRemaining, 3) }, ); this.timeRemainingClass = 'overdue'; return; } this.timeRemaining = Translate.instant('addon.mod_assign.duedatereached'); this.timeRemainingClass = ''; return; } const timeSubmittedDiff = this.userSubmission.timemodified - dueDate; if (timeSubmittedDiff > 0) { this.timeRemaining = Translate.instant( 'addon.mod_assign.submittedlate', { $a: CoreTimeUtils.formatDuration(timeSubmittedDiff, 2) }, ); this.timeRemainingClass = 'latesubmission'; return; } this.timeRemaining = Translate.instant( 'addon.mod_assign.submittedearly', { $a: CoreTimeUtils.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.showConfirm(Translate.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.isOnline()) { CoreDomUtils.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.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(); 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.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.discardFeedbackPluginData(this.assign!.id, this.submitId, this.feedback); } } /** * Go to the page to add or edit submission. */ goToEdit(): void { 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. * @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.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.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(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. * @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.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 (error) { // 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); // 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.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. * * @return 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). * * @return Promise resolved when done. */ protected async loadSubmissionOfflineData(): Promise { 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 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.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.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) { // 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.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.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(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.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(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.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.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.getSubmissionStatusColor(this.userSubmission.status); } else { if (!status.lastattempt?.submissionsenabled) { this.statusTranslated = translateService.instant('addon.mod_assign.noonlinesubmissions'); this.statusColor = AddonModAssign.getSubmissionStatusColor('noonlinesubmissions'); } else { this.statusTranslated = translateService.instant('addon.mod_assign.noattempt'); this.statusColor = AddonModAssign.getSubmissionStatusColor('noattempt'); } } } else { // Team submission. if (!status.lastattempt?.submissiongroup && this.assign!.preventsubmissionnotingroup) { this.statusTranslated = translateService.instant('addon.mod_assign.nosubmission'); this.statusColor = AddonModAssign.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.getSubmissionStatusColor(this.userSubmission.status); } else { if (!status.lastattempt?.submissionsenabled) { this.statusTranslated = translateService.instant('addon.mod_assign.noonlinesubmissions'); this.statusColor = AddonModAssign.getSubmissionStatusColor('noonlinesubmissions'); } else { this.statusTranslated = translateService.instant('addon.mod_assign.nosubmission'); this.statusColor = AddonModAssign.getSubmissionStatusColor('nosubmission'); } } } } /** * 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!.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. * * @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.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) { 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. * * @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.makeMenuFromList(this.gradeInfo.scale, Translate.instant('core.nograde')); } else { // Format the grade. this.grade.grade = CoreUtils.formatFloat(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 (this.gradeInfo.outcomes && AddonModAssign.isOutcomesEditEnabled()) { this.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; }); } // Get grade items. const grades = await CoreGradesHelper.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.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. 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.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.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.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.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.getAssignmentUserMappings(this.assign!.id, member, { cmId: this.moduleId, }).then((blindId) => { this.membersToSubmitBlind.push(blindId); return; })); } else { promises.push(CoreUser.getProfile(member, this.courseId).then((profile) => { this.membersToSubmit.push(profile); return; })); } }); } } // Get grading text and color. this.gradingStatusTranslationId = AddonModAssign.getSubmissionGradingStatusTranslationId( submissionStatus.lastattempt.gradingstatus, ); this.gradingColor = AddonModAssign.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.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 { // 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; };