From 241f1f2180cbb608706eee78a52f4c33c5a527be Mon Sep 17 00:00:00 2001 From: Albert Gasset Date: Mon, 16 Apr 2018 15:34:43 +0200 Subject: [PATCH 1/2] MOBILE-2337 choice: Migrate Choice --- src/addon/mod/choice/choice.module.ts | 55 +++ .../choice/components/components.module.ts | 45 ++ .../mod/choice/components/index/index.html | 90 ++++ .../mod/choice/components/index/index.ts | 368 +++++++++++++++ src/addon/mod/choice/lang/en.json | 20 + src/addon/mod/choice/pages/index/index.html | 16 + .../mod/choice/pages/index/index.module.ts | 33 ++ src/addon/mod/choice/pages/index/index.ts | 48 ++ src/addon/mod/choice/providers/choice.ts | 434 ++++++++++++++++++ .../mod/choice/providers/link-handler.ts | 29 ++ .../mod/choice/providers/module-handler.ts | 70 +++ src/addon/mod/choice/providers/offline.ts | 166 +++++++ .../mod/choice/providers/prefetch-handler.ts | 125 +++++ .../mod/choice/providers/sync-cron-handler.ts | 47 ++ src/addon/mod/choice/providers/sync.ts | 194 ++++++++ src/addon/mod/survey/providers/sync.ts | 4 +- src/app/app.module.ts | 2 + 17 files changed, 1744 insertions(+), 2 deletions(-) create mode 100644 src/addon/mod/choice/choice.module.ts create mode 100644 src/addon/mod/choice/components/components.module.ts create mode 100644 src/addon/mod/choice/components/index/index.html create mode 100644 src/addon/mod/choice/components/index/index.ts create mode 100644 src/addon/mod/choice/lang/en.json create mode 100644 src/addon/mod/choice/pages/index/index.html create mode 100644 src/addon/mod/choice/pages/index/index.module.ts create mode 100644 src/addon/mod/choice/pages/index/index.ts create mode 100644 src/addon/mod/choice/providers/choice.ts create mode 100644 src/addon/mod/choice/providers/link-handler.ts create mode 100644 src/addon/mod/choice/providers/module-handler.ts create mode 100644 src/addon/mod/choice/providers/offline.ts create mode 100644 src/addon/mod/choice/providers/prefetch-handler.ts create mode 100644 src/addon/mod/choice/providers/sync-cron-handler.ts create mode 100644 src/addon/mod/choice/providers/sync.ts diff --git a/src/addon/mod/choice/choice.module.ts b/src/addon/mod/choice/choice.module.ts new file mode 100644 index 000000000..8cfe8a5d6 --- /dev/null +++ b/src/addon/mod/choice/choice.module.ts @@ -0,0 +1,55 @@ +// (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 { NgModule } from '@angular/core'; +import { CoreCronDelegate } from '@providers/cron'; +import { CoreContentLinksDelegate } from '@core/contentlinks/providers/delegate'; +import { CoreCourseModuleDelegate } from '@core/course/providers/module-delegate'; +import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; +import { AddonModChoiceComponentsModule } from './components/components.module'; +import { AddonModChoiceModuleHandler } from './providers/module-handler'; +import { AddonModChoiceProvider } from './providers/choice'; +import { AddonModChoiceLinkHandler } from './providers/link-handler'; +import { AddonModChoicePrefetchHandler } from './providers/prefetch-handler'; +import { AddonModChoiceSyncProvider } from './providers/sync'; +import { AddonModChoiceSyncCronHandler } from './providers/sync-cron-handler'; +import { AddonModChoiceOfflineProvider } from './providers/offline'; + +@NgModule({ + declarations: [ + ], + imports: [ + AddonModChoiceComponentsModule + ], + providers: [ + AddonModChoiceProvider, + AddonModChoiceModuleHandler, + AddonModChoicePrefetchHandler, + AddonModChoiceLinkHandler, + AddonModChoiceSyncCronHandler, + AddonModChoiceSyncProvider, + AddonModChoiceOfflineProvider + ] +}) +export class AddonModChoiceModule { + constructor(moduleDelegate: CoreCourseModuleDelegate, moduleHandler: AddonModChoiceModuleHandler, + prefetchDelegate: CoreCourseModulePrefetchDelegate, prefetchHandler: AddonModChoicePrefetchHandler, + contentLinksDelegate: CoreContentLinksDelegate, linkHandler: AddonModChoiceLinkHandler, + cronDelegate: CoreCronDelegate, syncHandler: AddonModChoiceSyncCronHandler) { + moduleDelegate.registerHandler(moduleHandler); + prefetchDelegate.registerHandler(prefetchHandler); + contentLinksDelegate.registerHandler(linkHandler); + cronDelegate.register(syncHandler); + } +} diff --git a/src/addon/mod/choice/components/components.module.ts b/src/addon/mod/choice/components/components.module.ts new file mode 100644 index 000000000..a46e79922 --- /dev/null +++ b/src/addon/mod/choice/components/components.module.ts @@ -0,0 +1,45 @@ +// (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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { IonicModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { CoreCourseComponentsModule } from '@core/course/components/components.module'; +import { AddonModChoiceIndexComponent } from './index/index'; + +@NgModule({ + declarations: [ + AddonModChoiceIndexComponent + ], + imports: [ + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule, + CoreCourseComponentsModule + ], + providers: [ + ], + exports: [ + AddonModChoiceIndexComponent + ], + entryComponents: [ + AddonModChoiceIndexComponent + ] +}) +export class AddonModChoiceComponentsModule {} diff --git a/src/addon/mod/choice/components/index/index.html b/src/addon/mod/choice/components/index/index.html new file mode 100644 index 000000000..d0d85de75 --- /dev/null +++ b/src/addon/mod/choice/components/index/index.html @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + +

{{ 'addon.mod_choice.previewonly' | translate:{$a: choice.openTimeReadable} }}

+

{{ 'addon.mod_choice.notopenyet' | translate:{$a: choice.openTimeReadable} }}

+
+ + +

{{ 'addon.mod_choice.yourselection' | translate }}

+

{{ 'addon.mod_choice.expired' | translate:{$a: choice.closeTimeReadable} }}

+
+ + + + {{ 'core.hasdatatosync' | translate:{$a: moduleName} }} + + + + + + + {{ 'addon.mod_choice.full' | translate }} + + + + + + {{ 'addon.mod_choice.full' | translate }} + + + + + + + + + + + + + + + {{ 'addon.mod_choice.responses' | translate }} + + + + + + {{ 'addon.mod_choice.resultsnotsynced' | translate }} + + + + + + + +

+

{{ 'addon.mod_choice.numberofuser' | translate }}: {{ result.numberofuser }} ({{ 'core.percentagenumber' | translate: {$a: result.percentageamount} }})

+ + + + +

{{user.fullname}}

+
+
+
+
+
+
+ +

{{ 'addon.mod_choice.noresultsviewable' | translate }}

