From 7ab247cf7c090ad2daf353b085873046249a6a69 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 11 Apr 2018 10:06:20 +0200 Subject: [PATCH] MOBILE-2334 assign: Implement sync provider --- src/addon/mod/assign/assign.module.ts | 11 +- src/addon/mod/assign/providers/assign-sync.ts | 432 ++++++++++++++++++ .../mod/assign/providers/sync-cron-handler.ts | 47 ++ 3 files changed, 488 insertions(+), 2 deletions(-) create mode 100644 src/addon/mod/assign/providers/assign-sync.ts create mode 100644 src/addon/mod/assign/providers/sync-cron-handler.ts diff --git a/src/addon/mod/assign/assign.module.ts b/src/addon/mod/assign/assign.module.ts index 32dbecdcb..d3a41cfbf 100644 --- a/src/addon/mod/assign/assign.module.ts +++ b/src/addon/mod/assign/assign.module.ts @@ -13,15 +13,18 @@ // limitations under the License. import { NgModule } from '@angular/core'; +import { CoreCronDelegate } from '@providers/cron'; import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; import { AddonModAssignProvider } from './providers/assign'; import { AddonModAssignOfflineProvider } from './providers/assign-offline'; +import { AddonModAssignSyncProvider } from './providers/assign-sync'; import { AddonModAssignHelperProvider } from './providers/helper'; import { AddonModAssignFeedbackDelegate } from './providers/feedback-delegate'; import { AddonModAssignSubmissionDelegate } from './providers/submission-delegate'; import { AddonModAssignDefaultFeedbackHandler } from './providers/default-feedback-handler'; import { AddonModAssignDefaultSubmissionHandler } from './providers/default-submission-handler'; import { AddonModAssignPrefetchHandler } from './providers/prefetch-handler'; +import { AddonModAssignSyncCronHandler } from './providers/sync-cron-handler'; @NgModule({ declarations: [ @@ -29,16 +32,20 @@ import { AddonModAssignPrefetchHandler } from './providers/prefetch-handler'; providers: [ AddonModAssignProvider, AddonModAssignOfflineProvider, + AddonModAssignSyncProvider, AddonModAssignHelperProvider, AddonModAssignFeedbackDelegate, AddonModAssignSubmissionDelegate, AddonModAssignDefaultFeedbackHandler, AddonModAssignDefaultSubmissionHandler, - AddonModAssignPrefetchHandler + AddonModAssignPrefetchHandler, + AddonModAssignSyncCronHandler ] }) export class AddonModAssignModule { - constructor(prefetchDelegate: CoreCourseModulePrefetchDelegate, prefetchHandler: AddonModAssignPrefetchHandler) { + constructor(prefetchDelegate: CoreCourseModulePrefetchDelegate, prefetchHandler: AddonModAssignPrefetchHandler, + cronDelegate: CoreCronDelegate, syncHandler: AddonModAssignSyncCronHandler) { prefetchDelegate.registerHandler(prefetchHandler); + cronDelegate.register(syncHandler); } } diff --git a/src/addon/mod/assign/providers/assign-sync.ts b/src/addon/mod/assign/providers/assign-sync.ts new file mode 100644 index 000000000..1f43c7dde --- /dev/null +++ b/src/addon/mod/assign/providers/assign-sync.ts @@ -0,0 +1,432 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { 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 { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreCourseProvider } from '@core/course/providers/course'; +import { CoreGradesHelperProvider } from '@core/grades/providers/helper'; +import { CoreSyncBaseProvider } from '@classes/base-sync'; +import { AddonModAssignProvider } from './assign'; +import { AddonModAssignOfflineProvider } from './assign-offline'; +import { AddonModAssignSubmissionDelegate } from './submission-delegate'; + +/** + * Data returned by an assign sync. + */ +export interface AddonModAssignSyncResult { + /** + * List of warnings. + * @type {string[]} + */ + warnings: string[]; + + /** + * Whether data was updated in the site. + * @type {boolean} + */ + updated: boolean; +} + +/** + * 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'; + static SYNC_TIME = 300000; + + protected componentTranslate: string; + + constructor(loggerProvider: CoreLoggerProvider, sitesProvider: CoreSitesProvider, appProvider: CoreAppProvider, + syncProvider: CoreSyncProvider, textUtils: CoreTextUtilsProvider, translate: TranslateService, + private courseProvider: CoreCourseProvider, private eventsProvider: CoreEventsProvider, + private assignProvider: AddonModAssignProvider, private assignOfflineProvider: AddonModAssignOfflineProvider, + private utils: CoreUtilsProvider, private submissionDelegate: AddonModAssignSubmissionDelegate, + private gradesHelper: CoreGradesHelperProvider) { + + super('AddonModAssignSyncProvider', loggerProvider, sitesProvider, appProvider, syncProvider, textUtils, translate); + + this.componentTranslate = courseProvider.translateModuleName('assign'); + } + + /** + * Convenience function to get scale selected option. + * + * @param {string} options Possible options. + * @param {number} selected Selected option to search. + * @return {number} 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 {number} assignId Assign ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} 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 {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @return {Promise} Promise resolved if sync is successful, rejected if sync fails. + */ + syncAllAssignments(siteId?: string): Promise { + return this.syncOnSites('all assignments', this.syncAllAssignmentsFunc.bind(this), [], siteId); + } + + /** + * Sync all assignments on a site. + * + * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @param {Promise} Promise resolved if sync is successful, rejected if sync fails. + */ + protected syncAllAssignmentsFunc(siteId?: string): Promise { + // Get all assignments that have offline data. + return this.assignOfflineProvider.getAllAssigns(siteId).then((assignIds) => { + const promises = []; + + // Sync all assignments that haven't been synced for a while. + assignIds.forEach((assignId) => { + promises.push(this.syncAssignIfNeeded(assignId, siteId).then((data) => { + if (data && data.updated) { + // Sync done. Send event. + this.eventsProvider.trigger(AddonModAssignSyncProvider.AUTO_SYNCED, { + assignId: assignId, + warnings: data.warnings + }, siteId); + } + })); + }); + + return Promise.all(promises); + }); + } + + /** + * Sync an assignment only if a certain time has passed since the last time. + * + * @param {number} assignId Assign ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the assign is synced or it doesn't need to be synced. + */ + syncAssignIfNeeded(assignId: number, siteId?: string): Promise { + return this.isSyncNeeded(assignId, siteId).then((needed) => { + if (needed) { + return this.syncAssign(assignId, siteId); + } + }); + } + + /** + * Try to synchronize an assign. + * + * @param {number} assignId Assign ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved in success. + */ + syncAssign(assignId: number, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const promises = [], + result: AddonModAssignSyncResult = { + warnings: [], + updated: false + }; + let assign, + courseId, + syncPromise; + + 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.debug('Cannot sync assign ' + assignId + ' because it is blocked.'); + + return Promise.reject(this.translate.instant('core.errorsyncblocked', {$a: this.componentTranslate})); + } + + this.logger.debug('Try to sync assign ' + assignId + ' in site ' + siteId); + + // Get offline submissions to be sent. + promises.push(this.assignOfflineProvider.getAssignSubmissions(assignId, siteId).catch(() => { + // No offline data found, return empty array. + return []; + })); + + // Get offline submission grades to be sent. + promises.push(this.assignOfflineProvider.getAssignSubmissionsGrade(assignId, siteId).catch(() => { + // No offline data found, return empty array. + return []; + })); + + syncPromise = Promise.all(promises).then((results) => { + const submissions = results[0], + grades = results[1]; + + if (!submissions.length && !grades.length) { + // Nothing to sync. + return; + } else if (!this.appProvider.isOnline()) { + // Cannot sync in offline. + return Promise.reject(null); + } + + courseId = submissions.length > 0 ? submissions[0].courseid : grades[0].courseid; + + return this.assignProvider.getAssignmentById(courseId, assignId, siteId).then((assignData) => { + assign = assignData; + + const promises = []; + + submissions.forEach((submission) => { + promises.push(this.syncSubmission(assign, submission, result.warnings, siteId).then(() => { + result.updated = true; + })); + }); + + grades.forEach((grade) => { + promises.push(this.syncSubmissionGrade(assign, grade, result.warnings, courseId, siteId).then(() => { + result.updated = true; + })); + }); + + return Promise.all(promises); + }).then(() => { + if (result.updated) { + // Data has been sent to server. Now invalidate the WS calls. + return this.assignProvider.invalidateContent(assign.cmid, courseId, siteId).catch(() => { + // Ignore errors. + }); + } + }); + }).then(() => { + // Sync finished, set sync time. + return this.setSyncTime(assignId, siteId).catch(() => { + // Ignore errors. + }); + }).then(() => { + // All done, return the result. + return result; + }); + + return this.addOngoingSync(assignId, syncPromise, siteId); + } + + /** + * Synchronize a submission. + * + * @param {any} assign Assignment. + * @param {any} offlineData Submission offline data. + * @param {string[]} warnings List of warnings. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if success, rejected otherwise. + */ + protected syncSubmission(assign: any, offlineData: any, warnings: string[], siteId?: string): Promise { + const userId = offlineData.userId, + pluginData = {}; + let discardError, + submission; + + return this.assignProvider.getSubmissionStatus(assign.id, userId, false, true, true, siteId).then((status) => { + const promises = []; + + submission = this.assignProvider.getSubmissionObjectFromAttempt(assign, status.lastattempt); + + if (submission.timemodified != offlineData.onlineTimemodified) { + // The submission was modified in Moodle, discard the submission. + discardError = this.translate.instant('addon.mod_assign.warningsubmissionmodified'); + + return; + } + + submission.plugins.forEach((plugin) => { + promises.push(this.submissionDelegate.preparePluginSyncData(assign, submission, plugin, offlineData, pluginData, + siteId)); + }); + + return Promise.all(promises).then(() => { + // Now save the submission. + let promise; + + if (!Object.keys(pluginData).length) { + // Nothing to save. + promise = Promise.resolve(); + } else { + promise = this.assignProvider.saveSubmissionOnline(assign.id, pluginData, siteId); + } + + return promise.then(() => { + if (assign.submissiondrafts && offlineData.submitted) { + // The user submitted the assign manually. Submit it for grading. + return this.assignProvider.submitForGradingOnline(assign.id, offlineData.submissionStatement, siteId); + } + }).then(() => { + // Submission data sent, update cached data. No need to block the user for this. + this.assignProvider.getSubmissionStatus(assign.id, userId, false, true, true, siteId); + }); + }).catch((error) => { + if (error && this.utils.isWebServiceError(error)) { + // A WebService has thrown an error, this means it cannot be submitted. Discard the submission. + discardError = error.message || error.error || error.content || error.body; + } else { + // Couldn't connect to server, reject. + return Promise.reject(error); + } + }); + }).then(() => { + // Delete the offline data. + return this.assignOfflineProvider.deleteSubmission(assign.id, userId, siteId).then(() => { + const promises = []; + + submission.plugins.forEach((plugin) => { + promises.push(this.submissionDelegate.deletePluginOfflineData(assign, submission, plugin, offlineData, siteId)); + }); + + return Promise.all(promises); + }); + }).then(() => { + if (discardError) { + // Submission was discarded, add a warning. + const message = this.translate.instant('core.warningofflinedatadeleted', { + component: this.componentTranslate, + name: assign.name, + error: discardError + }); + + if (warnings.indexOf(message) == -1) { + warnings.push(message); + } + } + }); + } + + /** + * Synchronize a submission grade. + * + * @param {any} assign Assignment. + * @param {any} offlineData Submission grade offline data. + * @param {string[]} warnings List of warnings. + * @param {number} courseId Course Id. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if success, rejected otherwise. + */ + protected syncSubmissionGrade(assign: any, offlineData: any, warnings: string[], courseId: number, siteId?: string) + : Promise { + + const userId = offlineData.userId; + let discardError; + + return this.assignProvider.getSubmissionStatus(assign.id, userId, false, true, true, siteId).then((status) => { + const timemodified = status.feedback && (status.feedback.gradeddate || status.feedback.grade.timemodified); + + if (timemodified > offlineData.timemodified) { + // The submission grade was modified in Moodle, discard it. + discardError = this.translate.instant('addon.mod_assign.warningsubmissiongrademodified'); + + return; + } + + // If grade has been modified from gradebook, do not use offline. + return this.gradesHelper.getGradeModuleItems(courseId, assign.cmid, userId, undefined, siteId, true).then((grades) => { + return this.courseProvider.getModuleBasicGradeInfo(assign.cmid, siteId).then((gradeInfo) => { + + // 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); + } + }); + } + } + }); + }); + }).then(() => { + // Now submit the grade. + return this.assignProvider.submitGradingFormOnline(assign.id, userId, offlineData.grade, offlineData.attemptNumber, + offlineData.addAttempt, offlineData.workflowState, offlineData.applyToAll, offlineData.outcomes, + offlineData.pluginData, siteId).then(() => { + + // Grades sent, update cached data. No need to block the user for this. + this.assignProvider.getSubmissionStatus(assign.id, userId, false, true, true, siteId); + }).catch((error) => { + if (error && this.utils.isWebServiceError(error)) { + // The WebService has thrown an error, this means it cannot be submitted. Discard the offline data. + discardError = error.message || error.error || error.content || error.body; + } else { + // Couldn't connect to server, reject. + return Promise.reject(error); + } + }); + }); + }).then(() => { + // Delete the offline data. + return this.assignOfflineProvider.deleteSubmissionGrade(assign.id, userId, siteId); + }).then(() => { + if (discardError) { + // Submission grade was discarded, add a warning. + const message = this.translate.instant('core.warningofflinedatadeleted', { + component: this.componentTranslate, + name: assign.name, + error: discardError + }); + + if (warnings.indexOf(message) == -1) { + warnings.push(message); + } + } + }); + } +} diff --git a/src/addon/mod/assign/providers/sync-cron-handler.ts b/src/addon/mod/assign/providers/sync-cron-handler.ts new file mode 100644 index 000000000..9cc969259 --- /dev/null +++ b/src/addon/mod/assign/providers/sync-cron-handler.ts @@ -0,0 +1,47 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreCronHandler } from '@providers/cron'; +import { AddonModAssignSyncProvider } from './assign-sync'; + +/** + * Synchronization cron handler. + */ +@Injectable() +export class AddonModAssignSyncCronHandler implements CoreCronHandler { + name = 'AddonModAssignSyncCronHandler'; + + constructor(private assignSync: AddonModAssignSyncProvider) {} + + /** + * Execute the process. + * Receives the ID of the site affected, undefined for all sites. + * + * @param {string} [siteId] ID of the site affected, undefined for all sites. + * @return {Promise} Promise resolved when done, rejected if failure. + */ + execute(siteId?: string): Promise { + return this.assignSync.syncAllAssignments(siteId); + } + + /** + * Get the time between consecutive executions. + * + * @return {number} Time between consecutive executions (in ms). + */ + getInterval(): number { + return 600000; // 10 minutes. + } +}