diff --git a/scripts/langindex.json b/scripts/langindex.json index 758f678d6..7f279cbfd 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -408,6 +408,7 @@ "addon.mod_assign.submitassignment_help": "assign", "addon.mod_assign.submittedearly": "assign", "addon.mod_assign.submittedlate": "assign", + "addon.mod_assign.syncblockedusercomponent": "local_moodlemobileapp", "addon.mod_assign.timemodified": "assign", "addon.mod_assign.timeremaining": "assign", "addon.mod_assign.ungroupedusers": "assign", diff --git a/src/addon/mod/assign/components/index/index.ts b/src/addon/mod/assign/components/index/index.ts index b47535c64..d7370fea3 100644 --- a/src/addon/mod/assign/components/index/index.ts +++ b/src/addon/mod/assign/components/index/index.ts @@ -299,7 +299,7 @@ export class AddonModAssignIndexComponent extends CoreCourseModuleMainActivityCo */ protected hasSyncSucceed(result: any): boolean { if (result.updated) { - this.submissionComponent && this.submissionComponent.invalidateAndRefresh(); + this.submissionComponent && this.submissionComponent.invalidateAndRefresh(false); } return result.updated; @@ -324,7 +324,7 @@ export class AddonModAssignIndexComponent extends CoreCourseModuleMainActivityCo } return Promise.all(promises).finally(() => { - this.submissionComponent && this.submissionComponent.invalidateAndRefresh(); + this.submissionComponent && this.submissionComponent.invalidateAndRefresh(true); }); } diff --git a/src/addon/mod/assign/components/submission/addon-mod-assign-submission.html b/src/addon/mod/assign/components/submission/addon-mod-assign-submission.html index e4cb512e5..2164fd42d 100644 --- a/src/addon/mod/assign/components/submission/addon-mod-assign-submission.html +++ b/src/addon/mod/assign/components/submission/addon-mod-assign-submission.html @@ -20,7 +20,7 @@ - + diff --git a/src/addon/mod/assign/components/submission/submission.ts b/src/addon/mod/assign/components/submission/submission.ts index 6bc9c7282..42ea027ba 100644 --- a/src/addon/mod/assign/components/submission/submission.ts +++ b/src/addon/mod/assign/components/submission/submission.ts @@ -16,7 +16,7 @@ import { Component, Input, OnInit, OnDestroy, ViewChild, Optional, ViewChildren, import { NavController } from 'ionic-angular'; import { TranslateService } from '@ngx-translate/core'; import { CoreAppProvider } from '@providers/app'; -import { CoreEventsProvider } from '@providers/events'; +import { CoreEventsProvider, CoreEventObserver } from '@providers/events'; import { CoreGroupsProvider } from '@providers/groups'; import { CoreLangProvider } from '@providers/lang'; import { CoreSitesProvider } from '@providers/sites'; @@ -35,7 +35,9 @@ import { } from '../../providers/assign'; import { AddonModAssignHelperProvider } from '../../providers/helper'; import { AddonModAssignOfflineProvider } from '../../providers/assign-offline'; +import { AddonModAssignSync, AddonModAssignSyncProvider } from '../../providers/assign-sync'; import { CoreTabsComponent } from '@components/tabs/tabs'; +import { CoreTabComponent } from '@components/tabs/tab'; import { CoreSplitViewComponent } from '@components/split-view/split-view'; import { AddonModAssignSubmissionPluginComponent } from '../submission-plugin/submission-plugin'; @@ -107,6 +109,7 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy { protected submissionStatusAvailable: boolean; // Whether we were able to retrieve the submission status. protected originalGrades: any = {}; // Object with the original grade data, to check for changes. protected isDestroyed: boolean; // Whether the component has been destroyed. + protected syncObserver: CoreEventObserver; constructor(protected navCtrl: NavController, protected appProvider: CoreAppProvider, protected domUtils: CoreDomUtilsProvider, sitesProvider: CoreSitesProvider, protected syncProvider: CoreSyncProvider, protected timeUtils: CoreTimeUtilsProvider, @@ -129,7 +132,29 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy { this.selectedTab = this.showGrade && this.showGrade !== 'false' ? 1 : 0; this.isSubmittedForGrading = !!this.submitId; - this.loadData(); + this.loadData(true); + + // Refresh data if this assign is synchronized and it's grading. + const events = [AddonModAssignSyncProvider.AUTO_SYNCED, AddonModAssignSyncProvider.MANUAL_SYNCED]; + + this.syncObserver = this.eventsProvider.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 (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); } /** @@ -241,7 +266,7 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy { }, this.siteId); } else { // Invalidate and refresh data to update this view. - this.invalidateAndRefresh(); + this.invalidateAndRefresh(true); } }).catch((error) => { this.domUtils.showErrorModalDefault(error, 'core.error', true); @@ -334,9 +359,10 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy { /** * Invalidate and refresh data. * + * @param sync Whether to try to synchronize data. * @return Promise resolved when done. */ - invalidateAndRefresh(): Promise { + invalidateAndRefresh(sync?: boolean): Promise { this.loaded = false; const promises = []; @@ -361,16 +387,17 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy { return Promise.all(promises).catch(() => { // Ignore errors. }).then(() => { - return this.loadData(); + return this.loadData(sync); }); } /** * Load the data to render the submission. * + * @param sync Whether to try to synchronize data. * @return Promise resolved when done. */ - protected loadData(): Promise { + protected async loadData(sync?: boolean): Promise { let isBlind = !!this.blindId; this.previousAttempt = undefined; @@ -381,44 +408,53 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy { isBlind = false; } - // Get the assignment. - return this.assignProvider.getAssignment(this.courseId, this.moduleId).then((assign) => { - const time = this.timeUtils.timestamp(), - promises = []; + try { + // Get the assignment. + this.assign = await this.assignProvider.getAssignment(this.courseId, this.moduleId); - this.assign = assign; + 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.instance.syncAssign(this.assign.id); - if (assign.allowsubmissionsfromdate && assign.allowsubmissionsfromdate >= time) { - this.fromDate = this.timeUtils.userDate(assign.allowsubmissionsfromdate * 1000); + if (result && result.updated) { + this.eventsProvider.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 = this.timeUtils.timestamp(); + let promises = []; + + if (this.assign.allowsubmissionsfromdate && this.assign.allowsubmissionsfromdate >= time) { + this.fromDate = this.timeUtils.userDate(this.assign.allowsubmissionsfromdate * 1000); } this.currentAttempt = 0; this.maxAttemptsText = this.translate.instant('addon.mod_assign.unlimitedattempts'); - this.blindMarking = this.isSubmittedForGrading && assign.blindmarking && !assign.revealidentities; + this.blindMarking = this.isSubmittedForGrading && this.assign.blindmarking && !this.assign.revealidentities; if (!this.blindMarking && this.submitId != this.currentUserId) { - promises.push(this.userProvider.getProfile(this.submitId, this.courseId).then((profile) => { - this.user = profile; - })); + promises.push(this.loadSubmissionUserProfile()); } // Check if there's any offline data for this submission. - promises.push(this.assignOfflineProvider.getSubmission(assign.id, this.submitId).then((data) => { - this.hasOffline = data && data.plugindata && Object.keys(data.plugindata).length > 0; - this.submittedOffline = data && data.submitted; - }).catch(() => { - // No offline data found. - this.hasOffline = false; - this.submittedOffline = false; - })); + promises.push(this.loadSubmissionOfflineData()); + + await Promise.all(promises); - return Promise.all(promises); - }).then(() => { // Get submission status. - return this.assignProvider.getSubmissionStatusWithRetry(this.assign, this.submitId, undefined, isBlind); - }).then((response) => { + const response = await this.assignProvider.getSubmissionStatusWithRetry(this.assign, this.submitId, undefined, isBlind); - const promises = []; + promises = []; this.submissionStatusAvailable = true; this.lastAttempt = response.lastattempt; @@ -450,16 +486,41 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy { } // Get the submission plugins that don't support editing. - promises.push(this.assignProvider.getUnsupportedEditPlugins(this.userSubmission.plugins).then((list) => { - this.unsupportedEditPlugins = list; - })); + promises.push(this.loadUnsupportedPlugins()); - return Promise.all(promises); - }).catch((error) => { + await Promise.all(promises); + } catch (error) { this.domUtils.showErrorModalDefault(error, 'Error getting assigment data.'); - }).finally(() => { + } finally { this.loaded = true; - }); + } + } + + /** + * Load profile of submission's user. + * + * @return Promise resolved when done. + */ + protected async loadSubmissionUserProfile(): Promise { + this.user = await this.userProvider.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 data = await this.assignOfflineProvider.getSubmission(this.assign.id, this.submitId); + + this.hasOffline = data && data.plugindata && Object.keys(data.plugindata).length > 0; + this.submittedOffline = data && data.submitted; + } catch (error) { + // No offline data found. + this.hasOffline = false; + this.submittedOffline = false; + } } /** @@ -537,11 +598,6 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy { // Make sure outcomes is an array. gradeInfo.outcomes = gradeInfo.outcomes || []; - if (!this.isDestroyed) { - // Block the assignment. - this.syncProvider.blockOperation(AddonModAssignProvider.COMPONENT, this.assign.id); - } - // Treat the grade info. return this.treatGradeInfo(); }).then(() => { @@ -627,6 +683,15 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy { }); } + /** + * Get the submission plugins that don't support editing. + * + * @return Promise resolved when done. + */ + protected async loadUnsupportedPlugins(): Promise { + this.unsupportedEditPlugins = await this.assignProvider.getUnsupportedEditPlugins(this.userSubmission.plugins); + } + /** * Set the submission status name and class. * @@ -764,7 +829,7 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy { return this.discardDrafts(); }).finally(() => { // Invalidate and refresh data. - this.invalidateAndRefresh(); + this.invalidateAndRefresh(true); this.eventsProvider.trigger(AddonModAssignProvider.GRADED_EVENT, { assignmentId: this.assign.id, @@ -952,15 +1017,42 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy { } } + /** + * Block or unblock the automatic sync of the user grade. + * + * @param block Whether to block or unblock. + */ + protected setGradeSyncBlocked(block?: boolean): void { + if (this.isDestroyed || !this.assign || !this.isGrading) { + return; + } + + const syncId = AddonModAssignSync.instance.getGradeSyncId(this.assign.id, this.submitId); + + if (block) { + this.syncProvider.blockOperation(AddonModAssignProvider.COMPONENT, syncId); + } else { + this.syncProvider.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(this.tabs.getIndex(tab) === 1); + } + /** * Component being destroyed. */ ngOnDestroy(): void { + this.setGradeSyncBlocked(false); this.isDestroyed = true; - - if (this.assign && this.isGrading) { - this.syncProvider.unblockOperation(AddonModAssignProvider.COMPONENT, this.assign.id); - } + this.syncObserver && this.syncObserver.off(); } } diff --git a/src/addon/mod/assign/lang/en.json b/src/addon/mod/assign/lang/en.json index fd076f605..5311cdf8a 100644 --- a/src/addon/mod/assign/lang/en.json +++ b/src/addon/mod/assign/lang/en.json @@ -89,6 +89,7 @@ "submitassignment": "Submit assignment", "submittedearly": "Assignment was submitted {{$a}} early", "submittedlate": "Assignment was submitted {{$a}} late", + "syncblockedusercomponent": "user grade", "timemodified": "Last modified", "timeremaining": "Time remaining", "ungroupedusers": "The setting 'Require group to make submission' is enabled and some users are either not a member of any group, or are a member of more than one group, so are unable to make submissions.", diff --git a/src/addon/mod/assign/pages/submission-list/submission-list.ts b/src/addon/mod/assign/pages/submission-list/submission-list.ts index 1c1429011..3ca5c7674 100644 --- a/src/addon/mod/assign/pages/submission-list/submission-list.ts +++ b/src/addon/mod/assign/pages/submission-list/submission-list.ts @@ -15,7 +15,7 @@ import { Component, OnInit, OnDestroy, ViewChild } from '@angular/core'; import { IonicPage, NavParams } from 'ionic-angular'; import { TranslateService } from '@ngx-translate/core'; -import { CoreEventsProvider } from '@providers/events'; +import { CoreEventsProvider, CoreEventObserver } from '@providers/events'; import { CoreSitesProvider } from '@providers/sites'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreGroupsProvider, CoreGroupInfo } from '@providers/groups'; @@ -23,6 +23,7 @@ import { AddonModAssignProvider, AddonModAssignAssign, AddonModAssignGrade, AddonModAssignSubmission } from '../../providers/assign'; import { AddonModAssignOfflineProvider } from '../../providers/assign-offline'; +import { AddonModAssignSyncProvider, AddonModAssignSync } from '../../providers/assign-sync'; import { AddonModAssignHelperProvider, AddonModAssignSubmissionFormatted } from '../../providers/helper'; import { CoreSplitViewComponent } from '@components/split-view/split-view'; @@ -54,10 +55,11 @@ export class AddonModAssignSubmissionListPage implements OnInit, OnDestroy { protected moduleId: number; // Module ID the submission belongs to. protected courseId: number; // Course ID the assignment belongs to. protected selectedStatus: string; // The status to see. - protected gradedObserver; // Observer to refresh data when a grade changes. + protected gradedObserver: CoreEventObserver; // Observer to refresh data when a grade changes. + protected syncObserver: CoreEventObserver; // OObserver to refresh data when the async is synchronized. protected submissionsData: {canviewsubmissions: boolean, submissions?: AddonModAssignSubmission[]}; - constructor(navParams: NavParams, protected sitesProvider: CoreSitesProvider, eventsProvider: CoreEventsProvider, + constructor(navParams: NavParams, protected sitesProvider: CoreSitesProvider, protected eventsProvider: CoreEventsProvider, protected domUtils: CoreDomUtilsProvider, protected translate: TranslateService, protected assignProvider: AddonModAssignProvider, protected assignOfflineProvider: AddonModAssignOfflineProvider, protected assignHelper: AddonModAssignHelperProvider, protected groupsProvider: CoreGroupsProvider) { @@ -79,22 +81,37 @@ export class AddonModAssignSubmissionListPage implements OnInit, OnDestroy { // Update data if some grade changes. this.gradedObserver = eventsProvider.on(AddonModAssignProvider.GRADED_EVENT, (data) => { - if (this.assign && data.assignmentId == this.assign.id && data.userId == sitesProvider.getCurrentSiteUserId()) { + if (this.loaded && this.assign && data.assignmentId == this.assign.id && + data.userId == sitesProvider.getCurrentSiteUserId()) { // Grade changed, refresh the data. this.loaded = false; - this.refreshAllData().finally(() => { + this.refreshAllData(true).finally(() => { this.loaded = true; }); } }, sitesProvider.getCurrentSiteId()); + + // Refresh data if this assign is synchronized. + const events = [AddonModAssignSyncProvider.AUTO_SYNCED, AddonModAssignSyncProvider.MANUAL_SYNCED]; + this.syncObserver = eventsProvider.onMultiple(events, (data) => { + if (!this.loaded || data.context == 'submission-list') { + return; + } + + this.loaded = false; + + this.refreshAllData(false).finally(() => { + this.loaded = true; + }); + }, this.sitesProvider.getCurrentSiteId()); } /** * Component being initialized. */ ngOnInit(): void { - this.fetchAssignment().finally(() => { + this.fetchAssignment(true).finally(() => { if (!this.selectedSubmissionId && this.splitviewCtrl.isOn() && this.submissions.length > 0) { // Take first and load it. this.loadSubmission(this.submissions[0]); @@ -107,34 +124,49 @@ export class AddonModAssignSubmissionListPage implements OnInit, OnDestroy { /** * Fetch assignment data. * + * @param sync Whether to try to synchronize data. * @return Promise resolved when done. */ - protected fetchAssignment(): Promise { + protected async fetchAssignment(sync?: boolean): Promise { + try { + // Get assignment data. + this.assign = await this.assignProvider.getAssignment(this.courseId, this.moduleId); - // Get assignment data. - return this.assignProvider.getAssignment(this.courseId, this.moduleId).then((assign) => { - this.title = assign.name || this.title; - this.assign = assign; + this.title = this.assign.name || this.title; - // Get assignment submissions. - return this.assignProvider.getSubmissions(assign.id); - }).then((data) => { - if (!data.canviewsubmissions) { - // User shouldn't be able to reach here. - return Promise.reject(null); + if (sync) { + try { + // Try to synchronize data. + const result = await AddonModAssignSync.instance.syncAssign(this.assign.id); + + if (result && result.updated) { + this.eventsProvider.trigger(AddonModAssignSyncProvider.MANUAL_SYNCED, { + assignId: this.assign.id, + warnings: result.warnings, + gradesBlocked: result.gradesBlocked, + context: 'submission-list', + }, this.sitesProvider.getCurrentSiteId()); + } + } catch (error) { + // Ignore errors, probably user is offline or sync is blocked. + } } - this.submissionsData = data; + // Get assignment submissions. + this.submissionsData = await this.assignProvider.getSubmissions(this.assign.id); + + if (!this.submissionsData.canviewsubmissions) { + // User shouldn't be able to reach here. + throw new Error('Cannot view submissions.'); + } // Check if groupmode is enabled to avoid showing wrong numbers. - return this.groupsProvider.getActivityGroupInfo(this.assign.cmid, false).then((groupInfo) => { - this.groupInfo = groupInfo; + this.groupInfo = await this.groupsProvider.getActivityGroupInfo(this.assign.cmid, false); - return this.setGroup(this.groupsProvider.validateGroupId(this.groupId, groupInfo)); - }); - }).catch((error) => { + await this.setGroup(this.groupsProvider.validateGroupId(this.groupId, this.groupInfo)); + } catch (error) { this.domUtils.showErrorModalDefault(error, 'Error getting assigment data.'); - }); + } } /** @@ -265,9 +297,10 @@ export class AddonModAssignSubmissionListPage implements OnInit, OnDestroy { /** * Refresh all the data. * + * @param sync Whether to try to synchronize data. * @return Promise resolved when done. */ - protected refreshAllData(): Promise { + protected refreshAllData(sync?: boolean): Promise { const promises = []; promises.push(this.assignProvider.invalidateAssignmentData(this.courseId)); @@ -279,7 +312,7 @@ export class AddonModAssignSubmissionListPage implements OnInit, OnDestroy { } return Promise.all(promises).finally(() => { - return this.fetchAssignment(); + return this.fetchAssignment(sync); }); } @@ -289,7 +322,7 @@ export class AddonModAssignSubmissionListPage implements OnInit, OnDestroy { * @param refresher Refresher. */ refreshList(refresher: any): void { - this.refreshAllData().finally(() => { + this.refreshAllData(true).finally(() => { refresher.complete(); }); } @@ -299,6 +332,7 @@ export class AddonModAssignSubmissionListPage implements OnInit, OnDestroy { */ ngOnDestroy(): void { this.gradedObserver && this.gradedObserver.off(); + this.syncObserver && this.syncObserver.off(); } } diff --git a/src/addon/mod/assign/pages/submission-review/submission-review.ts b/src/addon/mod/assign/pages/submission-review/submission-review.ts index 030b7c112..d3d2ed5ba 100644 --- a/src/addon/mod/assign/pages/submission-review/submission-review.ts +++ b/src/addon/mod/assign/pages/submission-review/submission-review.ts @@ -137,7 +137,7 @@ export class AddonModAssignSubmissionReviewPage implements OnInit { } return Promise.all(promises).finally(() => { - this.submissionComponent && this.submissionComponent.invalidateAndRefresh(); + this.submissionComponent && this.submissionComponent.invalidateAndRefresh(true); return this.fetchSubmission(); }); diff --git a/src/addon/mod/assign/providers/assign-sync.ts b/src/addon/mod/assign/providers/assign-sync.ts index 8b5bcfbb1..f0e14fe95 100644 --- a/src/addon/mod/assign/providers/assign-sync.ts +++ b/src/addon/mod/assign/providers/assign-sync.ts @@ -25,12 +25,14 @@ 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 } from '@classes/base-sync'; -import { AddonModAssignProvider, AddonModAssignAssign } from './assign'; +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. */ @@ -44,6 +46,11 @@ export interface AddonModAssignSyncResult { * Whether data was updated in the site. */ updated: boolean; + + /** + * Whether some grade couldn't be synced because it was blocked. + */ + gradesBlocked: number[]; } /** @@ -53,6 +60,7 @@ export interface AddonModAssignSyncResult { export class AddonModAssignSyncProvider extends CoreSyncBaseProvider { static AUTO_SYNCED = 'addon_mod_assign_autom_synced'; + static MANUAL_SYNCED = 'addon_mod_assign_manual_synced'; protected componentTranslate: string; @@ -79,6 +87,17 @@ export class AddonModAssignSyncProvider extends CoreSyncBaseProvider { 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. * @@ -121,7 +140,7 @@ export class AddonModAssignSyncProvider extends CoreSyncBaseProvider { * @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 { + syncAllAssignments(siteId?: string, force?: boolean): Promise { return this.syncOnSites('all assignments', this.syncAllAssignmentsFunc.bind(this), [force], siteId); } @@ -132,26 +151,25 @@ export class AddonModAssignSyncProvider extends CoreSyncBaseProvider { * @param force Wether to force sync not depending on last execution. * @param Promise resolved if sync is successful, rejected if sync fails. */ - protected syncAllAssignmentsFunc(siteId?: string, force?: boolean): Promise { + protected async syncAllAssignmentsFunc(siteId?: string, force?: boolean): Promise { // Get all assignments that have offline data. - return this.assignOfflineProvider.getAllAssigns(siteId).then((assignIds) => { - // Sync all assignments that haven't been synced for a while. - const promises = assignIds.map((assignId) => { - const promise = force ? this.syncAssign(assignId, siteId) : this.syncAssignIfNeeded(assignId, siteId); + const assignIds = await this.assignOfflineProvider.getAllAssigns(siteId); - return promise.then((data) => { - if (data && data.updated) { - // Sync done. Send event. - this.eventsProvider.trigger(AddonModAssignSyncProvider.AUTO_SYNCED, { - assignId: assignId, - warnings: data.warnings - }, 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); - return Promise.all(promises); - }); + if (!data || !data.updated) { + // Not updated. + return; + } + + this.eventsProvider.trigger(AddonModAssignSyncProvider.AUTO_SYNCED, { + assignId: assignId, + warnings: data.warnings, + gradesBlocked: data.gradesBlocked, + }, siteId); + })); } /** @@ -161,12 +179,12 @@ export class AddonModAssignSyncProvider extends CoreSyncBaseProvider { * @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. */ - syncAssignIfNeeded(assignId: number, siteId?: string): Promise { - return this.isSyncNeeded(assignId, siteId).then((needed) => { - if (needed) { - return this.syncAssign(assignId, siteId); - } - }); + async syncAssignIfNeeded(assignId: number, siteId?: string): Promise { + const needed = await this.isSyncNeeded(assignId, siteId); + + if (needed) { + return this.syncAssign(assignId, siteId); + } } /** @@ -176,18 +194,9 @@ export class AddonModAssignSyncProvider extends CoreSyncBaseProvider { * @param siteId Site ID. If not defined, current site. * @return Promise resolved in success. */ - syncAssign(assignId: number, siteId?: string): Promise { + async syncAssign(assignId: number, siteId?: string): Promise { siteId = siteId || this.sitesProvider.getCurrentSiteId(); - const promises: Promise[] = [], - result: AddonModAssignSyncResult = { - warnings: [], - updated: false - }; - let assign: AddonModAssignAssign, - courseId: number, - syncPromise: Promise; - if (this.isSyncing(assignId, siteId)) { // There's already a sync ongoing for this assign, return the promise. return this.getOngoingSync(assignId, siteId); @@ -195,79 +204,126 @@ export class AddonModAssignSyncProvider extends CoreSyncBaseProvider { // 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.'); + this.logger.error('Cannot sync assign ' + assignId + ' because it is blocked.'); - return Promise.reject(this.translate.instant('core.errorsyncblocked', {$a: this.componentTranslate})); + throw new CoreSyncBlockedError(this.translate.instant('core.errorsyncblocked', {$a: this.componentTranslate})); } - this.logger.debug('Try to sync assign ' + assignId + ' in site ' + siteId); + return this.addOngoingSync(assignId, this.performSyncAssign(assignId, siteId), siteId); + } - // Get offline submissions to be sent. - promises.push(this.assignOfflineProvider.getAssignSubmissions(assignId, siteId).catch(() => { - // No offline data found, return empty array. - return []; - })); + /** + * 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 { - // Get offline submission grades to be sent. - promises.push(this.assignOfflineProvider.getAssignSubmissionsGrade(assignId, siteId).catch(() => { - // No offline data found, return empty array. - return []; - })); + this.logger.error('Try to sync assign ' + assignId + ' in site ' + siteId); - // Sync offline logs. - promises.push(this.logHelper.syncIfNeeded(AddonModAssignProvider.COMPONENT, assignId, siteId)); + const result: AddonModAssignSyncResult = { + warnings: [], + updated: false, + gradesBlocked: [], + }; - syncPromise = Promise.all(promises).then((results) => { - const submissions = results[0], - grades = results[1]; + // 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), + ]); - if (!submissions.length && !grades.length) { - // Nothing to sync. - return; - } else if (!this.appProvider.isOnline()) { - // Cannot sync in offline. - return Promise.reject(null); - } + const submissions = promisesResults[0]; + const grades = promisesResults[1]; - courseId = submissions.length > 0 ? submissions[0].courseid : grades[0].courseid; + if (!submissions.length && !grades.length) { + // Nothing to sync. + await this.utils.ignoreErrors(this.setSyncTime(assignId, siteId)); - return this.assignProvider.getAssignmentById(courseId, assignId, false, 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; - }); + } else if (!this.appProvider.isOnline()) { + // Cannot sync in offline. + throw new Error(this.translate.instant('core.cannotconnect')); + } - return this.addOngoingSync(assignId, syncPromise, siteId); + 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 []; + } } /** @@ -279,83 +335,76 @@ export class AddonModAssignSyncProvider extends CoreSyncBaseProvider { * @param siteId Site ID. If not defined, current site. * @return Promise resolved if success, rejected otherwise. */ - protected syncSubmission(assign: AddonModAssignAssign, offlineData: any, warnings: string[], siteId?: string): Promise { - const userId = offlineData.userid, - pluginData = {}; - let discardError, - submission; + protected async syncSubmission(assign: AddonModAssignAssign, offlineData: any, warnings: string[], siteId?: string) + : Promise { - return this.assignProvider.getSubmissionStatus(assign.id, userId, undefined, false, true, true, siteId).then((status) => { - const promises = []; + const userId = offlineData.userid; + const pluginData = {}; - submission = this.assignProvider.getSubmissionObjectFromAttempt(assign, status.lastattempt); + const status = await this.assignProvider.getSubmissionStatus(assign.id, userId, undefined, false, true, true, siteId); - if (submission.timemodified != offlineData.onlinetimemodified) { - // The submission was modified in Moodle, discard the submission. - discardError = this.translate.instant('addon.mod_assign.warningsubmissionmodified'); + const submission = this.assignProvider.getSubmissionObjectFromAttempt(assign, status.lastattempt); - return; + 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); } - 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, undefined, 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 = this.textUtils.getErrorMessageFromError(error); - } 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); - } + 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); + })); } /** @@ -368,90 +417,89 @@ export class AddonModAssignSyncProvider extends CoreSyncBaseProvider { * @param siteId Site ID. If not defined, current site. * @return Promise resolved if success, rejected otherwise. */ - protected syncSubmissionGrade(assign: AddonModAssignAssign, offlineData: any, warnings: string[], courseId: number, + protected async syncSubmissionGrade(assign: AddonModAssignAssign, offlineData: any, warnings: string[], courseId: number, siteId?: string): Promise { const userId = offlineData.userid; - let discardError; + const syncId = this.getGradeSyncId(assign.id, userId); - return this.assignProvider.getSubmissionStatus(assign.id, userId, undefined, false, true, true, siteId).then((status) => { - const timemodified = status.feedback && (status.feedback.gradeddate || status.feedback.grade.timemodified); + // 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.!!!!`); - if (timemodified > offlineData.timemodified) { - // The submission grade was modified in Moodle, discard it. - discardError = this.translate.instant('addon.mod_assign.warningsubmissiongrademodified'); + throw new CoreSyncBlockedError(this.translate.instant('core.errorsyncblocked', + {$a: this.translate.instant('addon.mod_assign.syncblockedusercomponent')})); + } - return; - } + const status = await this.assignProvider.getSubmissionStatus(assign.id, userId, undefined, false, true, true, siteId); - // 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) => { + const timemodified = status.feedback && (status.feedback.gradeddate || status.feedback.grade.timemodified); - // 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); - } - }); - } + 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); } }); - }); - }).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. - // 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)); - - return Promise.all(promises); - }).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 = this.textUtils.getErrorMessageFromError(error); - } 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); } } }); + + 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) {} diff --git a/src/assets/lang/en.json b/src/assets/lang/en.json index 98cf2f4e9..7e6b82cbc 100644 --- a/src/assets/lang/en.json +++ b/src/assets/lang/en.json @@ -408,6 +408,7 @@ "addon.mod_assign.submitassignment_help": "Once this assignment is submitted you will not be able to make any more changes.", "addon.mod_assign.submittedearly": "Assignment was submitted {{$a}} early", "addon.mod_assign.submittedlate": "Assignment was submitted {{$a}} late", + "addon.mod_assign.syncblockedusercomponent": "user grade", "addon.mod_assign.timemodified": "Last modified", "addon.mod_assign.timeremaining": "Time remaining", "addon.mod_assign.ungroupedusers": "The setting 'Require group to make submission' is enabled and some users are either not a member of any group, or are a member of more than one group, so are unable to make submissions.", diff --git a/src/classes/base-sync.ts b/src/classes/base-sync.ts index 14bea2fd7..81d7f8ed8 100644 --- a/src/classes/base-sync.ts +++ b/src/classes/base-sync.ts @@ -20,6 +20,18 @@ import { CoreAppProvider } from '@providers/app'; import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreTimeUtilsProvider } from '@providers/utils/time'; +/** + * Blocked sync error. + */ +export class CoreSyncBlockedError extends Error { + constructor(message: string) { + super(message); + + // Set the prototype explicitly, otherwise instanceof won't work as expected. + Object.setPrototypeOf(this, CoreSyncBlockedError.prototype); + } +} + /** * Base class to create sync providers. It provides some common functions. */ @@ -52,6 +64,26 @@ export class CoreSyncBaseProvider { this.component = component; } + /** + * Add an offline data deleted warning to a list of warnings. + * + * @param warnings List of warnings. + * @param component Component. + * @param name Instance name. + * @param error Specific error message. + */ + protected addOfflineDataDeletedWarning(warnings: string[], component: string, name: string, error: string): void { + const warning = this.translate.instant('core.warningofflinedatadeleted', { + component: component, + name: name, + error: error, + }); + + if (warnings.indexOf(warning) == -1) { + warnings.push(warning); + } + } + /** * Add an ongoing sync to the syncPromises list. On finish the promise will be removed. * @@ -60,7 +92,7 @@ export class CoreSyncBaseProvider { * @param siteId Site ID. If not defined, current site. * @return The sync promise. */ - addOngoingSync(id: string | number, promise: Promise, siteId?: string): Promise { + addOngoingSync(id: string | number, promise: Promise, siteId?: string): Promise { siteId = siteId || this.sitesProvider.getCurrentSiteId(); const uniqueId = this.getUniqueSyncId(id); diff --git a/src/providers/events.ts b/src/providers/events.ts index 81493e9f6..3974e7281 100644 --- a/src/providers/events.ts +++ b/src/providers/events.ts @@ -124,6 +124,33 @@ export class CoreEventsProvider { }; } + /** + * Listen for several events. To stop listening to the events: + * let observer = eventsProvider.onMultiple(['something', 'another'], myCallBack); + * ... + * observer.off(); + * + * @param eventNames Names of the events to listen to. + * @param callBack Function to call when any of the events is triggered. + * @param siteId Site where to trigger the event. Undefined won't check the site. + * @return Observer to stop listening. + */ + onMultiple(eventNames: string[], callBack: (value: any) => void, siteId?: string): CoreEventObserver { + + const observers = eventNames.map((name) => { + return this.on(name, callBack, siteId); + }); + + // Create and return a CoreEventObserver. + return { + off: (): void => { + observers.forEach((observer) => { + observer.off(); + }); + } + }; + } + /** * Triggers an event, notifying all the observers. *