From e4248a8a44066e56dba7d6dcc7cd2e65f8734df3 Mon Sep 17 00:00:00 2001 From: dpalou Date: Tue, 2 Oct 2018 13:02:11 +0200 Subject: [PATCH] MOBILE-2061 course: Synchronize offline manual completion --- scripts/langindex.json | 2 + .../module-completion/module-completion.ts | 2 +- src/core/course/course.module.ts | 16 +- src/core/course/lang/en.json | 3 +- src/core/course/pages/section/section.ts | 53 ++++-- src/core/course/providers/course-offline.ts | 21 ++- src/core/course/providers/course.ts | 11 +- src/core/course/providers/helper.ts | 4 +- .../course/providers/sync-cron-handler.ts | 47 +++++ src/core/course/providers/sync.ts | 178 ++++++++++++++++++ 10 files changed, 314 insertions(+), 23 deletions(-) create mode 100644 src/core/course/providers/sync-cron-handler.ts create mode 100644 src/core/course/providers/sync.ts diff --git a/scripts/langindex.json b/scripts/langindex.json index dc7ab8aa1..0c767ab98 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -1108,10 +1108,12 @@ "core.course.errorgetmodule": "local_moodlemobileapp", "core.course.hiddenfromstudents": "moodle", "core.course.hiddenoncoursepage": "moodle", + "core.course.manualcompletionnotsynced": "local_moodlemobileapp", "core.course.nocontentavailable": "local_moodlemobileapp", "core.course.overriddennotice": "grades", "core.course.sections": "moodle", "core.course.useactivityonbrowser": "local_moodlemobileapp", + "core.course.warningofflinemanualcompletiondeleted": "local_moodlemobileapp", "core.coursedetails": "moodle", "core.courses.allowguests": "enrol_guest", "core.courses.availablecourses": "moodle", diff --git a/src/core/course/components/module-completion/module-completion.ts b/src/core/course/components/module-completion/module-completion.ts index 5faf673b0..c383c41ca 100644 --- a/src/core/course/components/module-completion/module-completion.ts +++ b/src/core/course/components/module-completion/module-completion.ts @@ -72,7 +72,7 @@ export class CoreCourseModuleCompletionComponent implements OnChanges { const modal = this.domUtils.showModalLoading(); this.courseProvider.markCompletedManually(this.completion.cmid, this.completion.state === 1 ? 0 : 1, - this.completion.courseId).then((response) => { + this.completion.courseId, this.completion.courseName).then((response) => { if (!response.status) { return Promise.reject(null); diff --git a/src/core/course/course.module.ts b/src/core/course/course.module.ts index a4ebf855c..541c4333b 100644 --- a/src/core/course/course.module.ts +++ b/src/core/course/course.module.ts @@ -13,6 +13,7 @@ // limitations under the License. import { NgModule } from '@angular/core'; +import { CoreCronDelegate } from '@providers/cron'; import { CoreCourseProvider } from './providers/course'; import { CoreCourseHelperProvider } from './providers/helper'; import { CoreCourseFormatDelegate } from './providers/format-delegate'; @@ -26,6 +27,8 @@ import { CoreCourseFormatSingleActivityModule } from './formats/singleactivity/s import { CoreCourseFormatSocialModule } from './formats/social/social.module'; import { CoreCourseFormatTopicsModule } from './formats/topics/topics.module'; import { CoreCourseFormatWeeksModule } from './formats/weeks/weeks.module'; +import { CoreCourseSyncProvider } from './providers/sync'; +import { CoreCourseSyncCronHandler } from './providers/sync-cron-handler'; // List of providers (without handlers). export const CORE_COURSE_PROVIDERS: any[] = [ @@ -35,7 +38,8 @@ export const CORE_COURSE_PROVIDERS: any[] = [ CoreCourseModuleDelegate, CoreCourseModulePrefetchDelegate, CoreCourseOptionsDelegate, - CoreCourseOfflineProvider + CoreCourseOfflineProvider, + CoreCourseSyncProvider ]; @NgModule({ @@ -54,9 +58,15 @@ export const CORE_COURSE_PROVIDERS: any[] = [ CoreCourseModulePrefetchDelegate, CoreCourseOptionsDelegate, CoreCourseOfflineProvider, + CoreCourseSyncProvider, CoreCourseFormatDefaultHandler, - CoreCourseModuleDefaultHandler + CoreCourseModuleDefaultHandler, + CoreCourseSyncCronHandler ], exports: [] }) -export class CoreCourseModule {} +export class CoreCourseModule { + constructor(cronDelegate: CoreCronDelegate, syncHandler: CoreCourseSyncCronHandler) { + cronDelegate.register(syncHandler); + } +} diff --git a/src/core/course/lang/en.json b/src/core/course/lang/en.json index 02e85ef9e..3b9028eba 100644 --- a/src/core/course/lang/en.json +++ b/src/core/course/lang/en.json @@ -23,5 +23,6 @@ "overriddennotice": "Your final grade from this activity was manually adjusted.", "refreshcourse": "Refresh course", "sections": "Sections", - "useactivityonbrowser": "You can still use it using your device's web browser." + "useactivityonbrowser": "You can still use it using your device's web browser.", + "warningofflinemanualcompletiondeleted": "Some offline manual completion of course '{{name}}' has been deleted. {{error}}" } \ No newline at end of file diff --git a/src/core/course/pages/section/section.ts b/src/core/course/pages/section/section.ts index 4184c17ac..a1f902e16 100644 --- a/src/core/course/pages/section/section.ts +++ b/src/core/course/pages/section/section.ts @@ -24,6 +24,7 @@ import { CoreCourseHelperProvider } from '../../providers/helper'; import { CoreCourseFormatDelegate } from '../../providers/format-delegate'; import { CoreCourseModulePrefetchDelegate } from '../../providers/module-prefetch-delegate'; import { CoreCourseOptionsDelegate, CoreCourseOptionsHandlerToDisplay } from '../../providers/options-delegate'; +import { CoreCourseSyncProvider } from '../../providers/sync'; import { CoreCourseFormatComponent } from '../../components/format/format'; import { CoreCoursesProvider } from '@core/courses/providers/courses'; import { CoreTabsComponent } from '@components/tabs/tabs'; @@ -62,6 +63,7 @@ export class CoreCourseSectionPage implements OnDestroy { protected module: any; protected completionObserver; protected courseStatusObserver; + protected syncObserver; protected firstTabName: string; protected isDestroyed = false; @@ -70,7 +72,7 @@ export class CoreCourseSectionPage implements OnDestroy { private translate: TranslateService, private courseHelper: CoreCourseHelperProvider, eventsProvider: CoreEventsProvider, private textUtils: CoreTextUtilsProvider, private coursesProvider: CoreCoursesProvider, sitesProvider: CoreSitesProvider, private navCtrl: NavController, private injector: Injector, - private prefetchDelegate: CoreCourseModulePrefetchDelegate) { + private prefetchDelegate: CoreCourseModulePrefetchDelegate, private syncProvider: CoreCourseSyncProvider) { this.course = navParams.get('course'); this.sectionId = navParams.get('sectionId'); this.sectionNumber = navParams.get('sectionNumber'); @@ -87,7 +89,17 @@ export class CoreCourseSectionPage implements OnDestroy { if (shouldRefresh) { this.completionObserver = eventsProvider.on(CoreEventsProvider.COMPLETION_MODULE_VIEWED, (data) => { if (data && data.courseId == this.course.id) { - this.refreshAfterCompletionChange(); + this.refreshAfterCompletionChange(true); + } + }); + + this.syncObserver = eventsProvider.on(CoreCourseSyncProvider.AUTO_SYNCED, (data) => { + if (data && data.courseId == this.course.id) { + this.refreshAfterCompletionChange(false); + + if (data.warnings && data.warnings[0]) { + this.domUtils.showErrorModal(data.warnings[0]); + } } }); } @@ -113,7 +125,7 @@ export class CoreCourseSectionPage implements OnDestroy { this.courseHelper.openModule(this.navCtrl, this.module, this.course.id, this.sectionId); } - this.loadData().finally(() => { + this.loadData(false, true).finally(() => { this.dataLoaded = true; if (!this.downloadCourseEnabled) { @@ -146,19 +158,34 @@ export class CoreCourseSectionPage implements OnDestroy { /** * Fetch and load all the data required for the view. + * + * @param {boolean} [refresh] If it's refreshing content. + * @param {boolean} [sync] If the refresh is needs syncing. + * @return {Promise} Promise resolved when done. */ - protected loadData(refresh?: boolean): Promise { + protected loadData(refresh?: boolean, sync?: boolean): Promise { // First of all, get the course because the data might have changed. return this.coursesProvider.getUserCourse(this.course.id).catch(() => { // Error getting the course, probably guest access. }).then((course) => { - const promises = []; - let promise; - if (course) { this.course = course; } + if (sync) { + // Try to synchronize the course data. + return this.syncProvider.syncCourse(this.course.id).then((result) => { + if (result.warnings && result.warnings.length) { + this.domUtils.showErrorModal(result.warnings[0]); + } + }).catch(() => { + // For now we don't allow manual syncing, so ignore errors. + }); + } + }).then(() => { + const promises = []; + let promise; + // Get the completion status. if (this.course.enablecompletion === false) { // Completion not enabled. @@ -185,7 +212,7 @@ export class CoreCourseSectionPage implements OnDestroy { } }).then((sections) => { - this.courseHelper.addHandlerDataForModules(sections, this.course.id, completionStatus); + this.courseHelper.addHandlerDataForModules(sections, this.course.id, completionStatus, this.course.fullname); // Format the name of each section and check if it has content. this.sections = sections.map((section) => { @@ -268,7 +295,7 @@ export class CoreCourseSectionPage implements OnDestroy { */ doRefresh(refresher?: any): Promise { return this.invalidateData().finally(() => { - return this.loadData(true).finally(() => { + return this.loadData(true, true).finally(() => { /* Do not call doRefresh on the format component if the refresher is defined in the format component to prevent an inifinite loop. */ let promise; @@ -290,7 +317,7 @@ export class CoreCourseSectionPage implements OnDestroy { */ onCompletionChange(): void { this.invalidateData().finally(() => { - this.refreshAfterCompletionChange(); + this.refreshAfterCompletionChange(true); }); } @@ -314,8 +341,10 @@ export class CoreCourseSectionPage implements OnDestroy { /** * Refresh list after a completion change since there could be new activities. + * + * @param {boolean} [sync] If the refresh is needs syncing. */ - protected refreshAfterCompletionChange(): void { + protected refreshAfterCompletionChange(sync?: boolean): void { // Save scroll position to restore it once done. const scrollElement = this.content.getScrollElement(), scrollTop = scrollElement.scrollTop || 0, @@ -324,7 +353,7 @@ export class CoreCourseSectionPage implements OnDestroy { this.dataLoaded = false; this.domUtils.scrollToTop(this.content); // Scroll top so the spinner is seen. - this.loadData().then(() => { + this.loadData(true, sync).then(() => { return this.formatComponent.doRefresh(undefined, undefined, true); }).finally(() => { this.dataLoaded = true; diff --git a/src/core/course/providers/course-offline.ts b/src/core/course/providers/course-offline.ts index 774532a45..869161e8e 100644 --- a/src/core/course/providers/course-offline.ts +++ b/src/core/course/providers/course-offline.ts @@ -40,6 +40,10 @@ export class CoreCourseOfflineProvider { name: 'courseid', type: 'INTEGER' }, + { + name: 'coursename', + type: 'TEXT' + }, { name: 'timecreated', type: 'INTEGER' @@ -66,6 +70,19 @@ export class CoreCourseOfflineProvider { }); } + /** + * Get all offline manual completions for a certain course. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the list of completions. + */ + getAllManualCompletions(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + + return site.getDb().getRecords(CoreCourseOfflineProvider.MANUAL_COMPLETION_TABLE); + }); + } + /** * Get all offline manual completions for a certain course. * @@ -100,10 +117,11 @@ export class CoreCourseOfflineProvider { * @param {number} cmId The module ID to store the completion. * @param {number} completed Whether the module is completed or not. * @param {number} courseId Course ID the module belongs to. + * @param {string} [courseName] Course name. Recommended, it is used to display a better warning message. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise<{status: boolean, offline: boolean}>} Promise resolved when completion is successfully stored. */ - markCompletedManually(cmId: number, completed: number, courseId: number, siteId?: string) + markCompletedManually(cmId: number, completed: number, courseId: number, courseName?: string, siteId?: string) : Promise<{status: boolean, offline: boolean}> { // Store the offline data. @@ -112,6 +130,7 @@ export class CoreCourseOfflineProvider { cmid: cmId, completed: completed, courseid: courseId, + coursename: courseName || '', timecreated: Date.now() }; diff --git a/src/core/course/providers/course.ts b/src/core/course/providers/course.ts index bbe338cd4..4e30f65fb 100644 --- a/src/core/course/providers/course.ts +++ b/src/core/course/providers/course.ts @@ -677,15 +677,18 @@ export class CoreCourseProvider { * @param {number} cmId The module ID. * @param {number} completed Whether the module is completed or not. * @param {number} courseId Course ID the module belongs to. + * @param {string} [courseName] Course name. Recommended, it is used to display a better warning message. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when completion is successfully sent or stored. */ - markCompletedManually(cmId: number, completed: number, courseId: number, siteId?: string): Promise { + markCompletedManually(cmId: number, completed: number, courseId: number, courseName?: string, siteId?: string) + : Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); // Convenience function to store a message to be synchronized later. const storeOffline = (): Promise => { - return this.courseOffline.markCompletedManually(cmId, completed, courseId, siteId); + return this.courseOffline.markCompletedManually(cmId, completed, courseId, courseName, siteId); }; // Check if we already have a completion stored. @@ -703,7 +706,7 @@ export class CoreCourseProvider { }); } - if (!this.appProvider.isOnline()) { + if (!this.appProvider.isOnline() && courseId) { // App is offline, store the action. return storeOffline(); } @@ -720,7 +723,7 @@ export class CoreCourseProvider { return result; }).catch((error) => { - if (this.utils.isWebServiceError(error)) { + if (this.utils.isWebServiceError(error) || !courseId) { // The WebService has thrown an error, this means that responses cannot be submitted. return Promise.reject(error); } else { diff --git a/src/core/course/providers/helper.ts b/src/core/course/providers/helper.ts index 88c8f433f..968887a3e 100644 --- a/src/core/course/providers/helper.ts +++ b/src/core/course/providers/helper.ts @@ -131,9 +131,10 @@ export class CoreCourseHelperProvider { * @param {any[]} sections List of sections to treat modules. * @param {number} courseId Course ID of the modules. * @param {any[]} [completionStatus] List of completion status. + * @param {string} [courseName] Course name. Recommended if completionStatus is supplied. * @return {boolean} Whether the sections have content. */ - addHandlerDataForModules(sections: any[], courseId: number, completionStatus?: any): boolean { + addHandlerDataForModules(sections: any[], courseId: number, completionStatus?: any, courseName?: string): boolean { let hasContent = false; sections.forEach((section) => { @@ -150,6 +151,7 @@ export class CoreCourseHelperProvider { // Check if activity has completions and if it's marked. module.completionstatus = completionStatus[module.id]; module.completionstatus.courseId = courseId; + module.completionstatus.courseName = courseName; } // Check if the module is stealth. diff --git a/src/core/course/providers/sync-cron-handler.ts b/src/core/course/providers/sync-cron-handler.ts new file mode 100644 index 000000000..f7bd6cdba --- /dev/null +++ b/src/core/course/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 { CoreCourseSyncProvider } from './sync'; + +/** + * Synchronization cron handler. + */ +@Injectable() +export class CoreCourseSyncCronHandler implements CoreCronHandler { + name = 'CoreCourseSyncCronHandler'; + + constructor(private courseSync: CoreCourseSyncProvider) {} + + /** + * 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.courseSync.syncAllCourses(siteId); + } + + /** + * Get the time between consecutive executions. + * + * @return {number} Time between consecutive executions (in ms). + */ + getInterval(): number { + return this.courseSync.syncInterval; + } +} diff --git a/src/core/course/providers/sync.ts b/src/core/course/providers/sync.ts new file mode 100644 index 000000000..670040881 --- /dev/null +++ b/src/core/course/providers/sync.ts @@ -0,0 +1,178 @@ +// (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 { CoreLoggerProvider } from '@providers/logger'; +import { CoreSyncBaseProvider } from '@classes/base-sync'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreAppProvider } from '@providers/app'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreCourseOfflineProvider } from './course-offline'; +import { CoreCourseProvider } from './course'; +import { CoreEventsProvider } from '@providers/events'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreSyncProvider } from '@providers/sync'; + +/** + * Service to sync course offline data. This only syncs the offline data of the course itself, not the offline data of + * the activities in the course. + */ +@Injectable() +export class CoreCourseSyncProvider extends CoreSyncBaseProvider { + + static AUTO_SYNCED = 'core_course_autom_synced'; + + constructor(protected sitesProvider: CoreSitesProvider, loggerProvider: CoreLoggerProvider, + protected appProvider: CoreAppProvider, private courseOffline: CoreCourseOfflineProvider, + private eventsProvider: CoreEventsProvider, private courseProvider: CoreCourseProvider, + translate: TranslateService, private utils: CoreUtilsProvider, protected textUtils: CoreTextUtilsProvider, + syncProvider: CoreSyncProvider) { + + super('CoreCourseSyncProvider', loggerProvider, sitesProvider, appProvider, syncProvider, textUtils, translate); + } + + /** + * Try to synchronize all the courses 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. + */ + syncAllCourses(siteId?: string): Promise { + return this.syncOnSites('courses', this.syncAllCoursesFunc.bind(this), undefined, siteId); + } + + /** + * Sync all courses on a site. + * + * @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. + */ + protected syncAllCoursesFunc(siteId?: string): Promise { + return this.courseOffline.getAllManualCompletions(siteId).then((completions) => { + const promises = []; + + // Sync all courses. + completions.forEach((completion) => { + promises.push(this.syncCourseIfNeeded(completion.courseid, siteId).then((result) => { + if (result && result.updated) { + // Sync successful, send event. + this.eventsProvider.trigger(CoreCourseSyncProvider.AUTO_SYNCED, { + courseId: completion.courseid, + warnings: result.warnings + }, siteId); + } + })); + }); + }); + } + + /** + * Sync a course if it's needed. + * + * @param {number} courseId Course ID to be synced. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the course is synced or it doesn't need to be synced. + */ + syncCourseIfNeeded(courseId: number, siteId?: string): Promise { + // Usually we call isSyncNeeded to check if a certain time has passed. + // However, since we barely send data for now just sync the course. + return this.syncCourse(courseId, siteId); + } + + /** + * Synchronize a course. + * + * @param {number} courseId Course ID to be synced. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if sync is successful, rejected otherwise. + */ + syncCourse(courseId: number, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + if (this.isSyncing(courseId, siteId)) { + // There's already a sync ongoing for this discussion, return the promise. + return this.getOngoingSync(courseId, siteId); + } + + this.logger.debug(`Try to sync course '${courseId}'`); + + const result = { + warnings: [], + updated: false + }; + + // Get offline responses to be sent. + const syncPromise = this.courseOffline.getCourseManualCompletions(courseId, siteId).catch(() => { + // No offline data found, return empty list. + return []; + }).then((completions) => { + if (!completions || !completions.length) { + // Nothing to sync. + return; + } + + if (!this.appProvider.isOnline()) { + // Cannot sync in offline. + return Promise.reject(null); + } + + const promises = []; + + // Send all the completions. + completions.forEach((entry) => { + promises.push(this.courseProvider.markCompletedManuallyOnline(entry.cmid, entry.completed, siteId).then(() => { + result.updated = true; + + return this.courseOffline.deleteManualCompletion(entry.cmid, siteId); + }).catch((error) => { + if (this.utils.isWebServiceError(error)) { + // The WebService has thrown an error, this means that the completion cannot be submitted. Delete it. + result.updated = true; + + return this.courseOffline.deleteManualCompletion(entry.cmid, siteId).then(() => { + // Responses deleted, add a warning. + result.warnings.push(this.translate.instant('core.course.warningofflinemanualcompletiondeleted', { + name: entry.coursename || courseId, + error: error.error + })); + }); + } + + // Couldn't connect to server, reject. + return Promise.reject(error); + })); + }); + + return Promise.all(promises); + }).then(() => { + if (result.updated) { + // Update data. + return this.courseProvider.invalidateSections(courseId, siteId).then(() => { + return this.courseProvider.getActivitiesCompletionStatus(courseId); + }).catch(() => { + // Ignore errors. + }); + } + }).then(() => { + // Sync finished, set sync time. + return this.setSyncTime(courseId, siteId); + }).then(() => { + // All done, return the data. + return result; + }); + + return this.addOngoingSync(courseId, syncPromise, siteId); + } +}