+
+
diff --git a/src/addon/mod/choice/components/index/index.ts b/src/addon/mod/choice/components/index/index.ts new file mode 100644 index 000000000..28c178bea --- /dev/null +++ b/src/addon/mod/choice/components/index/index.ts @@ -0,0 +1,368 @@ +// (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 { Component, Optional, Injector } from '@angular/core'; +import { Content } from 'ionic-angular'; +import { CoreCourseModuleMainActivityComponent } from '@core/course/classes/main-activity-component'; +import { AddonModChoiceProvider } from '../../providers/choice'; +import { AddonModChoiceOfflineProvider } from '../../providers/offline'; +import { AddonModChoiceSyncProvider } from '../../providers/sync'; +import * as moment from 'moment'; + +/** + * Component that displays a choice. + */ +@Component({ + selector: 'addon-mod-choice-index', + templateUrl: 'index.html', +}) +export class AddonModChoiceIndexComponent extends CoreCourseModuleMainActivityComponent { + component = AddonModChoiceProvider.COMPONENT; + moduleName = 'choice'; + + choice: any; + options = []; + selectedOption: any; + choiceNotOpenYet = false; + choiceClosed = false; + canEdit = false; + canDelete = false; + canSeeResults = false; + data = []; + labels = []; + results = []; + + protected userId: number; + protected syncEventName = AddonModChoiceSyncProvider.AUTO_SYNCED; + protected hasAnsweredOnline = false; + protected now: number; + + constructor(injector: Injector, private choiceProvider: AddonModChoiceProvider, @Optional() private content: Content, + private choiceOffline: AddonModChoiceOfflineProvider, private choiceSync: AddonModChoiceSyncProvider) { + super(injector); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + super.ngOnInit(); + + this.userId = this.sitesProvider.getCurrentSiteUserId(); + + this.loadContent(false, true).then(() => { + this.choiceProvider.logView(this.choice.id).then(() => { + this.courseProvider.checkModuleCompletion(this.courseId, this.module.completionstatus); + }); + }); + } + + /** + * Perform the invalidate content function. + * + * @return {Promise} Resolved when done. + */ + protected invalidateContent(): Promise { + return Promise.all([ + this.choiceProvider.invalidateChoiceData(this.courseId), + this.choiceProvider.invalidateOptions(this.choice.id), + this.choiceProvider.invalidateResults(this.choice.id), + ]); + } + + /** + * Compares sync event data with current data to check if refresh content is needed. + * + * @param {any} syncEventData Data receiven on sync observer. + * @return {boolean} True if refresh is needed, false otherwise. + */ + protected isRefreshSyncNeeded(syncEventData: any): boolean { + if (this.choice && syncEventData.choiceId == this.choice.id && syncEventData.userId == this.userId) { + this.content.scrollToTop(); + + return true; + } + + return false; + } + + /** + * Download choice contents. + * + * @param {boolean} [refresh=false] If it's refreshing content. + * @param {boolean} [sync=false] If the refresh is needs syncing. + * @param {boolean} [showErrors=false] If show errors to the user of hide them. + * @return {Promise} Promise resolved when done. + */ + protected fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise { + this.now = new Date().getTime(); + + return this.choiceProvider.getChoice(this.courseId, this.module.id).then((choice) => { + this.choice = choice; + this.choice.timeopen = parseInt(choice.timeopen) * 1000; + this.choice.openTimeReadable = moment(choice.timeopen).format('LLL'); + this.choice.timeclose = parseInt(choice.timeclose) * 1000; + this.choice.closeTimeReadable = moment(choice.timeclose).format('LLL'); + + this.description = choice.intro || choice.description; + this.choiceNotOpenYet = choice.timeopen && choice.timeopen > this.now; + this.choiceClosed = choice.timeclose && choice.timeclose <= this.now; + + this.dataRetrieved.emit(choice); + + if (sync) { + // Try to synchronize the choice. + return this.syncActivity(showErrors).then((updated) => { + if (updated) { + // Responses were sent, update the choice. + return this.choiceProvider.getChoice(this.courseId, this.module.id).then((choice) => { + this.choice = choice; + }); + } + }); + } + }).then(() => { + // Check if there are responses stored in offline. + return this.choiceOffline.hasResponse(this.choice.id); + }).then((hasOffline) => { + this.hasOffline = hasOffline; + + // We need fetchOptions to finish before calling fetchResults because it needs hasAnsweredOnline variable. + return this.fetchOptions(hasOffline).then(() => { + return this.fetchResults(); + }); + }).then(() => { + // All data obtained, now fill the context menu. + this.fillContextMenu(refresh); + }); + } + + /** + * Convenience function to get choice options. + * + * @param {boolean} hasOffline True if there are responses stored offline. + * @return {Promise} Promise resolved when done. + */ + protected fetchOptions(hasOffline: boolean): Promise { + return this.choiceProvider.getOptions(this.choice.id).then((options) => { + let promise; + + // Check if the user has answered (synced) to allow show results. + this.hasAnsweredOnline = options.some((option) => option.checked); + + if (hasOffline) { + promise = this.choiceOffline.getResponse(this.choice.id).then((response) => { + const optionsKeys = {}; + options.forEach((option) => { + optionsKeys[option.id] = option; + }); + // Update options with the offline data. + if (response.deleting) { + // Uncheck selected options. + if (response.responses.length > 0) { + // Uncheck all options selected in responses. + response.responses.forEach((selected) => { + if (optionsKeys[selected] && optionsKeys[selected].checked) { + optionsKeys[selected].checked = false; + optionsKeys[selected].countanswers--; + } + }); + } else { + // On empty responses, uncheck all selected. + Object.keys(optionsKeys).forEach((key) => { + if (optionsKeys[key].checked) { + optionsKeys[key].checked = false; + optionsKeys[key].countanswers--; + } + }); + } + } else { + // Uncheck all options to check again the offlines'. + Object.keys(optionsKeys).forEach((key) => { + if (optionsKeys[key].checked) { + optionsKeys[key].checked = false; + optionsKeys[key].countanswers--; + } + }); + // Then check selected ones. + response.responses.forEach((selected) => { + if (optionsKeys[selected]) { + optionsKeys[selected].checked = true; + optionsKeys[selected].countanswers++; + } + }); + } + + // Convert it again to array. + return Object.keys(optionsKeys).map((key) => optionsKeys[key]); + }); + } else { + promise = Promise.resolve(options); + } + + promise.then((options) => { + const isOpen = this.isChoiceOpen(); + + let hasAnswered = false; + this.selectedOption = {id: -1}; // Single choice model. + options.forEach((option) => { + if (option.checked) { + hasAnswered = true; + if (!this.choice.allowmultiple) { + this.selectedOption.id = option.id; + } + } + }); + + this.canEdit = isOpen && (this.choice.allowupdate || !hasAnswered); + this.canDelete = isOpen && this.choice.allowupdate && hasAnswered; + this.options = options; + }); + }); + } + + /** + * Convenience function to get choice results. + * + * @return {Promise} Resolved when done. + */ + protected fetchResults(): Promise { + if (this.choiceNotOpenYet) { + // Cannot see results yet. + this.canSeeResults = false; + + return Promise.resolve(null); + } + + return this.choiceProvider.getResults(this.choice.id).then((results) => { + let hasVotes = false; + this.data = []; + this.labels = []; + results.forEach((result) => { + if (result.numberofuser > 0) { + hasVotes = true; + } + result.percentageamount = parseFloat(result.percentageamount).toFixed(1); + this.data.push(result.numberofuser); + this.labels.push(result.text); + }); + this.canSeeResults = hasVotes || this.choiceProvider.canStudentSeeResults(this.choice, this.hasAnsweredOnline); + this.results = results; + }); + } + + /** + * Check if a choice is open. + * + * @return {boolean} True if choice is open, false otherwise. + */ + protected isChoiceOpen(): boolean { + return (this.choice.timeopen === 0 || this.choice.timeopen <= this.now) && + (this.choice.timeclose === 0 || this.choice.timeclose > this.now); + } + + /** + * Return true if the user has selected at least one option. + * + * @return {boolean} True if the user has responded. + */ + canSave(): boolean { + if (this.choice.allowmultiple) { + return this.options.some((option) => option.checked); + } else { + return this.selectedOption.id !== -1; + } + } + + /** + * Save options selected. + */ + save(): void { + // Only show confirm if choice doesn't allow update. + let promise; + if (this.choice.allowupdate) { + promise = Promise.resolve(); + } else { + promise = this.domUtils.showConfirm(this.translate.instant('core.areyousure')); + } + + promise.then(() => { + const responses = []; + if (this.choice.allowmultiple) { + this.options.forEach((option) => { + if (option.checked) { + responses.push(option.id); + } + }); + } else { + responses.push(this.selectedOption.id); + } + + const modal = this.domUtils.showModalLoading('core.sending', true); + this.choiceProvider.submitResponse(this.choice.id, this.choice.name, this.courseId, responses).then(() => { + // Success! + // Check completion since it could be configured to complete once the user answers the choice. + this.courseProvider.checkModuleCompletion(this.courseId, this.module.completionstatus); + this.content.scrollToTop(); + + // Let's refresh the data. + return this.refreshContent(false); + }).catch((message) => { + this.domUtils.showErrorModalDefault(message, 'addon.mod_choice.cannotsubmit', true); + }).finally(() => { + modal.dismiss(); + }); + }); + } + + /** + * Delete options selected. + */ + delete(): void { + this.domUtils.showConfirm(this.translate.instant('core.areyousure')).then(() => { + const modal = this.domUtils.showModalLoading('core.sending', true); + this.choiceProvider.deleteResponses(this.choice.id, this.choice.name, this.courseId).then(() => { + this.content.scrollToTop(); + + // Success! Let's refresh the data. + return this.refreshContent(false); + }).catch((message) => { + this.domUtils.showErrorModalDefault(message, 'addon.mod_choice.cannotsubmit', true); + }).finally(() => { + modal.dismiss(); + }); + }).catch(() => { + // Ingore cancelled modal. + }); + } + + /** + * Performs the sync of the activity. + * + * @return {Promise} Promise resolved when done. + */ + protected sync(): Promise { + return this.choiceSync.syncChoice(this.choice.id, this.userId); + } + + /** + * Checks if sync has succeed from result sync data. + * + * @param {any} result Data returned on the sync function. + * @return {boolean} Whether it succeed or not. + */ + protected hasSyncSucceed(result: any): boolean { + return result.updated; + } +} diff --git a/src/addon/mod/choice/lang/en.json b/src/addon/mod/choice/lang/en.json new file mode 100644 index 000000000..12e82dee0 --- /dev/null +++ b/src/addon/mod/choice/lang/en.json @@ -0,0 +1,20 @@ +{ + "cannotsubmit": "Sorry, there was a problem submitting your choice. Please try again.", + "choiceoptions": "Choice options", + "errorgetchoice": "Error getting choice data.", + "expired": "Sorry, this activity closed on {{$a}} and is no longer available", + "full": "(Full)", + "noresultsviewable": "The results are not currently viewable.", + "notopenyet": "Sorry, this activity is not available until {{$a}}", + "numberofuser": "Number of responses", + "numberofuserinpercentage": "Percentage of responses", + "previewonly": "This is just a preview of the available options for this activity. You will not be able to submit your choice until {{$a}}.", + "removemychoice": "Remove my choice", + "responses": "Responses", + "responsesresultgraphdescription": "{{number}}% of the users chose the option: {{text}}.", + "responsesresultgraphheader": "Graph display", + "resultsnotsynced": "Your last response must be synchronised before it is included in the results.", + "savemychoice": "Save my choice", + "userchoosethisoption": "Users who chose this option", + "yourselection": "Your selection" +} \ No newline at end of file diff --git a/src/addon/mod/choice/pages/index/index.html b/src/addon/mod/choice/pages/index/index.html new file mode 100644 index 000000000..3655ec817 --- /dev/null +++ b/src/addon/mod/choice/pages/index/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/addon/mod/choice/pages/index/index.module.ts b/src/addon/mod/choice/pages/index/index.module.ts new file mode 100644 index 000000000..bc19841ae --- /dev/null +++ b/src/addon/mod/choice/pages/index/index.module.ts @@ -0,0 +1,33 @@ +// (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 { NgModule } from '@angular/core'; +import { IonicPageModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { AddonModChoiceComponentsModule } from '../../components/components.module'; +import { AddonModChoiceIndexPage } from './index'; + +@NgModule({ + declarations: [ + AddonModChoiceIndexPage, + ], + imports: [ + CoreDirectivesModule, + AddonModChoiceComponentsModule, + IonicPageModule.forChild(AddonModChoiceIndexPage), + TranslateModule.forChild() + ], +}) +export class AddonModChoiceIndexPageModule {} diff --git a/src/addon/mod/choice/pages/index/index.ts b/src/addon/mod/choice/pages/index/index.ts new file mode 100644 index 000000000..1fb470cd4 --- /dev/null +++ b/src/addon/mod/choice/pages/index/index.ts @@ -0,0 +1,48 @@ +// (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 { Component, ViewChild } from '@angular/core'; +import { IonicPage, NavParams } from 'ionic-angular'; +import { AddonModChoiceIndexComponent } from '../../components/index/index'; + +/** + * Page that displays a choice. + */ +@IonicPage({ segment: 'addon-mod-choice-index' }) +@Component({ + selector: 'page-addon-mod-choice-index', + templateUrl: 'index.html', +}) +export class AddonModChoiceIndexPage { + @ViewChild(AddonModChoiceIndexComponent) choiceComponent: AddonModChoiceIndexComponent; + + title: string; + module: any; + courseId: number; + + constructor(navParams: NavParams) { + this.module = navParams.get('module') || {}; + this.courseId = navParams.get('courseId'); + this.title = this.module.name; + } + + /** + * Update some data based on the choice instance. + * + * @param {any} choice Choice instance. + */ + updateData(choice: any): void { + this.title = choice.name || this.title; + } +} diff --git a/src/addon/mod/choice/providers/choice.ts b/src/addon/mod/choice/providers/choice.ts new file mode 100644 index 000000000..8ce53366b --- /dev/null +++ b/src/addon/mod/choice/providers/choice.ts @@ -0,0 +1,434 @@ +// (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 { CoreSitesProvider } from '@providers/sites'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreAppProvider } from '@providers/app'; +import { CoreFilepoolProvider } from '@providers/filepool'; +import { AddonModChoiceOfflineProvider } from './offline'; + +/** + * Service that provides some features for choices. + */ +@Injectable() +export class AddonModChoiceProvider { + static COMPONENT = 'mmaModChoice'; + + static RESULTS_NOT = 0; + static RESULTS_AFTER_ANSWER = 1; + static RESULTS_AFTER_CLOSE = 2; + static RESULTS_ALWAYS = 3; + + protected ROOT_CACHE_KEY = 'mmaModChoice:'; + + constructor(private sitesProvider: CoreSitesProvider, private appProvider: CoreAppProvider, + private filepoolProvider: CoreFilepoolProvider, private utils: CoreUtilsProvider, + private choiceOffline: AddonModChoiceOfflineProvider) {} + + /** + * Check if results can be seen by a student. The student can see the results if: + * - they're always published, OR + * - they're published after the choice is closed and it's closed, OR + * - they're published after answering and the user has answered. + * + * @param {any} choice Choice to check. + * @param {boolean} hasAnswered True if user has answered the choice, false otherwise. + * @return {boolean} True if the students can see the results. + */ + canStudentSeeResults(choice: any, hasAnswered: boolean): boolean { + const now = new Date().getTime(); + + return choice.showresults === AddonModChoiceProvider.RESULTS_ALWAYS || + choice.showresults === AddonModChoiceProvider.RESULTS_AFTER_CLOSE && + choice.timeclose !== 0 && choice.timeclose <= now || + choice.showresults === AddonModChoiceProvider.RESULTS_AFTER_ANSWER && hasAnswered; + } + + /** + * Delete responses from a choice. + * + * @param {number} choiceId Choice ID. + * @param {string} name Choice name. + * @param {number} courseId Course ID the choice belongs to. + * @param {number[]} [responses] IDs of the answers. If not defined, delete all the answers of the current user. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the options are deleted. + */ + deleteResponses(choiceId: number, name: string, courseId: number, responses?: number[], siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + responses = responses || []; + + // Convenience function to store a message to be synchronized later. + const storeOffline = (): Promise => { + return this.choiceOffline.saveResponse(choiceId, name, courseId, responses, true, siteId).then(() => { + return false; + }); + }; + + if (!this.appProvider.isOnline()) { + // App is offline, store the action. + return storeOffline(); + } + + // If there's already a response to be sent to the server, discard it first. + return this.choiceOffline.deleteResponse(choiceId, siteId).then(() => { + return this.deleteResponsesOnline(choiceId, responses, siteId).then(() => { + return true; + }).catch((error) => { + if (error && error.wserror) { + // The WebService has thrown an error, this means that responses cannot be deleted. + return Promise.reject(error.error); + } else { + // Couldn't connect to server, store in offline. + return storeOffline(); + } + }); + }); + } + + /** + * Delete responses from a choice. It will fail if offline or cannot connect. + * + * @param {number} choiceId Choice ID. + * @param {number[]} [responses] IDs of the answers. If not defined, delete all the answers of the current user. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when responses are successfully deleted. + */ + deleteResponsesOnline(choiceId: number, responses?: number[], siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + choiceid: choiceId, + responses: responses + }; + + return site.write('mod_choice_delete_choice_responses', params).then((response) => { + // Other errors ocurring. + if (!response || response.status === false) { + return Promise.reject(this.utils.createFakeWSError('')); + } + + // Invalidate related data. + const promises = [ + this.invalidateOptions(choiceId, site.id), + this.invalidateResults(choiceId, site.id) + ]; + + return Promise.all(promises).catch(() => { + // Ignore errors. + }); + }); + }); + } + + /** + * Get cache key for choice data WS calls. + * + * @param {number} courseId Course ID. + * @return {string} Cache key. + */ + protected getChoiceDataCacheKey(courseId: number): string { + return this.ROOT_CACHE_KEY + 'choice:' + courseId; + } + + /** + * Get cache key for choice options WS calls. + * + * @param {number} choiceId Choice ID. + * @return {string} Cache key. + */ + protected getChoiceOptionsCacheKey(choiceId: number): string { + return this.ROOT_CACHE_KEY + 'options:' + choiceId; + } + + /** + * Get cache key for choice results WS calls. + * + * @param {number} choiceId Choice ID. + * @return {string} Cache key. + */ + protected getChoiceResultsCacheKey(choiceId: number): string { + return this.ROOT_CACHE_KEY + 'results:' + choiceId; + } + + /** + * Get a choice with key=value. If more than one is found, only the first will be returned. + * + * @param {string} siteId Site ID. + * @param {number} courseId Course ID. + * @param {string} key Name of the property to check. + * @param {any} value Value to search. + * @param {boolean} [forceCache=false] True to always get the value from cache, false otherwise. Default false. + * @return {Promise} Promise resolved when the choice is retrieved. + */ + protected getChoiceByDataKey(siteId: string, courseId: number, key: string, value: any, forceCache: boolean = false) + : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + courseids: [courseId] + }; + const preSets = { + cacheKey: this.getChoiceDataCacheKey(courseId), + omitExpires: forceCache + }; + + return site.read('mod_choice_get_choices_by_courses', params, preSets).then((response) => { + if (response && response.choices) { + let currentChoice; + response.choices.forEach((choice) => { + if (!currentChoice && choice[key] == value) { + currentChoice = choice; + } + }); + if (currentChoice) { + return currentChoice; + } + } + + return Promise.reject(null); + }); + }); + } + + /** + * Get a choice by course module ID. + * + * @param {number} courseId Course ID. + * @param {number} cmId Course module ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {boolean} [forceCache=false] True to always get the value from cache, false otherwise. Default false. + * @return {Promise} Promise resolved when the choice is retrieved. + */ + getChoice(courseId: number, cmId: number, siteId?: string, forceCache: boolean = false): Promise { + return this.getChoiceByDataKey(siteId, courseId, 'coursemodule', cmId, forceCache); + } + + /** + * Get a choice by ID. + * + * @param {number} courseId Course ID. + * @param {number} choiceId Choice ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {boolean} [forceCache=false] True to always get the value from cache, false otherwise. Default false. + * @return {Promise} Promise resolved when the choice is retrieved. + */ + getChoiceById(courseId: number, choiceId: number, siteId?: string, forceCache: boolean = false): Promise { + return this.getChoiceByDataKey(siteId, courseId, 'id', choiceId, forceCache); + } + + /** + * Get choice options. + * + * @param {number} choiceId Choice ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with choice options. + */ + getOptions(choiceId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + choiceid: choiceId + }; + const preSets = { + cacheKey: this.getChoiceOptionsCacheKey(choiceId) + }; + + return site.read('mod_choice_get_choice_options', params, preSets).then((response) => { + if (response.options) { + return response.options; + } + + return Promise.reject(null); + }); + }); + } + + /** + * Get choice results. + * + * @param {number} choiceId Choice ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with choice results. + */ + getResults(choiceId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + choiceid: choiceId + }; + const preSets = { + cacheKey: this.getChoiceResultsCacheKey(choiceId) + }; + + return site.read('mod_choice_get_choice_results', params, preSets).then((response) => { + if (response.options) { + return response.options; + } + + return Promise.reject(null); + }); + }); + } + + /** + * Invalidate choice data. + * + * @param {number} courseId Course ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateChoiceData(courseId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(null).then((site) => { + return site.invalidateWsCacheForKey(this.getChoiceDataCacheKey(courseId)); + }); + } + + /** + * Invalidate the prefetched content. + * + * @param {number} moduleId The module ID. + * @param {number} courseId Course ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when data is invalidated. + */ + invalidateContent(moduleId: number, courseId: number, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const promises = []; + + promises.push(this.getChoice(courseId, moduleId).then((choice) => { + return Promise.all([ + this.invalidateChoiceData(courseId), + this.invalidateOptions(choice.id), + this.invalidateResults(choice.id), + ]); + })); + + promises.push(this.filepoolProvider.invalidateFilesByComponent(siteId, AddonModChoiceProvider.COMPONENT, moduleId)); + + return this.utils.allPromises(promises); + } + + /** + * Invalidate choice options. + * + * @param {number} choiceId Choice ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateOptions(choiceId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getChoiceOptionsCacheKey(choiceId)); + }); + } + + /** + * Invalidate choice results. + * + * @param {number} choiceId Choice ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateResults(choiceId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getChoiceResultsCacheKey(choiceId)); + }); + } + + /** + * Report the choice as being viewed. + * + * @param {string} id Choice ID. + * @return {Promise} Promise resolved when the WS call is successful. + */ + logView(id: string): Promise { + if (id) { + const params = { + choiceid: id + }; + + return this.sitesProvider.getCurrentSite().write('mod_choice_view_choice', params); + } + + return Promise.reject(null); + } + + /** + * Send a response to a choice to Moodle. + * + * @param {number} choiceId Choice ID. + * @param {string} name Choice name. + * @param {number} courseId Course ID the choice belongs to. + * @param {number[]} responses IDs of selected options. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with boolean: true if response was sent to server, false if stored in device. + */ + submitResponse(choiceId: number, name: string, courseId: number, responses: number[], siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + // Convenience function to store a message to be synchronized later. + const storeOffline = (): Promise => { + return this.choiceOffline.saveResponse(choiceId, name, courseId, responses, false, siteId).then(() => { + return false; + }); + }; + + if (!this.appProvider.isOnline()) { + // App is offline, store the action. + return storeOffline(); + } + + // If there's already a response to be sent to the server, discard it first. + return this.choiceOffline.deleteResponse(choiceId, siteId).then(() => { + return this.submitResponseOnline(choiceId, responses, siteId).then(() => { + return true; + }).catch((error) => { + if (this.utils.isWebServiceError(error)) { + // The WebService has thrown an error, this means that responses cannot be submitted. + return Promise.reject(error); + } else { + // Couldn't connect to server, store it offline. + return storeOffline(); + } + }); + }); + } + + /** + * Send a response to a choice to Moodle. It will fail if offline or cannot connect. + * + * @param {number} choiceId Choice ID. + * @param {number[]} responses IDs of selected options. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when responses are successfully submitted. + */ + submitResponseOnline(choiceId: number, responses: number[], siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + choiceid: choiceId, + responses: responses + }; + + return site.write('mod_choice_submit_choice_response', params).then(() => { + // Invalidate related data. + const promises = [ + this.invalidateOptions(choiceId, siteId), + this.invalidateResults(choiceId, siteId) + ]; + + return Promise.all(promises).catch(() => { + // Ignore errors. + }); + }); + }); + } +} diff --git a/src/addon/mod/choice/providers/link-handler.ts b/src/addon/mod/choice/providers/link-handler.ts new file mode 100644 index 000000000..d7ea13f57 --- /dev/null +++ b/src/addon/mod/choice/providers/link-handler.ts @@ -0,0 +1,29 @@ +// (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 { CoreContentLinksModuleIndexHandler } from '@core/contentlinks/classes/module-index-handler'; +import { CoreCourseHelperProvider } from '@core/course/providers/helper'; + +/** + * Handler to treat links to choice. + */ +@Injectable() +export class AddonModChoiceLinkHandler extends CoreContentLinksModuleIndexHandler { + name = 'AddonModChoiceLinkHandler'; + + constructor(courseHelper: CoreCourseHelperProvider) { + super(courseHelper, 'AddonModChoice', 'choice'); + } +} diff --git a/src/addon/mod/choice/providers/module-handler.ts b/src/addon/mod/choice/providers/module-handler.ts new file mode 100644 index 000000000..2e2032790 --- /dev/null +++ b/src/addon/mod/choice/providers/module-handler.ts @@ -0,0 +1,70 @@ +// (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 { NavController, NavOptions } from 'ionic-angular'; +import { AddonModChoiceIndexComponent } from '../components/index/index'; +import { CoreCourseModuleHandler, CoreCourseModuleHandlerData } from '@core/course/providers/module-delegate'; +import { CoreCourseProvider } from '@core/course/providers/course'; + +/** + * Handler to support choice modules. + */ +@Injectable() +export class AddonModChoiceModuleHandler implements CoreCourseModuleHandler { + name = 'AddonModChoice'; + modName = 'choice'; + + constructor(private courseProvider: CoreCourseProvider) { } + + /** + * Check if the handler is enabled on a site level. + * + * @return {boolean} Whether or not the handler is enabled on a site level. + */ + isEnabled(): boolean { + return true; + } + + /** + * Get the data required to display the module in the course contents view. + * + * @param {any} module The module object. + * @param {number} courseId The course ID. + * @param {number} sectionId The section ID. + * @return {CoreCourseModuleHandlerData} Data to render the module. + */ + getData(module: any, courseId: number, sectionId: number): CoreCourseModuleHandlerData { + return { + icon: this.courseProvider.getModuleIconSrc('choice'), + title: module.name, + class: 'addon-mod_choice-handler', + action(event: Event, navCtrl: NavController, module: any, courseId: number, options: NavOptions): void { + navCtrl.push('AddonModChoiceIndexPage', {module: module, courseId: courseId}, options); + } + }; + } + + /** + * Get the component to render the module. This is needed to support singleactivity course format. + * The component returned must implement CoreCourseModuleMainComponent. + * + * @param {any} course The course object. + * @param {any} module The module object. + * @return {any} The component to use, undefined if not found. + */ + getMainComponent(course: any, module: any): any { + return AddonModChoiceIndexComponent; + } +} diff --git a/src/addon/mod/choice/providers/offline.ts b/src/addon/mod/choice/providers/offline.ts new file mode 100644 index 000000000..ea4e3538e --- /dev/null +++ b/src/addon/mod/choice/providers/offline.ts @@ -0,0 +1,166 @@ +// (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 { CoreSitesProvider } from '@providers/sites'; + +/** + * Service to handle offline choices. + */ +@Injectable() +export class AddonModChoiceOfflineProvider { + + // Variables for database. + protected CHOICE_TABLE = 'addon_mod_choice_responses'; + protected tablesSchema = [ + { + name: this.CHOICE_TABLE, + columns: [ + { + name: 'choiceid', + type: 'INTEGER' + }, + { + name: 'name', + type: 'TEXT' + }, + { + name: 'courseid', + type: 'INTEGER' + }, + { + name: 'userid', + type: 'INTEGER' + }, + { + name: 'responses', + type: 'TEXT' + }, + { + name: 'deleting', + type: 'INTEGER' + }, + { + name: 'timecreated', + type: 'INTEGER' + } + ], + primaryKeys: ['choiceid', 'userid'] + } + ]; + + constructor(private sitesProvider: CoreSitesProvider) { + this.sitesProvider.createTablesFromSchema(this.tablesSchema); + } + + /** + * Delete a response. + * + * @param {number} choiceId Choice ID to remove. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User the responses belong to. If not defined, current user in site. + * @return {Promise} Promise resolved if stored, rejected if failure. + */ + deleteResponse(choiceId: number, siteId?: string, userId?: number): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + return site.getDb().deleteRecords(this.CHOICE_TABLE, {choiceid: choiceId, userid: userId}); + }); + } + + /** + * Get all offline responses. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promi[se resolved with responses. + */ + getResponses(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().getRecords(this.CHOICE_TABLE).then((records) => { + records.forEach((record) => { + record.responses = JSON.parse(record.responses); + }); + + return records; + }); + }); + } + + /** + * Check if there are offline responses to send. + * + * @param {number} choiceId Choice ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User the responses belong to. If not defined, current user in site. + * @return {Promise} Promise resolved with boolean: true if has offline answers, false otherwise. + */ + hasResponse(choiceId: number, siteId?: string, userId?: number): Promise { + return this.getResponse(choiceId, siteId, userId).then((response) => { + return !!response.choiceid; + }).catch((error) => { + // No offline data found, return false. + return false; + }); + } + + /** + * Get response to be synced. + * + * @param {number} choiceId Choice ID to get. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User the responses belong to. If not defined, current user in site. + * @return {Promise} Promise resolved with the object to be synced. + */ + getResponse(choiceId: number, siteId?: string, userId?: number): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + return site.getDb().getRecord(this.CHOICE_TABLE, {choiceid: choiceId, userid: userId}).then((record) => { + record.responses = JSON.parse(record.responses); + + return record; + }); + }); + } + + /** + * Offline version for sending a response to a choice to Moodle. + * + * @param {number} choiceId Choice ID. + * @param {string} name Choice name. + * @param {number} courseId Course ID the choice belongs to. + * @param {number[]} responses IDs of selected options. + * @param {boolean} deleting If true, the user is deleting responses, if false, submitting. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User the responses belong to. If not defined, current user in site. + * @return {Promise} Promise resolved when results are successfully submitted. + */ + saveResponse(choiceId: number, name: string, courseId: number, responses: number[], deleting: boolean, + siteId?: string, userId?: number): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const entry = { + choiceid: choiceId, + name: name, + courseid: courseId, + userid: userId || site.getUserId(), + responses: JSON.stringify(responses), + deleting: deleting ? 1 : 0, + timecreated: new Date().getTime() + }; + + return site.getDb().insertRecord(this.CHOICE_TABLE, entry); + }); + } +} diff --git a/src/addon/mod/choice/providers/prefetch-handler.ts b/src/addon/mod/choice/providers/prefetch-handler.ts new file mode 100644 index 000000000..d30849fa4 --- /dev/null +++ b/src/addon/mod/choice/providers/prefetch-handler.ts @@ -0,0 +1,125 @@ +// (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, Injector } from '@angular/core'; +import { CoreCourseModulePrefetchHandlerBase } from '@core/course/classes/module-prefetch-handler'; +import { CoreUserProvider } from '@core/user/providers/user'; +import { AddonModChoiceProvider } from './choice'; + +/** + * Handler to prefetch choices. + */ +@Injectable() +export class AddonModChoicePrefetchHandler extends CoreCourseModulePrefetchHandlerBase { + name = 'AddonModChoice'; + modName = 'choice'; + component = AddonModChoiceProvider.COMPONENT; + updatesNames = /^configuration$|^.*files$|^answers$/; + + constructor(injector: Injector, protected choiceProvider: AddonModChoiceProvider, private userProvider: CoreUserProvider) { + super(injector); + } + + /** + * Download or prefetch the content. + * + * @param {any} module The module object returned by WS. + * @param {number} courseId Course ID. + * @param {boolean} [prefetch] True to prefetch, false to download right away. + * @param {string} [dirPath] Path of the directory where to store all the content files. This is to keep the files + * relative paths and make the package work in an iframe. Undefined to download the files + * in the filepool root choice. + * @return {Promise} Promise resolved when all content is downloaded. Data returned is not reliable. + */ + downloadOrPrefetch(module: any, courseId: number, prefetch?: boolean, dirPath?: string): Promise { + const siteId = this.sitesProvider.getCurrentSiteId(); + const promises = []; + + promises.push(super.downloadOrPrefetch(module, courseId, prefetch)); + promises.push(this.choiceProvider.getChoice(courseId, module.id, siteId).then((choice) => { + const promises = []; + + // Get the options and results. + promises.push(this.choiceProvider.getOptions(choice.id, siteId)); + promises.push(this.choiceProvider.getResults(choice.id, siteId).then((options) => { + // If we can see the users that answered, prefetch their profile and avatar. + const subPromises = []; + options.forEach((option) => { + option.userresponses.forEach((response) => { + if (response.userid) { + subPromises.push(this.userProvider.getProfile(response.userid, courseId, false, siteId)); + } + if (response.profileimageurl) { + subPromises.push(this.filepoolProvider.addToQueueByUrl(siteId, response.profileimageurl).catch(() => { + // Ignore failures. + })); + } + }); + }); + + return Promise.all(subPromises); + })); + + return Promise.all(promises); + })); + + return Promise.all(promises); + } + + /** + * Returns choice intro files. + * + * @param {any} module The module object returned by WS. + * @param {number} courseId Course ID. + * @return {Promise} Promise resolved with list of intro files. + */ + getIntroFiles(module: any, courseId: number): Promise { + return this.choiceProvider.getChoice(courseId, module.id).catch(() => { + // Not found, return undefined so module description is used. + }).then((choice) => { + return this.getIntroFilesFromInstance(module, choice); + }); + } + + /** + * Invalidate the prefetched content. + * + * @param {number} moduleId The module ID. + * @param {number} courseId Course ID the module belongs to. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateContent(moduleId: number, courseId: number): Promise { + return this.choiceProvider.invalidateContent(moduleId, courseId); + } + + /** + * Invalidate WS calls needed to determine module status. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @return {Promise} Promise resolved when invalidated. + */ + invalidateModule(module: any, courseId: number): Promise { + return this.choiceProvider.invalidateChoiceData(courseId); + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return {boolean|Promise} A boolean, or a promise resolved with a boolean, indicating if the handler is enabled. + */ + isEnabled(): boolean | Promise { + return true; + } +} diff --git a/src/addon/mod/choice/providers/sync-cron-handler.ts b/src/addon/mod/choice/providers/sync-cron-handler.ts new file mode 100644 index 000000000..c123021f8 --- /dev/null +++ b/src/addon/mod/choice/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 { AddonModChoiceSyncProvider } from './sync'; + +/** + * Synchronization cron handler. + */ +@Injectable() +export class AddonModChoiceSyncCronHandler implements CoreCronHandler { + name = 'AddonModChoiceSyncCronHandler'; + + constructor(private choiceSync: AddonModChoiceSyncProvider) {} + + /** + * 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.choiceSync.syncAllChoices(siteId); + } + + /** + * Get the time between consecutive executions. + * + * @return {number} Time between consecutive executions (in ms). + */ + getInterval(): number { + return 600000; // 10 minutes. + } +} diff --git a/src/addon/mod/choice/providers/sync.ts b/src/addon/mod/choice/providers/sync.ts new file mode 100644 index 000000000..3b736ccef --- /dev/null +++ b/src/addon/mod/choice/providers/sync.ts @@ -0,0 +1,194 @@ +// (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 { AddonModChoiceOfflineProvider } from './offline'; +import { AddonModChoiceProvider } from './choice'; +import { CoreEventsProvider } from '@providers/events'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreCourseProvider } from '@core/course/providers/course'; +import { CoreSyncProvider } from '@providers/sync'; + +/** + * Service to sync choices. + */ +@Injectable() +export class AddonModChoiceSyncProvider extends CoreSyncBaseProvider { + + static AUTO_SYNCED = 'addon_mod_choice_autom_synced'; + protected componentTranslate: string; + + constructor(protected sitesProvider: CoreSitesProvider, loggerProvider: CoreLoggerProvider, + protected appProvider: CoreAppProvider, private choiceOffline: AddonModChoiceOfflineProvider, + private eventsProvider: CoreEventsProvider, private choiceProvider: AddonModChoiceProvider, + translate: TranslateService, private utils: CoreUtilsProvider, protected textUtils: CoreTextUtilsProvider, + courseProvider: CoreCourseProvider, syncProvider: CoreSyncProvider) { + super('AddonModChoiceSyncProvider', loggerProvider, sitesProvider, appProvider, syncProvider, textUtils, translate); + this.componentTranslate = courseProvider.translateModuleName('choice'); + } + + /** + * Get the ID of a choice sync. + * + * @param {number} choiceId Choice ID. + * @param {number} userId User the responses belong to. + * @return {string} Sync ID. + */ + protected getSyncId(choiceId: number, userId: number): string { + return choiceId + '#' + userId; + } + + /** + * Try to synchronize all the choices 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. + */ + syncAllChoices(siteId?: string): Promise { + return this.syncOnSites('choices', this.syncAllChoicesFunc.bind(this), undefined, siteId); + } + + /** + * Sync all pending choices 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 syncAllChoicesFunc(siteId?: string): Promise { + return this.choiceOffline.getResponses(siteId).then((responses) => { + const promises = []; + + // Sync all responses. + responses.forEach((response) => { + promises.push(this.syncChoice(response.choiceid, response.userid, siteId).then((result) => { + if (result && result.updated) { + // Sync successful, send event. + this.eventsProvider.trigger(AddonModChoiceSyncProvider.AUTO_SYNCED, { + choiceId: response.choiceid, + userId: response.userid, + warnings: result.warnings + }, siteId); + } + })); + }); + }); + } + + /** + * Synchronize a choice. + * + * @param {number} choiceId Choice ID to be synced. + * @param {number} userId User the answers belong to. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if sync is successful, rejected otherwise. + */ + syncChoice(choiceId: number, userId: number, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const syncId = this.getSyncId(choiceId, userId); + if (this.isSyncing(syncId, siteId)) { + // There's already a sync ongoing for this discussion, return the promise. + return this.getOngoingSync(syncId, siteId); + } + + this.logger.debug(`Try to sync choice '${choiceId}' for user '${userId}'`); + + let courseId; + const result = { + warnings: [], + updated: false + }; + + // Get offline responses to be sent. + const syncPromise = this.choiceOffline.getResponse(choiceId, siteId, userId).catch(() => { + // No offline data found, return empty object. + return {}; + }).then((data) => { + if (!data.choiceid) { + // Nothing to sync. + return; + } + + if (!this.appProvider.isOnline()) { + // Cannot sync in offline. + return Promise.reject(null); + } + + courseId = data.courseid; + + // Send the responses. + let promise; + + if (data.deleting) { + // A user has deleted some responses. + promise = this.choiceProvider.deleteResponsesOnline(choiceId, data.responses, siteId); + } else { + // A user has added some responses. + promise = this.choiceProvider.submitResponseOnline(choiceId, data.responses, siteId); + } + + return promise.then(() => { + result.updated = true; + + return this.choiceOffline.deleteResponse(choiceId, siteId, userId); + }).catch((error) => { + if (this.utils.isWebServiceError(error)) { + // The WebService has thrown an error, this means that responses cannot be submitted. Delete them. + result.updated = true; + + return this.choiceOffline.deleteResponse(choiceId, siteId, userId).then(() => { + // Responses deleted, add a warning. + result.warnings.push(this.translate.instant('core.warningofflinedatadeleted', { + component: this.componentTranslate, + name: data.name, + error: error.error + })); + }); + } + + // Couldn't connect to server, reject. + return Promise.reject(error && error.error); + }); + }).then(() => { + if (courseId) { + const promises = [ + this.choiceProvider.invalidateChoiceData(courseId), + choiceId ? this.choiceProvider.invalidateOptions(choiceId) : Promise.resolve(), + choiceId ? this.choiceProvider.invalidateResults(choiceId) : Promise.resolve(), + ]; + + // Data has been sent to server, update choice data. + return Promise.all(promises).then(() => { + return this.choiceProvider.getChoiceById(courseId, choiceId, siteId); + }).catch(() => { + // Ignore errors. + }); + } + }).then(() => { + // Sync finished, set sync time. + return this.setSyncTime(syncId, siteId); + }).then(() => { + // All done, return the warnings. + return result; + }); + + return this.addOngoingSync(syncId, syncPromise, siteId); + } +} diff --git a/src/addon/mod/survey/providers/sync.ts b/src/addon/mod/survey/providers/sync.ts index d6fc54f02..74b1f160d 100644 --- a/src/addon/mod/survey/providers/sync.ts +++ b/src/addon/mod/survey/providers/sync.ts @@ -131,14 +131,14 @@ export class AddonModSurveySyncProvider extends CoreSyncBaseProvider { return this.getOngoingSync(syncId, siteId); } + this.logger.debug(`Try to sync survey '${surveyId}' for user '${userId}'`); + let courseId; const result = { warnings: [], answersSent: false }; - this.logger.debug(`Try to sync survey '${surveyId}' for user '${userId}'`); - // Get answers to be sent. const syncPromise = this.surveyOffline.getSurveyData(surveyId, siteId, userId).catch(() => { // No offline data found, return empty object. diff --git a/src/app/app.module.ts b/src/app/app.module.ts index f4212aef9..d4976b3e9 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -78,6 +78,7 @@ import { AddonUserProfileFieldModule } from '@addon/userprofilefield/userprofile import { AddonFilesModule } from '@addon/files/files.module'; import { AddonModBookModule } from '@addon/mod/book/book.module'; import { AddonModChatModule } from '@addon/mod/chat/chat.module'; +import { AddonModChoiceModule } from '@addon/mod/choice/choice.module'; import { AddonModLabelModule } from '@addon/mod/label/label.module'; import { AddonModResourceModule } from '@addon/mod/resource/resource.module'; import { AddonModFeedbackModule } from '@addon/mod/feedback/feedback.module'; @@ -174,6 +175,7 @@ export const CORE_PROVIDERS: any[] = [ AddonFilesModule, AddonModBookModule, AddonModChatModule, + AddonModChoiceModule, AddonModLabelModule, AddonModResourceModule, AddonModFeedbackModule, From 3131f503069820fe68108de366031e09450293d6 Mon Sep 17 00:00:00 2001 From: Albert Gasset Date: Fri, 20 Apr 2018 13:00:25 +0200 Subject: [PATCH 2/2] MOBILE-2337 choice: PR fixes --- .../mod/choice/components/index/index.html | 2 +- .../mod/choice/components/index/index.ts | 22 +++++-- src/addon/mod/choice/providers/choice.ts | 31 ++++------ .../mod/choice/providers/module-handler.ts | 1 + .../mod/choice/providers/prefetch-handler.ts | 58 +++++++++++++------ src/addon/mod/choice/providers/sync.ts | 2 +- 6 files changed, 71 insertions(+), 45 deletions(-) diff --git a/src/addon/mod/choice/components/index/index.html b/src/addon/mod/choice/components/index/index.html index d0d85de75..870b14089 100644 --- a/src/addon/mod/choice/components/index/index.html +++ b/src/addon/mod/choice/components/index/index.html @@ -23,7 +23,7 @@ -

