// (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 { Injectable } from '@angular/core'; import { CoreEvents, CoreEventSiteData } from '@singletons/events'; import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; import { CoreSyncBlockedError } from '@classes/base-sync'; import { AddonModAssignProvider, AddonModAssignAssign, AddonModAssignSubmission, AddonModAssign, AddonModAssignGetSubmissionStatusWSResponse, AddonModAssignSubmissionStatusOptions, } from './assign'; import { makeSingleton, Translate } from '@singletons'; import { CoreCourse } from '@features/course/services/course'; import { CoreCourseActivitySyncBaseProvider } from '@features/course/classes/activity-sync'; import { AddonModAssignOffline, AddonModAssignSubmissionsDBRecordFormatted, AddonModAssignSubmissionsGradingDBRecordFormatted, } from './assign-offline'; import { CoreSync } from '@services/sync'; import { CoreCourseLogHelper } from '@features/course/services/log-helper'; import { CoreUtils } from '@services/utils/utils'; import { CoreApp } from '@services/app'; import { CoreTextUtils } from '@services/utils/text'; import { CoreNetworkError } from '@classes/errors/network-error'; import { CoreGradesFormattedItem, CoreGradesFormattedRow, CoreGradesHelper } from '@features/grades/services/grades-helper'; import { AddonModAssignSubmissionDelegate } from './submission-delegate'; import { AddonModAssignFeedbackDelegate } from './feedback-delegate'; /** * Service to sync assigns. */ @Injectable({ providedIn: 'root' }) export class AddonModAssignSyncProvider extends CoreCourseActivitySyncBaseProvider { static readonly AUTO_SYNCED = 'addon_mod_assign_autom_synced'; static readonly MANUAL_SYNCED = 'addon_mod_assign_manual_synced'; protected componentTranslate: string; constructor() { super('AddonModLessonSyncProvider'); this.componentTranslate = CoreCourse.instance.translateModuleName('assign'); } /** * Get the sync ID for a certain user grade. * * @param assignId Assign ID. * @param userId User the grade belongs to. * @return Sync ID. */ getGradeSyncId(assignId: number, userId: number): string { return 'assignGrade#' + assignId + '#' + userId; } /** * Convenience function to get scale selected option. * * @param options Possible options. * @param selected Selected option to search. * @return Index of the selected option. */ protected getSelectedScaleId(options: string, selected: string): number { let optionsList = options.split(','); optionsList = optionsList.map((value) => value.trim()); optionsList.unshift(''); const index = options.indexOf(selected) || 0; if (index < 0) { return 0; } return index; } /** * Check if an assignment has data to synchronize. * * @param assignId Assign ID. * @param siteId Site ID. If not defined, current site. * @return Promise resolved with boolean: whether it has data to sync. */ hasDataToSync(assignId: number, siteId?: string): Promise { return AddonModAssignOffline.instance.hasAssignOfflineData(assignId, siteId); } /** * Try to synchronize all the assignments in a certain site or in all sites. * * @param siteId Site ID to sync. If not defined, sync all sites. * @param force Wether to force sync not depending on last execution. * @return Promise resolved if sync is successful, rejected if sync fails. */ syncAllAssignments(siteId?: string, force?: boolean): Promise { return this.syncOnSites('all assignments', this.syncAllAssignmentsFunc.bind(this, !!force), siteId); } /** * Sync all assignments on a site. * * @param force Wether to force sync not depending on last execution. * @param siteId Site ID to sync. If not defined, sync all sites. * @param Promise resolved if sync is successful, rejected if sync fails. */ protected async syncAllAssignmentsFunc(force: boolean, siteId: string): Promise { // Get all assignments that have offline data. const assignIds = await AddonModAssignOffline.instance.getAllAssigns(siteId); // Try to sync all assignments. await Promise.all(assignIds.map(async (assignId) => { const result = force ? await this.syncAssign(assignId, siteId) : await this.syncAssignIfNeeded(assignId, siteId); if (result?.updated) { CoreEvents.trigger(AddonModAssignSyncProvider.AUTO_SYNCED, { assignId: assignId, warnings: result.warnings, gradesBlocked: result.gradesBlocked, }, siteId); } })); } /** * Sync an assignment only if a certain time has passed since the last time. * * @param assignId Assign ID. * @param siteId Site ID. If not defined, current site. * @return Promise resolved when the assign is synced or it doesn't need to be synced. */ async syncAssignIfNeeded(assignId: number, siteId?: string): Promise { const needed = await this.isSyncNeeded(assignId, siteId); if (needed) { return this.syncAssign(assignId, siteId); } } /** * Try to synchronize an assign. * * @param assignId Assign ID. * @param siteId Site ID. If not defined, current site. * @return Promise resolved in success. */ async syncAssign(assignId: number, siteId?: string): Promise { siteId = siteId || CoreSites.instance.getCurrentSiteId(); this.componentTranslate = this.componentTranslate || CoreCourse.instance.translateModuleName('assign'); if (this.isSyncing(assignId, siteId)) { // There's already a sync ongoing for this assign, return the promise. return this.getOngoingSync(assignId, siteId)!; } // Verify that assign isn't blocked. if (CoreSync.instance.isBlocked(AddonModAssignProvider.COMPONENT, assignId, siteId)) { this.logger.debug('Cannot sync assign ' + assignId + ' because it is blocked.'); throw new CoreSyncBlockedError(Translate.instance.instant('core.errorsyncblocked', { $a: this.componentTranslate })); } this.logger.debug('Try to sync assign ' + assignId + ' in site ' + siteId); const syncPromise = this.performSyncAssign(assignId, siteId); return this.addOngoingSync(assignId, syncPromise, siteId); } /** * Perform the assign submission. * * @param assignId Assign ID. * @param siteId Site ID. If not defined, current site. * @return Promise resolved in success. */ protected async performSyncAssign(assignId: number, siteId: string): Promise { // Sync offline logs. await CoreUtils.instance.ignoreErrors( CoreCourseLogHelper.instance.syncActivity(AddonModAssignProvider.COMPONENT, assignId, siteId), ); const result: AddonModAssignSyncResult = { warnings: [], updated: false, gradesBlocked: [], }; // Load offline data and sync offline logs. const [submissions, grades] = await Promise.all([ this.getOfflineSubmissions(assignId, siteId), this.getOfflineGrades(assignId, siteId), ]); if (!submissions.length && !grades.length) { // Nothing to sync. await CoreUtils.instance.ignoreErrors(this.setSyncTime(assignId, siteId)); return result; } if (!CoreApp.instance.isOnline()) { // Cannot sync in offline. throw new CoreNetworkError(); } const courseId = submissions.length > 0 ? submissions[0].courseid : grades[0].courseid; const assign = await AddonModAssign.instance.getAssignmentById(courseId, assignId, { siteId }); let promises: Promise[] = []; promises = promises.concat(submissions.map(async (submission) => { await this.syncSubmission(assign, submission, result.warnings, siteId); result.updated = true; return; })); promises = promises.concat(grades.map(async (grade) => { try { await this.syncSubmissionGrade(assign, grade, result.warnings, courseId, siteId); result.updated = true; } catch (error) { if (error instanceof CoreSyncBlockedError) { // Grade blocked, but allow finish the sync. result.gradesBlocked.push(grade.userid); } else { throw error; } } })); await CoreUtils.instance.allPromises(promises); if (result.updated) { // Data has been sent to server. Now invalidate the WS calls. await CoreUtils.instance.ignoreErrors(AddonModAssign.instance.invalidateContent(assign.cmid, courseId, siteId)); } // Sync finished, set sync time. await CoreUtils.instance.ignoreErrors(this.setSyncTime(assignId, siteId)); // All done, return the result. return result; } /** * Get offline grades to be sent. * * @param assignId Assign ID. * @param siteId Site ID. If not defined, current site. * @return Promise with grades. */ protected async getOfflineGrades( assignId: number, siteId: string, ): Promise { // If no offline data found, return empty array. return CoreUtils.instance.ignoreErrors(AddonModAssignOffline.instance.getAssignSubmissionsGrade(assignId, siteId), []); } /** * Get offline submissions to be sent. * * @param assignId Assign ID. * @param siteId Site ID. If not defined, current site. * @return Promise with submissions. */ protected async getOfflineSubmissions( assignId: number, siteId: string, ): Promise { // If no offline data found, return empty array. return CoreUtils.instance.ignoreErrors(AddonModAssignOffline.instance.getAssignSubmissions(assignId, siteId), []); } /** * Synchronize a submission. * * @param assign Assignment. * @param offlineData Submission offline data. * @param warnings List of warnings. * @param siteId Site ID. If not defined, current site. * @return Promise resolved if success, rejected otherwise. */ protected async syncSubmission( assign: AddonModAssignAssign, offlineData: AddonModAssignSubmissionsDBRecordFormatted, warnings: string[], siteId: string, ): Promise { const userId = offlineData.userid; const pluginData = {}; const options: AddonModAssignSubmissionStatusOptions = { userId, cmId: assign.cmid, readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, siteId, }; const status = await AddonModAssign.instance.getSubmissionStatus(assign.id, options); const submission = AddonModAssign.instance.getSubmissionObjectFromAttempt(assign, status.lastattempt); if (submission && submission.timemodified != offlineData.onlinetimemodified) { // The submission was modified in Moodle, discard the submission. this.addOfflineDataDeletedWarning( warnings, this.componentTranslate, assign.name, Translate.instance.instant('addon.mod_assign.warningsubmissionmodified'), ); return this.deleteSubmissionData(assign, offlineData, submission, siteId); } try { if (submission?.plugins) { // Prepare plugins data. await Promise.all(submission.plugins.map((plugin) => AddonModAssignSubmissionDelegate.instance.preparePluginSyncData( assign, submission, plugin, offlineData, pluginData, siteId, ))); } // Now save the submission. if (Object.keys(pluginData).length > 0) { await AddonModAssign.instance.saveSubmissionOnline(assign.id, pluginData, siteId); } if (assign.submissiondrafts && offlineData.submitted) { // The user submitted the assign manually. Submit it for grading. await AddonModAssign.instance.submitForGradingOnline(assign.id, !!offlineData.submissionstatement, siteId); } // Submission data sent, update cached data. No need to block the user for this. AddonModAssign.instance.getSubmissionStatus(assign.id, options); } catch (error) { if (!error || !CoreUtils.instance.isWebServiceError(error)) { // Local error, reject. throw error; } // A WebService has thrown an error, this means it cannot be submitted. Discard the submission. this.addOfflineDataDeletedWarning( warnings, this.componentTranslate, assign.name, CoreTextUtils.instance.getErrorMessageFromError(error) || '', ); } // Delete the offline data. await this.deleteSubmissionData(assign, offlineData, submission, siteId); } /** * Delete the submission offline data (not grades). * * @param assign Assign. * @param submission Submission. * @param offlineData Offline data. * @param siteId Site ID. * @return Promise resolved when done. */ protected async deleteSubmissionData( assign: AddonModAssignAssign, offlineData: AddonModAssignSubmissionsDBRecordFormatted, submission?: AddonModAssignSubmission, siteId?: string, ): Promise { // Delete the offline data. await AddonModAssignOffline.instance.deleteSubmission(assign.id, offlineData.userid, siteId); if (submission?.plugins){ // Delete plugins data. await Promise.all(submission.plugins.map((plugin) => AddonModAssignSubmissionDelegate.instance.deletePluginOfflineData( assign, submission, plugin, offlineData, siteId, ))); } } /** * Synchronize a submission grade. * * @param assign Assignment. * @param offlineData Submission grade offline data. * @param warnings List of warnings. * @param courseId Course Id. * @param siteId Site ID. If not defined, current site. * @return Promise resolved if success, rejected otherwise. */ protected async syncSubmissionGrade( assign: AddonModAssignAssign, offlineData: AddonModAssignSubmissionsGradingDBRecordFormatted, warnings: string[], courseId: number, siteId: string, ): Promise { const userId = offlineData.userid; const syncId = this.getGradeSyncId(assign.id, userId); const options: AddonModAssignSubmissionStatusOptions = { userId, cmId: assign.cmid, readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, siteId, }; // Check if this grade sync is blocked. if (CoreSync.instance.isBlocked(AddonModAssignProvider.COMPONENT, syncId, siteId)) { this.logger.error(`Cannot sync grade for assign ${assign.id} and user ${userId} because it is blocked.!!!!`); throw new CoreSyncBlockedError(Translate.instance.instant( 'core.errorsyncblocked', { $a: Translate.instance.instant('addon.mod_assign.syncblockedusercomponent') }, )); } const status = await AddonModAssign.instance.getSubmissionStatus(assign.id, options); const timemodified = (status.feedback && (status.feedback.gradeddate || status.feedback.grade?.timemodified)) || 0; if (timemodified > offlineData.timemodified) { // The submission grade was modified in Moodle, discard it. this.addOfflineDataDeletedWarning( warnings, this.componentTranslate, assign.name, Translate.instance.instant('addon.mod_assign.warningsubmissiongrademodified'), ); return AddonModAssignOffline.instance.deleteSubmissionGrade(assign.id, userId, siteId); } // If grade has been modified from gradebook, do not use offline. const grades: CoreGradesFormattedItem[] | CoreGradesFormattedRow[] = await CoreGradesHelper.instance.getGradeModuleItems(courseId, assign.cmid, userId, undefined, siteId, true); const gradeInfo = await CoreCourse.instance.getModuleBasicGradeInfo(assign.cmid, siteId); // Override offline grade and outcomes based on the gradebook data. grades.forEach((grade: CoreGradesFormattedItem | CoreGradesFormattedRow) => { if ('gradedategraded' in grade && (grade.gradedategraded || 0) >= offlineData.timemodified) { if (!grade.outcomeid && !grade.scaleid) { if (gradeInfo && gradeInfo.scale) { offlineData.grade = this.getSelectedScaleId(gradeInfo.scale, grade.grade || ''); } else { offlineData.grade = parseFloat(grade.grade || ''); } } else if (gradeInfo && grade.outcomeid && AddonModAssign.instance.isOutcomesEditEnabled() && gradeInfo.outcomes) { gradeInfo.outcomes.forEach((outcome, index) => { if (outcome.scale && grade.itemnumber == index) { offlineData.outcomes[grade.itemnumber] = this.getSelectedScaleId( outcome.scale, grade.grade || '', ); } }); } } }); try { // Now submit the grade. await AddonModAssign.instance.submitGradingFormOnline( assign.id, userId, offlineData.grade, offlineData.attemptnumber, !!offlineData.addattempt, offlineData.workflowstate, !!offlineData.applytoall, offlineData.outcomes, offlineData.plugindata, siteId, ); // Grades sent. Discard grades drafts. let promises: Promise[] = []; if (status.feedback && status.feedback.plugins) { promises = status.feedback.plugins.map((plugin) => AddonModAssignFeedbackDelegate.instance.discardPluginFeedbackData(assign.id, userId, plugin, siteId)); } // Update cached data. promises.push(AddonModAssign.instance.getSubmissionStatus(assign.id, options)); await CoreUtils.instance.allPromises(promises); } catch (error) { if (!error || !CoreUtils.instance.isWebServiceError(error)) { // Local error, reject. throw error; } // A WebService has thrown an error, this means it cannot be submitted. Discard the submission. this.addOfflineDataDeletedWarning( warnings, this.componentTranslate, assign.name, CoreTextUtils.instance.getErrorMessageFromError(error) || '', ); } // Delete the offline data. await AddonModAssignOffline.instance.deleteSubmissionGrade(assign.id, userId, siteId); } } export const AddonModAssignSync = makeSingleton(AddonModAssignSyncProvider); /** * Data returned by a assign sync. */ export type AddonModAssignSyncResult = { warnings: string[]; // List of warnings. updated: boolean; // Whether some data was sent to the server or offline data was updated. courseId?: number; // Course the assign belongs to (if known). gradesBlocked: number[]; // Whether some grade couldn't be synced because it was blocked. UserId fields of the blocked grade. }; /** * Data passed to AUTO_SYNCED event. */ export type AddonModAssignAutoSyncData = CoreEventSiteData & { assignId: number; warnings: string[]; gradesBlocked: number[]; // Whether some grade couldn't be synced because it was blocked. UserId fields of the blocked grade. }; /** * Data passed to MANUAL_SYNCED event. */ export type AddonModAssignManualSyncData = AddonModAssignAutoSyncData & { context: string; submitId?: number; };