// (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 { TranslateService } from '@ngx-translate/core'; import { CoreAppProvider } from '@providers/app'; import { CoreEventsProvider } from '@providers/events'; import { CoreLoggerProvider } from '@providers/logger'; import { CoreSitesProvider } from '@providers/sites'; import { CoreSyncProvider } from '@providers/sync'; import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreTimeUtilsProvider } from '@providers/utils/time'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreCourseProvider } from '@core/course/providers/course'; import { CoreCourseLogHelperProvider } from '@core/course/providers/log-helper'; import { CoreGradesHelperProvider } from '@core/grades/providers/helper'; import { CoreSyncBaseProvider, CoreSyncBlockedError } from '@classes/base-sync'; import { AddonModAssignProvider, AddonModAssignAssign, AddonModAssignSubmission } from './assign'; import { AddonModAssignOfflineProvider } from './assign-offline'; import { AddonModAssignSubmissionDelegate } from './submission-delegate'; import { AddonModAssignFeedbackDelegate } from './feedback-delegate'; import { makeSingleton } from '@singletons/core.singletons'; /** * Data returned by an assign sync. */ export interface AddonModAssignSyncResult { /** * List of warnings. */ warnings: string[]; /** * Whether data was updated in the site. */ updated: boolean; /** * Whether some grade couldn't be synced because it was blocked. */ gradesBlocked: number[]; } /** * Service to sync assigns. */ @Injectable() export class AddonModAssignSyncProvider extends CoreSyncBaseProvider { static AUTO_SYNCED = 'addon_mod_assign_autom_synced'; static MANUAL_SYNCED = 'addon_mod_assign_manual_synced'; protected componentTranslate: string; constructor(loggerProvider: CoreLoggerProvider, sitesProvider: CoreSitesProvider, appProvider: CoreAppProvider, syncProvider: CoreSyncProvider, textUtils: CoreTextUtilsProvider, translate: TranslateService, timeUtils: CoreTimeUtilsProvider, protected courseProvider: CoreCourseProvider, protected eventsProvider: CoreEventsProvider, protected assignProvider: AddonModAssignProvider, protected assignOfflineProvider: AddonModAssignOfflineProvider, protected utils: CoreUtilsProvider, protected submissionDelegate: AddonModAssignSubmissionDelegate, protected feedbackDelegate: AddonModAssignFeedbackDelegate, protected gradesHelper: CoreGradesHelperProvider, protected logHelper: CoreCourseLogHelperProvider) { super('AddonModAssignSyncProvider', loggerProvider, sitesProvider, appProvider, syncProvider, textUtils, translate, timeUtils); this.componentTranslate = courseProvider.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) => { return 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 this.assignOfflineProvider.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 siteId Site ID to sync. If not defined, sync all sites. * @param force Wether to force sync not depending on last execution. * @param Promise resolved if sync is successful, rejected if sync fails. */ protected async syncAllAssignmentsFunc(siteId?: string, force?: boolean): Promise { // Get all assignments that have offline data. const assignIds = await this.assignOfflineProvider.getAllAssigns(siteId); // Try to sync all assignments. await Promise.all(assignIds.map(async (assignId) => { const data = force ? await this.syncAssign(assignId, siteId) : await this.syncAssignIfNeeded(assignId, siteId); if (!data || !data.updated) { // Not updated. return; } this.eventsProvider.trigger(AddonModAssignSyncProvider.AUTO_SYNCED, { assignId: assignId, warnings: data.warnings, gradesBlocked: data.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 || this.sitesProvider.getCurrentSiteId(); 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 (this.syncProvider.isBlocked(AddonModAssignProvider.COMPONENT, assignId, siteId)) { this.logger.error('Cannot sync assign ' + assignId + ' because it is blocked.'); throw new CoreSyncBlockedError(this.translate.instant('core.errorsyncblocked', {$a: this.componentTranslate})); } return this.addOngoingSync(assignId, this.performSyncAssign(assignId, siteId), 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 { this.logger.error('Try to sync assign ' + assignId + ' in site ' + siteId); const result: AddonModAssignSyncResult = { warnings: [], updated: false, gradesBlocked: [], }; // Load offline data and sync offline logs. const promisesResults = await Promise.all([ this.getOfflineSubmissions(assignId, siteId), this.getOfflineGrades(assignId, siteId), this.logHelper.syncIfNeeded(AddonModAssignProvider.COMPONENT, assignId, siteId), ]); const submissions = promisesResults[0]; const grades = promisesResults[1]; if (!submissions.length && !grades.length) { // Nothing to sync. await this.utils.ignoreErrors(this.setSyncTime(assignId, siteId)); return result; } else if (!this.appProvider.isOnline()) { // Cannot sync in offline. throw new Error(this.translate.instant('core.cannotconnect')); } const courseId = submissions.length > 0 ? submissions[0].courseid : grades[0].courseid; const assign = await this.assignProvider.getAssignmentById(courseId, assignId, false, siteId); let promises = []; promises = promises.concat(submissions.map(async (submission) => { await this.syncSubmission(assign, submission, result.warnings, siteId); result.updated = true; })); 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 Promise.all(promises); if (result.updated) { // Data has been sent to server. Now invalidate the WS calls. await this.utils.ignoreErrors(this.assignProvider.invalidateContent(assign.cmid, courseId, siteId)); } // Sync finished, set sync time. await this.utils.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 { try { const submissions = await this.assignOfflineProvider.getAssignSubmissionsGrade(assignId, siteId); return submissions; } catch (error) { // No offline data found, return empty array. return []; } } /** * 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 { try { const submissions = await this.assignOfflineProvider.getAssignSubmissions(assignId, siteId); return submissions; } catch (error) { // No offline data found, return empty array. return []; } } /** * 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: any, warnings: string[], siteId?: string) : Promise { const userId = offlineData.userid; const pluginData = {}; const status = await this.assignProvider.getSubmissionStatus(assign.id, userId, undefined, false, true, true, siteId); const submission = this.assignProvider.getSubmissionObjectFromAttempt(assign, status.lastattempt); if (submission.timemodified != offlineData.onlinetimemodified) { // The submission was modified in Moodle, discard the submission. this.addOfflineDataDeletedWarning(warnings, this.componentTranslate, assign.name, this.translate.instant('addon.mod_assign.warningsubmissionmodified')); return this.deleteSubmissionData(assign, submission, offlineData, siteId); } try { // Prepare plugins data. await Promise.all(submission.plugins.map(async (plugin) => { await this.submissionDelegate.preparePluginSyncData(assign, submission, plugin, offlineData, pluginData, siteId); })); // Now save the submission. if (Object.keys(pluginData).length > 0) { await this.assignProvider.saveSubmissionOnline(assign.id, pluginData, siteId); } if (assign.submissiondrafts && offlineData.submitted) { // The user submitted the assign manually. Submit it for grading. await this.assignProvider.submitForGradingOnline(assign.id, offlineData.submissionstatement, siteId); } // Submission data sent, update cached data. No need to block the user for this. this.assignProvider.getSubmissionStatus(assign.id, userId, undefined, false, true, true, siteId); } catch (error) { if (!error || !this.utils.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, this.textUtils.getErrorMessageFromError(error)); } // Delete the offline data. await this.deleteSubmissionData(assign, submission, offlineData, 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, submission: AddonModAssignSubmission, offlineData: any, siteId?: string): Promise { // Delete the offline data. await this.assignOfflineProvider.deleteSubmission(assign.id, offlineData.userid, siteId); // Delete plugins data. await Promise.all(submission.plugins.map(async (plugin) => { await this.submissionDelegate.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: any, warnings: string[], courseId: number, siteId?: string): Promise { const userId = offlineData.userid; const syncId = this.getGradeSyncId(assign.id, userId); // Check if this grade sync is blocked. if (this.syncProvider.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(this.translate.instant('core.errorsyncblocked', {$a: this.translate.instant('addon.mod_assign.syncblockedusercomponent')})); } const status = await this.assignProvider.getSubmissionStatus(assign.id, userId, undefined, false, true, true, siteId); const timemodified = status.feedback && (status.feedback.gradeddate || status.feedback.grade.timemodified); if (timemodified > offlineData.timemodified) { // The submission grade was modified in Moodle, discard it. this.addOfflineDataDeletedWarning(warnings, this.componentTranslate, assign.name, this.translate.instant('addon.mod_assign.warningsubmissiongrademodified')); return this.assignOfflineProvider.deleteSubmissionGrade(assign.id, userId, siteId); } // If grade has been modified from gradebook, do not use offline. const grades = await this.gradesHelper.getGradeModuleItems(courseId, assign.cmid, userId, undefined, siteId, true); const gradeInfo = await this.courseProvider.getModuleBasicGradeInfo(assign.cmid, siteId); // Override offline grade and outcomes based on the gradebook data. grades.forEach((grade) => { if (grade.gradedategraded >= offlineData.timemodified) { if (!grade.outcomeid && !grade.scaleid) { if (gradeInfo && gradeInfo.scale) { offlineData.grade = this.getSelectedScaleId(gradeInfo.scale, grade.gradeformatted); } else { offlineData.grade = parseFloat(grade.gradeformatted) || null; } } else if (grade.outcomeid && this.assignProvider.isOutcomesEditEnabled() && gradeInfo.outcomes) { gradeInfo.outcomes.forEach((outcome, index) => { if (outcome.scale && grade.itemnumber == index) { offlineData.outcomes[grade.itemnumber] = this.getSelectedScaleId(outcome.scale, outcome.selected); } }); } } }); try { // Now submit the grade. await this.assignProvider.submitGradingFormOnline(assign.id, userId, offlineData.grade, offlineData.attemptnumber, offlineData.addattempt, offlineData.workflowstate, offlineData.applytoall, offlineData.outcomes, offlineData.plugindata, siteId); // Grades sent. Discard grades drafts. const promises = []; if (status.feedback && status.feedback.plugins) { status.feedback.plugins.forEach((plugin) => { promises.push(this.feedbackDelegate.discardPluginFeedbackData(assign.id, userId, plugin, siteId)); }); } // Update cached data. promises.push(this.assignProvider.getSubmissionStatus(assign.id, userId, undefined, false, true, true, siteId)); await Promise.all(promises); } catch (error) { if (!error || !this.utils.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, this.textUtils.getErrorMessageFromError(error)); } // Delete the offline data. await this.assignOfflineProvider.deleteSubmissionGrade(assign.id, userId, siteId); } } export class AddonModAssignSync extends makeSingleton(AddonModAssignSyncProvider) {}