{{ 'addon.mod_choice.yourselection' | translate }}

+

{{ 'addon.mod_choice.yourselection' | translate }}

{{ 'addon.mod_choice.expired' | translate:{$a: choice.closeTimeReadable} }}

diff --git a/src/addon/mod/choice/components/index/index.ts b/src/addon/mod/choice/components/index/index.ts index 28c178bea..3c52e925f 100644 --- a/src/addon/mod/choice/components/index/index.ts +++ b/src/addon/mod/choice/components/index/index.ts @@ -62,8 +62,13 @@ export class AddonModChoiceIndexComponent extends CoreCourseModuleMainActivityCo this.userId = this.sitesProvider.getCurrentSiteUserId(); this.loadContent(false, true).then(() => { + if (!this.choice) { + return; + } this.choiceProvider.logView(this.choice.id).then(() => { this.courseProvider.checkModuleCompletion(this.courseId, this.module.completionstatus); + }).catch((error) => { + // Ignore errors. }); }); } @@ -74,11 +79,16 @@ export class AddonModChoiceIndexComponent extends CoreCourseModuleMainActivityCo * @return {Promise} Resolved when done. */ protected invalidateContent(): Promise { - return Promise.all([ - this.choiceProvider.invalidateChoiceData(this.courseId), - this.choiceProvider.invalidateOptions(this.choice.id), - this.choiceProvider.invalidateResults(this.choice.id), - ]); + const promises = []; + + promises.push(this.choiceProvider.invalidateChoiceData(this.courseId)); + + if (this.choice) { + promises.push(this.choiceProvider.invalidateOptions(this.choice.id)); + promises.push(this.choiceProvider.invalidateResults(this.choice.id)); + } + + return Promise.all(promises); } /** @@ -242,7 +252,7 @@ export class AddonModChoiceIndexComponent extends CoreCourseModuleMainActivityCo // Cannot see results yet. this.canSeeResults = false; - return Promise.resolve(null); + return Promise.resolve(); } return this.choiceProvider.getResults(this.choice.id).then((results) => { diff --git a/src/addon/mod/choice/providers/choice.ts b/src/addon/mod/choice/providers/choice.ts index 8ce53366b..a26b6be29 100644 --- a/src/addon/mod/choice/providers/choice.ts +++ b/src/addon/mod/choice/providers/choice.ts @@ -87,13 +87,13 @@ export class AddonModChoiceProvider { return this.deleteResponsesOnline(choiceId, responses, siteId).then(() => { return true; }).catch((error) => { - if (error && error.wserror) { - // The WebService has thrown an error, this means that responses cannot be deleted. - return Promise.reject(error.error); - } else { - // Couldn't connect to server, store in offline. - return storeOffline(); + if (this.utils.isWebServiceError(error)) { + // The WebService has thrown an error, this means that responses cannot be submitted. + return Promise.reject(error); } + + // Couldn't connect to server, store in offline. + return storeOffline(); }); }); } @@ -185,12 +185,7 @@ export class AddonModChoiceProvider { return site.read('mod_choice_get_choices_by_courses', params, preSets).then((response) => { if (response && response.choices) { - let currentChoice; - response.choices.forEach((choice) => { - if (!currentChoice && choice[key] == value) { - currentChoice = choice; - } - }); + const currentChoice = response.choices.find((choice) => choice[key] == value); if (currentChoice) { return currentChoice; } @@ -351,15 +346,11 @@ export class AddonModChoiceProvider { * @return {Promise} Promise resolved when the WS call is successful. */ logView(id: string): Promise { - if (id) { - const params = { - choiceid: id - }; + const params = { + choiceid: id + }; - return this.sitesProvider.getCurrentSite().write('mod_choice_view_choice', params); - } - - return Promise.reject(null); + return this.sitesProvider.getCurrentSite().write('mod_choice_view_choice', params); } /** diff --git a/src/addon/mod/choice/providers/module-handler.ts b/src/addon/mod/choice/providers/module-handler.ts index 2e2032790..402f5555f 100644 --- a/src/addon/mod/choice/providers/module-handler.ts +++ b/src/addon/mod/choice/providers/module-handler.ts @@ -50,6 +50,7 @@ export class AddonModChoiceModuleHandler implements CoreCourseModuleHandler { icon: this.courseProvider.getModuleIconSrc('choice'), title: module.name, class: 'addon-mod_choice-handler', + showDownloadButton: true, action(event: Event, navCtrl: NavController, module: any, courseId: number, options: NavOptions): void { navCtrl.push('AddonModChoiceIndexPage', {module: module, courseId: courseId}, options); } diff --git a/src/addon/mod/choice/providers/prefetch-handler.ts b/src/addon/mod/choice/providers/prefetch-handler.ts index d30849fa4..8aee0ec61 100644 --- a/src/addon/mod/choice/providers/prefetch-handler.ts +++ b/src/addon/mod/choice/providers/prefetch-handler.ts @@ -16,6 +16,7 @@ import { Injectable, Injector } from '@angular/core'; import { CoreCourseModulePrefetchHandlerBase } from '@core/course/classes/module-prefetch-handler'; import { CoreUserProvider } from '@core/user/providers/user'; import { AddonModChoiceProvider } from './choice'; +import { AddonModChoiceSyncProvider } from './sync'; /** * Handler to prefetch choices. @@ -27,27 +28,48 @@ export class AddonModChoicePrefetchHandler extends CoreCourseModulePrefetchHandl component = AddonModChoiceProvider.COMPONENT; updatesNames = /^configuration$|^.*files$|^answers$/; - constructor(injector: Injector, protected choiceProvider: AddonModChoiceProvider, private userProvider: CoreUserProvider) { + constructor(protected injector: Injector, protected choiceProvider: AddonModChoiceProvider, + protected syncProvider: AddonModChoiceSyncProvider, protected userProvider: CoreUserProvider) { super(injector); } /** - * Download or prefetch the content. + * Download the module. * - * @param {any} module The module object returned by WS. - * @param {number} courseId Course ID. - * @param {boolean} [prefetch] True to prefetch, false to download right away. - * @param {string} [dirPath] Path of the directory where to store all the content files. This is to keep the files - * relative paths and make the package work in an iframe. Undefined to download the files - * in the filepool root choice. - * @return {Promise} Promise resolved when all content is downloaded. Data returned is not reliable. + * @param {any} module The module object returned by WS. + * @param {number} courseId Course ID. + * @param {string} [dirPath] Path of the directory where to store all the content files. @see downloadOrPrefetch. + * @return {Promise} Promise resolved when all content is downloaded. */ - downloadOrPrefetch(module: any, courseId: number, prefetch?: boolean, dirPath?: string): Promise { - const siteId = this.sitesProvider.getCurrentSiteId(); - const promises = []; + download(module: any, courseId: number, dirPath?: string): Promise { + // Same implementation for download or prefetch. + return this.prefetch(module, courseId, false, dirPath); + } - promises.push(super.downloadOrPrefetch(module, courseId, prefetch)); - promises.push(this.choiceProvider.getChoice(courseId, module.id, siteId).then((choice) => { + /** + * Prefetch a module. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section. + * @param {string} [dirPath] Path of the directory where to store all the content files. @see downloadOrPrefetch. + * @return {Promise} Promise resolved when done. + */ + prefetch(module: any, courseId?: number, single?: boolean, dirPath?: string): Promise { + return this.prefetchPackage(module, courseId, single, this.prefetchChoice.bind(this)); + } + + /** + * Prefetch a choice. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @param {boolean} single True if we're downloading a single module, false if we're downloading a whole section. + * @param {String} siteId Site ID. + * @return {Promise} Promise resolved when done. + */ + protected prefetchChoice(module: any, courseId: number, single: boolean, siteId: string): Promise { + return this.choiceProvider.getChoice(courseId, module.id, siteId).then((choice) => { const promises = []; // Get the options and results. @@ -71,10 +93,12 @@ export class AddonModChoicePrefetchHandler extends CoreCourseModulePrefetchHandl return Promise.all(subPromises); })); - return Promise.all(promises); - })); + // Get the intro files. + const introFiles = this.getIntroFilesFromInstance(module, choice); + promises.push(this.filepoolProvider.addFilesToQueue(siteId, introFiles, AddonModChoiceProvider.COMPONENT, module.id)); - return Promise.all(promises); + return Promise.all(promises); + }); } /** diff --git a/src/addon/mod/choice/providers/sync.ts b/src/addon/mod/choice/providers/sync.ts index 3b736ccef..fb86705b3 100644 --- a/src/addon/mod/choice/providers/sync.ts +++ b/src/addon/mod/choice/providers/sync.ts @@ -164,7 +164,7 @@ export class AddonModChoiceSyncProvider extends CoreSyncBaseProvider { } // Couldn't connect to server, reject. - return Promise.reject(error && error.error); + return Promise.reject(error); }); }).then(() => { if (courseId) {