diff --git a/src/addons/mod/mod.module.ts b/src/addons/mod/mod.module.ts index 95fdd0b31..60fea5102 100644 --- a/src/addons/mod/mod.module.ts +++ b/src/addons/mod/mod.module.ts @@ -27,6 +27,7 @@ import { AddonModResourceModule } from './resource/resource.module'; import { AddonModUrlModule } from './url/url.module'; import { AddonModLtiModule } from './lti/lti.module'; import { AddonModH5PActivityModule } from './h5pactivity/h5pactivity.module'; +import { AddonModSurveyModule } from './survey/survey.module'; @NgModule({ declarations: [], @@ -44,6 +45,7 @@ import { AddonModH5PActivityModule } from './h5pactivity/h5pactivity.module'; AddonModImscpModule, AddonModLtiModule, AddonModH5PActivityModule, + AddonModSurveyModule, ], providers: [], exports: [], diff --git a/src/addons/mod/survey/components/components.module.ts b/src/addons/mod/survey/components/components.module.ts new file mode 100644 index 000000000..22bde845b --- /dev/null +++ b/src/addons/mod/survey/components/components.module.ts @@ -0,0 +1,32 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { AddonModSurveyIndexComponent } from './index/index'; +import { CoreSharedModule } from '@/core/shared.module'; +import { CoreCourseComponentsModule } from '@features/course/components/components.module'; + +@NgModule({ + declarations: [ + AddonModSurveyIndexComponent, + ], + imports: [ + CoreSharedModule, + CoreCourseComponentsModule, + ], + exports: [ + AddonModSurveyIndexComponent, + ], +}) +export class AddonModSurveyComponentsModule {} diff --git a/src/addons/mod/survey/components/index/addon-mod-survey-index.html b/src/addons/mod/survey/components/index/addon-mod-survey-index.html new file mode 100644 index 000000000..89f5cad45 --- /dev/null +++ b/src/addons/mod/survey/components/index/addon-mod-survey-index.html @@ -0,0 +1,152 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

{{ 'addon.mod_survey.surveycompletednograph' | translate }}

+ + + {{ 'addon.mod_survey.results' | translate }} + +
+ + + + + + {{ 'core.hasdatatosync' | translate: {$a: moduleName} }} + + + + +
+ + + + + +

{{ question.text }}

+ + {{ 'addon.mod_survey.responses' | translate }} + + {{ option }} + + + +

{{ question.intro }}

+
+
+ + + + + + + + + {{question.num}}. {{ question.text }} + + + + + + + + + + + + + {{ 'core.choose' | translate }} + + {{option}} + + + + + + + + + + + + + {{question.num}}. {{ question.text }} + + + + + + + {{option}} + + + + + + + + + {{question.num}}. {{ question.text }} + + + + + + + +
+
+ + + + + {{ 'core.submit' | translate }} + + + +
+ +
diff --git a/src/addons/mod/survey/components/index/index.scss b/src/addons/mod/survey/components/index/index.scss new file mode 100644 index 000000000..261e20841 --- /dev/null +++ b/src/addons/mod/survey/components/index/index.scss @@ -0,0 +1,25 @@ +:host { + --grid-background: var(--white); + --even-background: var(--gray-light); + + .option-name { + font-size: 14px; + } + + .addon-mod_survey-question { + border-top: 1px solid var(--gray); + } + + ion-row { + background-color: var(--grid-background); + } + + .even { + background-color: var(--even-background); + } +} + +:host-context(body.dark) { + --grid-background: var(--black); + --even-background: var(--gray-darker); +} diff --git a/src/addons/mod/survey/components/index/index.ts b/src/addons/mod/survey/components/index/index.ts new file mode 100644 index 000000000..38db91dd8 --- /dev/null +++ b/src/addons/mod/survey/components/index/index.ts @@ -0,0 +1,250 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, OnInit, Optional } from '@angular/core'; +import { CoreIonLoadingElement } from '@classes/ion-loading'; +import { CoreCourseModuleMainActivityComponent } from '@features/course/classes/main-activity-component'; +import { CoreCourseContentsPage } from '@features/course/pages/contents/contents'; +import { CoreCourse } from '@features/course/services/course'; +import { IonContent } from '@ionic/angular'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreTextUtils } from '@services/utils/text'; +import { Translate } from '@singletons'; +import { CoreEvents } from '@singletons/events'; +import { AddonModSurveyPrefetchHandler } from '../../services/handlers/prefetch'; +import { + AddonModSurveyProvider, + AddonModSurveySurvey, + AddonModSurvey, + AddonModSurveySubmitAnswerData, +} from '../../services/survey'; +import { AddonModSurveyHelper, AddonModSurveyQuestionFormatted } from '../../services/survey-helper'; +import { AddonModSurveyOffline } from '../../services/survey-offline'; +import { AddonModSurveyAutoSyncData, AddonModSurveySync, AddonModSurveySyncResult } from '../../services/survey-sync'; + +/** + * Component that displays a survey. + */ +@Component({ + selector: 'addon-mod-survey-index', + templateUrl: 'addon-mod-survey-index.html', + styleUrls: ['index.scss'], +}) +export class AddonModSurveyIndexComponent extends CoreCourseModuleMainActivityComponent implements OnInit { + + component = AddonModSurveyProvider.COMPONENT; + moduleName = 'survey'; + + survey?: AddonModSurveySurvey; + questions: AddonModSurveyQuestionFormatted[] = []; + answers: Record = {}; + + protected currentUserId?: number; + + constructor( + protected content?: IonContent, + @Optional() courseContentsPage?: CoreCourseContentsPage, + ) { + super('AddonModSurveyIndexComponent', content, courseContentsPage); + } + + /** + * @inheritdoc + */ + async ngOnInit(): Promise { + super.ngOnInit(); + + this.currentUserId = CoreSites.getCurrentSiteUserId(); + + await this.loadContent(false, true); + + try { + await AddonModSurvey.logView(this.survey!.id, this.survey!.name); + CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); + } catch { + // Ignore errors. Just don't check Module completion. + } + } + + /** + * Perform the invalidate content function. + * + * @return Resolved when done. + */ + protected async invalidateContent(): Promise { + const promises: Promise[] = []; + + promises.push(AddonModSurvey.invalidateSurveyData(this.courseId)); + if (this.survey) { + promises.push(AddonModSurvey.invalidateQuestions(this.survey.id)); + } + + await Promise.all(promises); + } + + /** + * Compares sync event data with current data to check if refresh content is needed. + * + * @param syncEventData Data receiven on sync observer. + * @return True if refresh is needed, false otherwise. + */ + protected isRefreshSyncNeeded(syncEventData: AddonModSurveyAutoSyncData): boolean { + if (this.survey && syncEventData.surveyId == this.survey.id && syncEventData.userId == this.currentUserId) { + return true; + } + + return false; + } + + /** + * Download survey contents. + * + * @param refresh If it's refreshing content. + * @param sync If it should try to sync. + * @param showErrors If show errors to the user of hide them. + * @return Promise resolved when done. + */ + protected async fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise { + try { + this.survey = await AddonModSurvey.getSurvey(this.courseId, this.module.id); + + this.description = this.survey.intro; + this.dataRetrieved.emit(this.survey); + + if (sync) { + // Try to synchronize the survey. + const answersSent = await this.syncActivity(showErrors); + if (answersSent) { + // Answers were sent, update the survey. + this.survey = await AddonModSurvey.getSurvey(this.courseId, this.module.id); + } + } + + // Check if there are answers stored in offline. + this.hasOffline = this.survey.surveydone + ? false + : await AddonModSurveyOffline.hasAnswers(this.survey.id); + + if (!this.survey.surveydone && !this.hasOffline) { + await this.fetchQuestions(); + } + } finally { + this.fillContextMenu(refresh); + } + } + + /** + * Convenience function to get survey questions. + * + * @return Promise resolved when done. + */ + protected async fetchQuestions(): Promise { + const questions = await AddonModSurvey.getQuestions(this.survey!.id, { cmId: this.module.id }); + + this.questions = AddonModSurveyHelper.formatQuestions(questions); + + // Init answers object. + this.questions.forEach((question) => { + if (question.name) { + const isTextArea = question.multiArray && question.multiArray.length === 0 && question.type === 0; + this.answers[question.name] = question.required ? '-1' : (isTextArea ? '' : '0'); + } + + if (question.multiArray && !question.multiArray.length && question.parent === 0 && question.type > 0) { + // Options shown in a select. Remove all HTML. + question.optionsArray = question.optionsArray?.map((option) => CoreTextUtils.cleanTags(option)); + } + }); + } + + /** + * Check if answers are valid to be submitted. + * + * @return If answers are valid + */ + isValidResponse(): boolean { + return !this.questions.some((question) => question.required && question.name && + (question.type === 0 ? this.answers[question.name] == '' : parseInt(this.answers[question.name], 10) === -1)); + } + + /** + * Save options selected. + */ + async submit(): Promise { + let modal: CoreIonLoadingElement | undefined; + + try { + await CoreDomUtils.showConfirm(Translate.instant('core.areyousure')); + + const answers: AddonModSurveySubmitAnswerData[] = []; + modal = await CoreDomUtils.showModalLoading('core.sending', true); + + for (const x in this.answers) { + answers.push({ + key: x, + value: this.answers[x], + }); + } + + const online = await AddonModSurvey.submitAnswers(this.survey!.id, this.survey!.name, this.courseId, answers); + + CoreEvents.trigger(CoreEvents.ACTIVITY_DATA_SENT, { module: this.moduleName }); + + if (online && this.isPrefetched()) { + // The survey is downloaded, update the data. + try { + await AddonModSurveySync.prefetchAfterUpdate( + AddonModSurveyPrefetchHandler.instance, + this.module, + this.courseId, + ); + + // Update the view. + this.showLoadingAndFetch(false, false); + } catch { + // Prefetch failed, refresh the data. + await this.showLoadingAndRefresh(false); + } + } else { + // Not downloaded, refresh the data. + await this.showLoadingAndRefresh(false); + } + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'addon.mod_survey.cannotsubmitsurvey', true); + } finally { + modal?.dismiss(); + } + } + + /** + * Performs the sync of the activity. + * + * @return Promise resolved when done. + */ + protected async sync(): Promise { + await AddonModSurveySync.syncSurvey(this.survey!.id, this.currentUserId); + } + + /** + * Checks if sync has succeed from result sync data. + * + * @param result Data returned on the sync function. + * @return If suceed or not. + */ + protected hasSyncSucceed(result: AddonModSurveySyncResult): boolean { + return result.answersSent; + } + +} diff --git a/src/addons/mod/survey/lang.json b/src/addons/mod/survey/lang.json new file mode 100644 index 000000000..9ccaff870 --- /dev/null +++ b/src/addons/mod/survey/lang.json @@ -0,0 +1,10 @@ +{ + "cannotsubmitsurvey": "Sorry, there was a problem submitting your survey. Please try again.", + "errorgetsurvey": "Error getting survey data.", + "ifoundthat": "I found that", + "ipreferthat": "I prefer that", + "modulenameplural": "Surveys", + "responses": "Responses", + "results": "Results", + "surveycompletednograph": "You have completed this survey." +} \ No newline at end of file diff --git a/src/addons/mod/survey/pages/index/index.html b/src/addons/mod/survey/pages/index/index.html new file mode 100644 index 000000000..3ccf0efe9 --- /dev/null +++ b/src/addons/mod/survey/pages/index/index.html @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/addons/mod/survey/pages/index/index.ts b/src/addons/mod/survey/pages/index/index.ts new file mode 100644 index 000000000..68d8aa521 --- /dev/null +++ b/src/addons/mod/survey/pages/index/index.ts @@ -0,0 +1,30 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, ViewChild } from '@angular/core'; +import { CoreCourseModuleMainActivityPage } from '@features/course/classes/main-activity-page'; +import { AddonModSurveyIndexComponent } from '../../components/index'; + +/** + * Page that displays a survey. + */ +@Component({ + selector: 'page-addon-mod-survey-index', + templateUrl: 'index.html', +}) +export class AddonModSurveyIndexPage extends CoreCourseModuleMainActivityPage { + + @ViewChild(AddonModSurveyIndexComponent) activityComponent?: AddonModSurveyIndexComponent; + +} diff --git a/src/addons/mod/survey/services/database/survey.ts b/src/addons/mod/survey/services/database/survey.ts new file mode 100644 index 000000000..060ad0394 --- /dev/null +++ b/src/addons/mod/survey/services/database/survey.ts @@ -0,0 +1,68 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreSiteSchema } from '@services/sites'; + +/** + * Database variables for AddonModSurveyOfflineProvider. + */ +export const SURVEY_TABLE = 'addon_mod_survey_answers'; +export const ADDON_MOD_SURVEY_OFFLINE_SITE_SCHEMA: CoreSiteSchema = { + name: 'AddonModSurveyOfflineProvider', + version: 1, + tables: [ + { + name: SURVEY_TABLE, + columns: [ + { + name: 'surveyid', + type: 'INTEGER', + }, + { + name: 'name', + type: 'TEXT', + }, + { + name: 'courseid', + type: 'INTEGER', + }, + { + name: 'userid', + type: 'INTEGER', + }, + { + name: 'answers', + type: 'TEXT', + }, + { + name: 'timecreated', + type: 'INTEGER', + }, + ], + primaryKeys: ['surveyid', 'userid'], + }, + ], +}; + +/** + * Survey offline answers. + */ +export type AddonModSurveyAnswersDBRecord = { + surveyid: number; + userid: number; + name: string; + courseid: number; + answers: string; + timecreated: number; +}; diff --git a/src/addons/mod/survey/services/handlers/index-link.ts b/src/addons/mod/survey/services/handlers/index-link.ts new file mode 100644 index 000000000..8fa2d56d8 --- /dev/null +++ b/src/addons/mod/survey/services/handlers/index-link.ts @@ -0,0 +1,32 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreContentLinksModuleIndexHandler } from '@features/contentlinks/classes/module-index-handler'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to treat links to survey. + */ +@Injectable( { providedIn: 'root' }) +export class AddonModSurveyIndexLinkHandlerService extends CoreContentLinksModuleIndexHandler { + + name = 'AddonModSurveyLinkHandler'; + + constructor() { + super('AddonModSurvey', 'survey'); + } + +} +export const AddonModSurveyIndexLinkHandler = makeSingleton(AddonModSurveyIndexLinkHandlerService); diff --git a/src/addons/mod/survey/services/handlers/list-link.ts b/src/addons/mod/survey/services/handlers/list-link.ts new file mode 100644 index 000000000..85ffc4c1d --- /dev/null +++ b/src/addons/mod/survey/services/handlers/list-link.ts @@ -0,0 +1,32 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreContentLinksModuleListHandler } from '@features/contentlinks/classes/module-list-handler'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to treat links to survey list page. + */ +@Injectable( { providedIn: 'root' }) +export class AddonModSurveyListLinkHandlerService extends CoreContentLinksModuleListHandler { + + name = 'AddonModSurveyListLinkHandler'; + + constructor() { + super('AddonModSurvey', 'survey'); + } + +} +export const AddonModSurveyListLinkHandler = makeSingleton(AddonModSurveyListLinkHandlerService); diff --git a/src/addons/mod/survey/services/handlers/module.ts b/src/addons/mod/survey/services/handlers/module.ts new file mode 100644 index 000000000..704249350 --- /dev/null +++ b/src/addons/mod/survey/services/handlers/module.ts @@ -0,0 +1,84 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreConstants } from '@/core/constants'; +import { Injectable, Type } from '@angular/core'; +import { CoreCourse, CoreCourseAnyModuleData } from '@features/course/services/course'; +import { CoreCourseModule } from '@features/course/services/course-helper'; +import { CoreCourseModuleHandler, CoreCourseModuleHandlerData } from '@features/course/services/module-delegate'; +import { CoreNavigationOptions, CoreNavigator } from '@services/navigator'; +import { makeSingleton } from '@singletons'; +import { AddonModSurveyIndexComponent } from '../../components/index'; + +/** + * Handler to support survey modules. + */ +@Injectable( { providedIn: 'root' }) +export class AddonModSurveyModuleHandlerService implements CoreCourseModuleHandler { + + static readonly PAGE_NAME = 'mod_survey'; + + name = 'AddonModSurvey'; + modName = 'survey'; + + supportedFeatures = { + [CoreConstants.FEATURE_GROUPS]: true, + [CoreConstants.FEATURE_GROUPINGS]: true, + [CoreConstants.FEATURE_MOD_INTRO]: true, + [CoreConstants.FEATURE_COMPLETION_TRACKS_VIEWS]: true, + [CoreConstants.FEATURE_COMPLETION_HAS_RULES]: true, + [CoreConstants.FEATURE_GRADE_HAS_GRADE]: false, + [CoreConstants.FEATURE_GRADE_OUTCOMES]: false, + [CoreConstants.FEATURE_BACKUP_MOODLE2]: true, + [CoreConstants.FEATURE_SHOW_DESCRIPTION]: true, + }; + + /** + * @inheritdoc + */ + async isEnabled(): Promise { + return true; + } + + /** + * @inheritdoc + */ + getData( + module: CoreCourseAnyModuleData, + ): CoreCourseModuleHandlerData { + return { + icon: CoreCourse.getModuleIconSrc(this.modName, 'modicon' in module ? module.modicon : undefined), + title: module.name, + class: 'addon-mod_survey-handler', + showDownloadButton: true, + action: (event: Event, module: CoreCourseModule, courseId: number, options?: CoreNavigationOptions) => { + options = options || {}; + options.params = options.params || {}; + Object.assign(options.params, { module }); + const routeParams = '/' + courseId + '/' + module.id; + + CoreNavigator.navigateToSitePath(AddonModSurveyModuleHandlerService.PAGE_NAME + routeParams, options); + }, + }; + } + + /** + * @inheritdoc + */ + async getMainComponent(): Promise> { + return AddonModSurveyIndexComponent; + } + +} +export const AddonModSurveyModuleHandler = makeSingleton(AddonModSurveyModuleHandlerService); diff --git a/src/addons/mod/survey/services/handlers/prefetch.ts b/src/addons/mod/survey/services/handlers/prefetch.ts new file mode 100644 index 000000000..c856f5656 --- /dev/null +++ b/src/addons/mod/survey/services/handlers/prefetch.ts @@ -0,0 +1,114 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreCourseActivityPrefetchHandlerBase } from '@features/course/classes/activity-prefetch-handler'; +import { CoreCourseAnyModuleData } from '@features/course/services/course'; +import { CoreFilepool } from '@services/filepool'; +import { CoreSitesReadingStrategy } from '@services/sites'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreWSExternalFile } from '@services/ws'; +import { makeSingleton } from '@singletons'; +import { AddonModSurvey, AddonModSurveyProvider } from '../survey'; +import { AddonModSurveySync, AddonModSurveySyncResult } from '../survey-sync'; + +/** + * Handler to prefetch surveys. + */ +@Injectable( { providedIn: 'root' }) +export class AddonModSurveyPrefetchHandlerService extends CoreCourseActivityPrefetchHandlerBase { + + name = 'AddonModSurvey'; + modName = 'survey'; + component = AddonModSurveyProvider.COMPONENT; + updatesNames = /^configuration$|^.*files$|^answers$/; + + /** + * @inheritdoc + */ + async getIntroFiles(module: CoreCourseAnyModuleData, courseId: number): Promise { + const survey = await CoreUtils.ignoreErrors(AddonModSurvey.getSurvey(courseId, module.id)); + + return this.getIntroFilesFromInstance(module, survey); + } + + /** + * @inheritdoc + */ + async invalidateContent(moduleId: number, courseId: number): Promise { + return AddonModSurvey.invalidateContent(moduleId, courseId); + } + + /** + * @inheritdoc + */ + async invalidateModule(module: CoreCourseAnyModuleData, courseId: number): Promise { + await AddonModSurvey.invalidateSurveyData(courseId); + } + + /** + * @inheritdoc + */ + async isEnabled(): Promise { + return true; + } + + /** + * @inheritdoc + */ + prefetch(module: CoreCourseAnyModuleData, courseId?: number): Promise { + return this.prefetchPackage(module, courseId, this.prefetchSurvey.bind(this, module, courseId)); + } + + /** + * Prefetch a survey. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @param siteId SiteId or current site. + * @return Promise resolved when done. + */ + protected async prefetchSurvey(module: CoreCourseAnyModuleData, courseId: number, siteId: string): Promise { + const survey = await AddonModSurvey.getSurvey(courseId, module.id, { + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + siteId, + }); + + const promises: Promise[] = []; + const files = this.getIntroFilesFromInstance(module, survey); + + // Prefetch files. + promises.push(CoreFilepool.addFilesToQueue(siteId, files, AddonModSurveyProvider.COMPONENT, module.id)); + + // If survey isn't answered, prefetch the questions. + if (!survey.surveydone) { + promises.push(AddonModSurvey.getQuestions(survey.id, { + cmId: module.id, + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + siteId, + })); + } + + await Promise.all(promises); + } + + /** + * @inheritdoc + */ + sync(module: CoreCourseAnyModuleData, courseId: number, siteId?: string): Promise { + return AddonModSurveySync.syncSurvey(module.instance!, undefined, siteId); + } + +} +export const AddonModSurveyPrefetchHandler = makeSingleton(AddonModSurveyPrefetchHandlerService); diff --git a/src/addons/mod/survey/services/handlers/sync-cron.ts b/src/addons/mod/survey/services/handlers/sync-cron.ts new file mode 100644 index 000000000..bad10bf57 --- /dev/null +++ b/src/addons/mod/survey/services/handlers/sync-cron.ts @@ -0,0 +1,43 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreCronHandler } from '@services/cron'; +import { makeSingleton } from '@singletons'; +import { AddonModSurveySync } from '../survey-sync'; + +/** + * Synchronization cron handler. + */ +@Injectable( { providedIn: 'root' }) +export class AddonModSurveySyncCronHandlerService implements CoreCronHandler { + + name = 'AddonModSurveySyncCronHandler'; + + /** + * @inheritdoc + */ + async execute(siteId?: string, force?: boolean): Promise { + await AddonModSurveySync.syncAllSurveys(siteId, force); + } + + /** + * @inheritdoc + */ + getInterval(): number { + return AddonModSurveySync.syncInterval; + } + +} +export const AddonModSurveySyncCronHandler = makeSingleton(AddonModSurveySyncCronHandlerService); diff --git a/src/addons/mod/survey/services/survey-helper.ts b/src/addons/mod/survey/services/survey-helper.ts new file mode 100644 index 000000000..352ad6487 --- /dev/null +++ b/src/addons/mod/survey/services/survey-helper.ts @@ -0,0 +1,138 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { makeSingleton, Translate } from '@singletons'; +import { AddonModSurveyQuestion } from './survey'; + +/** + * Service that provides helper functions for surveys. + */ +@Injectable( { providedIn: 'root' }) +export class AddonModSurveyHelperProvider { + + /** + * Turns a string with values separated by commas into an array. + * + * @param value Value to convert. + * @return Array. + */ + protected commaStringToArray(value: string | string[]): string[] { + if (typeof value == 'string') { + if (value.length > 0) { + return value.split(','); + } + + return []; + } + + return value; + } + + /** + * Gets the parent questions and puts them in an object: ID -> question. + * + * @param questions Questions. + * @return Object with parent questions. + */ + protected getParentQuestions(questions: AddonModSurveyQuestion[]): {[id: number]: AddonModSurveyQuestion} { + const parents: { [id: number]: AddonModSurveyQuestion } = {}; + + questions.forEach((question) => { + if (question.parent === 0) { + parents[question.id] = question; + } + }); + + return parents; + } + + /** + * Format a questions list, turning "multi" and "options" strings into arrays and adding the properties + * 'num' and 'name'. + * + * @param questions Questions. + * @return Promise resolved with the formatted questions. + */ + formatQuestions(questions: AddonModSurveyQuestion[]): AddonModSurveyQuestionFormatted[] { + const strIPreferThat = Translate.instant('addon.mod_survey.ipreferthat'); + const strIFoundThat = Translate.instant('addon.mod_survey.ifoundthat'); + const strChoose = Translate.instant('core.choose'); + + const formatted: AddonModSurveyQuestionFormatted[] = []; + const parents = this.getParentQuestions(questions); + + let num = 1; + + questions.forEach((question) => { + // Copy the object to prevent modifying the original. + const q1: AddonModSurveyQuestionFormatted = Object.assign({}, question); + const parent = parents[q1.parent]; + + // Turn multi and options into arrays. + q1.multiArray = this.commaStringToArray(q1.multi); + q1.optionsArray = this.commaStringToArray(q1.options); + + if (parent) { + // It's a sub-question. + q1.required = true; + + if (parent.type === 1 || parent.type === 2) { + // One answer question. Set its name and add it to the returned array. + q1.name = 'q' + (parent.type == 2 ? 'P' : '') + q1.id; + q1.num = num++; + } else { + // Two answers per question (COLLES P&A). We'll add two questions. + const q2 = Object.assign({}, q1); + + q1.text = strIPreferThat + ' ' + q1.text; + q1.name = 'qP' + q1.id; + q1.num = num++; + formatted.push(q1); + + q2.text = strIFoundThat + ' ' + q2.text; + q2.name = 'q' + q1.id; + q2.num = num++; + formatted.push(q2); + + return; + } + } else if (q1.multiArray && q1.multiArray.length === 0) { + // It's a single question. + q1.name = 'q' + q1.id; + q1.num = num++; + if (q1.type > 0) { // Add "choose" option since this question is not required. + q1.optionsArray.unshift(strChoose); + } + } + + formatted.push(q1); + }); + + return formatted; + } + +} +export const AddonModSurveyHelper = makeSingleton(AddonModSurveyHelperProvider); + +/** + * Survey question with some calculated data. + */ +export type AddonModSurveyQuestionFormatted = AddonModSurveyQuestion & { + required?: boolean; // Calculated in the app. Whether the question is required. + name?: string; // Calculated in the app. The name of the question. + num?: number; // Calculated in the app. Number of the question. + multiArray?: string[]; // Subquestions ids, converted to an array. + optionsArray?: string[]; // Question options, converted to an array. +}; diff --git a/src/addons/mod/survey/services/survey-offline.ts b/src/addons/mod/survey/services/survey-offline.ts new file mode 100644 index 000000000..0a0f468eb --- /dev/null +++ b/src/addons/mod/survey/services/survey-offline.ts @@ -0,0 +1,151 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreSites } from '@services/sites'; +import { CoreTextUtils } from '@services/utils/text'; +import { makeSingleton } from '@singletons'; +import { AddonModSurveyAnswersDBRecord, SURVEY_TABLE } from './database/survey'; +import { AddonModSurveySubmitAnswerData } from './survey'; + +/** + * Service to handle Offline survey. + */ +@Injectable( { providedIn: 'root' }) +export class AddonModSurveyOfflineProvider { + + /** + * Delete a survey answers. + * + * @param surveyId Survey ID. + * @param siteId Site ID. If not defined, current site. + * @param userId User the answers belong to. If not defined, current user in site. + * @return Promise resolved if deleted, rejected if failure. + */ + async deleteSurveyAnswers(surveyId: number, siteId?: string, userId?: number): Promise { + const site = await CoreSites.getSite(siteId); + userId = userId || site.getUserId(); + + await site.getDb().deleteRecords(SURVEY_TABLE, { surveyid: surveyId, userid: userId }); + } + + /** + * Get all the stored data from all the surveys. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with answers. + */ + async getAllData(siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + const entries = await site.getDb().getAllRecords(SURVEY_TABLE); + + return entries.map((entry) => Object.assign(entry, { + answers: CoreTextUtils.parseJSON(entry.answers), + })); + } + + /** + * Get a survey stored answers. + * + * @param surveyId Survey ID. + * @param siteId Site ID. If not defined, current site. + * @param userId User the answers belong to. If not defined, current user in site. + * @return Promise resolved with the answers. + */ + async getSurveyAnswers(surveyId: number, siteId?: string, userId?: number): Promise { + try { + const entry = await this.getSurveyData(surveyId, siteId, userId); + + return entry.answers || []; + } catch { + return []; + } + } + + /** + * Get a survey stored data. + * + * @param surveyId Survey ID. + * @param siteId Site ID. If not defined, current site. + * @param userId User the answers belong to. If not defined, current user in site. + * @return Promise resolved with the data. + */ + async getSurveyData(surveyId: number, siteId?: string, userId?: number): Promise { + const site = await CoreSites.getSite(siteId); + userId = userId || site.getUserId(); + + const entry = await site.getDb().getRecord( + SURVEY_TABLE, + { surveyid: surveyId, userid: userId }, + ); + + return Object.assign(entry, { + answers: CoreTextUtils.parseJSON(entry.answers), + }); + } + + /** + * Check if there are offline answers to send. + * + * @param surveyId Survey ID. + * @param siteId Site ID. If not defined, current site. + * @param userId User the answers belong to. If not defined, current user in site. + * @return Promise resolved with boolean: true if has offline answers, false otherwise. + */ + async hasAnswers(surveyId: number, siteId?: string, userId?: number): Promise { + const answers = await this.getSurveyAnswers(surveyId, siteId, userId); + + return !!answers.length; + } + + /** + * Save answers to be sent later. + * + * @param surveyId Survey ID. + * @param name Survey name. + * @param courseId Course ID the survey belongs to. + * @param answers Answers. + * @param siteId Site ID. If not defined, current site. + * @param userId User the answers belong to. If not defined, current user in site. + * @return Promise resolved if stored, rejected if failure. + */ + async saveAnswers( + surveyId: number, + name: string, + courseId: number, + answers: AddonModSurveySubmitAnswerData[], + siteId?: string, + userId?: number, + ): Promise { + const site = await CoreSites.getSite(siteId); + userId = userId || site.getUserId(); + + const entry: AddonModSurveyAnswersDBRecord = { + surveyid: surveyId, + name: name, + courseid: courseId, + userid: userId, + answers: JSON.stringify(answers), + timecreated: new Date().getTime(), + }; + + await site.getDb().insertRecord(SURVEY_TABLE, entry); + } + +} +export const AddonModSurveyOffline = makeSingleton(AddonModSurveyOfflineProvider); + +export type AddonModSurveyAnswersDBRecordFormatted = Omit & { + answers: AddonModSurveySubmitAnswerData[]; +}; diff --git a/src/addons/mod/survey/services/survey-sync.ts b/src/addons/mod/survey/services/survey-sync.ts new file mode 100644 index 000000000..c2c10cb39 --- /dev/null +++ b/src/addons/mod/survey/services/survey-sync.ts @@ -0,0 +1,257 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreNetworkError } from '@classes/errors/network-error'; +import { CoreCourseActivitySyncBaseProvider } from '@features/course/classes/activity-sync'; +import { CoreCourse } from '@features/course/services/course'; +import { CoreCourseLogHelper } from '@features/course/services/log-helper'; +import { CoreApp } from '@services/app'; +import { CoreSites } from '@services/sites'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton, Translate } from '@singletons'; +import { CoreEvents } from '@singletons/events'; +import { AddonModSurveyPrefetchHandler } from './handlers/prefetch'; +import { AddonModSurvey, AddonModSurveyProvider } from './survey'; +import { AddonModSurveyAnswersDBRecordFormatted, AddonModSurveyOffline } from './survey-offline'; + +/** + * Service to sync surveys. + */ +@Injectable( { providedIn: 'root' }) +export class AddonModSurveySyncProvider extends CoreCourseActivitySyncBaseProvider { + + static readonly AUTO_SYNCED = 'addon_mod_survey_autom_synced'; + + protected componentTranslate: string; + + constructor() { + super('AddonModSurveySyncProvider'); + this.componentTranslate = CoreCourse.translateModuleName('survey'); + } + + /** + * Get the ID of a survey sync. + * + * @param surveyId Survey ID. + * @param userId User the answers belong to. + * @return Sync ID. + * @protected + */ + getSyncId(surveyId: number, userId: number): string { + return surveyId + '#' + userId; + } + + /** + * Try to synchronize all the surveys in a certain site or in all sites. + * + * @param siteId Site ID to sync. If not defined, sync all sites. + * @param force Wether to force sync not depending on last execution. + * @return Promise resolved if sync is successful, rejected if sync fails. + */ + syncAllSurveys(siteId?: string, force?: boolean): Promise { + return this.syncOnSites('all surveys', this.syncAllSurveysFunc.bind(this, !!force), siteId); + } + + /** + * Sync all pending surveys on a site. + * + * @param force Wether to force sync not depending on last execution. + * @param siteId Site ID to sync. + * @param Promise resolved if sync is successful, rejected if sync fails. + */ + protected async syncAllSurveysFunc(force: boolean, siteId: string): Promise { + // Get all survey answers pending to be sent in the site. + const entries = await AddonModSurveyOffline.getAllData(siteId); + + // Sync all surveys. + const promises = entries.map(async (entry) => { + const result = await (force + ? this.syncSurvey(entry.surveyid, entry.userid, siteId) + : this.syncSurveyIfNeeded(entry.surveyid, entry.userid, siteId)); + + if (result && result.answersSent) { + // Sync successful, send event. + CoreEvents.trigger(AddonModSurveySyncProvider.AUTO_SYNCED, { + surveyId: entry.surveyid, + userId: entry.userid, + warnings: result.warnings, + }, siteId); + } + }); + + await Promise.all(promises); + } + + /** + * Sync a survey only if a certain time has passed since the last time. + * + * @param surveyId Survey ID. + * @param userId User the answers belong to. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the survey is synced or if it doesn't need to be synced. + */ + async syncSurveyIfNeeded(surveyId: number, userId: number, siteId?: string): Promise { + const syncId = this.getSyncId(surveyId, userId); + + const needed = await this.isSyncNeeded(syncId, siteId); + if (needed) { + return this.syncSurvey(surveyId, userId, siteId); + } + } + + /** + * Synchronize a survey. + * + * @param surveyId Survey ID. + * @param userId User the answers belong to. If not defined, current user. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if sync is successful, rejected otherwise. + */ + async syncSurvey(surveyId: number, userId?: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + siteId = site.getId(); + userId = userId || site.getUserId(); + + const syncId = this.getSyncId(surveyId, userId); + + if (this.isSyncing(syncId, siteId)) { + // There's already a sync ongoing for this site, return the promise. + return this.getOngoingSync(syncId, siteId)!; + } + + this.logger.debug(`Try to sync survey '${surveyId}' for user '${userId}'`); + + // Get offline events. + const syncPromise = this.performSyncSurvey(surveyId, userId, siteId); + + return this.addOngoingSync(syncId, syncPromise, siteId); + } + + /** + * Perform the survey sync. + * + * @param surveyId Survey ID. + * @param userId User the answers belong to. If not defined, current user. + * @param siteId Site ID. + * @return Promise resolved if sync is successful, rejected otherwise. + */ + protected async performSyncSurvey(surveyId: number, userId: number, siteId: string): Promise { + const result: AddonModSurveySyncResult = { + warnings: [], + answersSent: false, + }; + + // Sync offline logs. + CoreUtils.ignoreErrors(CoreCourseLogHelper.syncActivity(AddonModSurveyProvider.COMPONENT, surveyId, siteId)); + + let answersNumber = 0; + let data: AddonModSurveyAnswersDBRecordFormatted | undefined; + try { + // Get answers to be sent. + data = await AddonModSurveyOffline.getSurveyData(surveyId, siteId, userId); + + answersNumber = data.answers.length; + } catch { + // Ignore errors. + } + + if (answersNumber > 0 && data) { + if (!CoreApp.isOnline()) { + // Cannot sync in offline. + throw new CoreNetworkError(); + } + + result.courseId = data.courseid; + + // Send the answers. + try { + await AddonModSurvey.submitAnswersOnline(surveyId, data.answers, siteId); + + result.answersSent = true; + + // Answers sent, delete them. + await AddonModSurveyOffline.deleteSurveyAnswers(surveyId, siteId, userId); + } catch (error) { + if (!CoreUtils.isWebServiceError(error)) { + // Local error, reject. + throw error; + } + + // The WebService has thrown an error, this means that answers cannot be submitted. Delete them. + result.answersSent = true; + + await AddonModSurveyOffline.deleteSurveyAnswers(surveyId, siteId, userId); + + // Answers deleted, add a warning. + result.warnings.push(Translate.instant('core.warningofflinedatadeleted', { + component: this.componentTranslate, + name: data.name, + error: CoreTextUtils.getErrorMessageFromError(error), + })); + } + + if (result.courseId) { + await AddonModSurvey.invalidateSurveyData(result.courseId, siteId); + + // Data has been sent to server, update survey data. + const module = await CoreCourse.getModuleBasicInfoByInstance(surveyId, 'survey', siteId); + + CoreUtils.ignoreErrors( + this.prefetchAfterUpdate(AddonModSurveyPrefetchHandler.instance, module, result.courseId, undefined, siteId), + ); + } + } + + const syncId = this.getSyncId(surveyId, userId); + // Sync finished, set sync time. + CoreUtils.ignoreErrors(this.setSyncTime(syncId, siteId)); + + return result; + } + +} +export const AddonModSurveySync = makeSingleton(AddonModSurveySyncProvider); + +declare module '@singletons/events' { + + /** + * Augment CoreEventsData interface with events specific to this service. + * + * @see https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation + */ + export interface CoreEventsData { + [AddonModSurveySyncProvider.AUTO_SYNCED]: AddonModSurveyAutoSyncData; + } + +} + +/** + * Data returned by a assign sync. + */ +export type AddonModSurveySyncResult = { + warnings: string[]; // List of warnings. + answersSent: boolean; // Whether some data was sent to the server or offline data was updated. + courseId?: number; // Course the survey belongs to (if known). +}; + +/** + * Data passed to AUTO_SYNCED event. + */ +export type AddonModSurveyAutoSyncData = { + surveyId: number; + warnings: string[]; + userId: number; +}; diff --git a/src/addons/mod/survey/services/survey.ts b/src/addons/mod/survey/services/survey.ts new file mode 100644 index 000000000..a6d5d9997 --- /dev/null +++ b/src/addons/mod/survey/services/survey.ts @@ -0,0 +1,393 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreError } from '@classes/errors/error'; +import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; +import { CoreCourseCommonModWSOptions } from '@features/course/services/course'; +import { CoreCourseLogHelper } from '@features/course/services/log-helper'; +import { CoreApp } from '@services/app'; +import { CoreFilepool } from '@services/filepool'; +import { CoreSites, CoreSitesCommonWSOptions } from '@services/sites'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreStatusWithWarningsWSResponse, CoreWSExternalFile, CoreWSExternalWarning } from '@services/ws'; +import { makeSingleton } from '@singletons'; +import { AddonModSurveyOffline } from './survey-offline'; + +const ROOT_CACHE_KEY = 'mmaModSurvey:'; + +/** + * Service that provides some features for surveys. + */ +@Injectable( { providedIn: 'root' }) +export class AddonModSurveyProvider { + + static readonly COMPONENT = 'mmaModSurvey'; + + /** + * Get a survey's questions. + * + * @param surveyId Survey ID. + * @param options Other options. + * @return Promise resolved when the questions are retrieved. + */ + async getQuestions(surveyId: number, options: CoreCourseCommonModWSOptions = {}): Promise { + const site = await CoreSites.getSite(options.siteId); + + const params: AddonModSurveyGetQuestionsWSParams = { + surveyid: surveyId, + }; + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getQuestionsCacheKey(surveyId), + updateFrequency: CoreSite.FREQUENCY_RARELY, + component: AddonModSurveyProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const response = await site.read('mod_survey_get_questions', params, preSets); + if (response.questions) { + return response.questions; + } + + throw new CoreError('No questions were found.'); + } + + /** + * Get cache key for survey questions WS calls. + * + * @param surveyId Survey ID. + * @return Cache key. + */ + protected getQuestionsCacheKey(surveyId: number): string { + return ROOT_CACHE_KEY + 'questions:' + surveyId; + } + + /** + * Get cache key for survey data WS calls. + * + * @param courseId Course ID. + * @return Cache key. + */ + protected getSurveyCacheKey(courseId: number): string { + return ROOT_CACHE_KEY + 'survey:' + courseId; + } + + /** + * Get a survey data. + * + * @param courseId Course ID. + * @param key Name of the property to check. + * @param value Value to search. + * @param options Other options. + * @return Promise resolved when the survey is retrieved. + */ + protected async getSurveyDataByKey( + courseId: number, + key: string, + value: number, + options: CoreSitesCommonWSOptions = {}, + ): Promise { + const site = await CoreSites.getSite(options.siteId); + + const params: AddonModSurveyGetSurveysByCoursesWSParams = { + courseids: [courseId], + }; + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getSurveyCacheKey(courseId), + updateFrequency: CoreSite.FREQUENCY_RARELY, + component: AddonModSurveyProvider.COMPONENT, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const response = + await site.read('mod_survey_get_surveys_by_courses', params, preSets); + + const currentSurvey = response.surveys.find((survey) => survey[key] == value); + if (currentSurvey) { + return currentSurvey; + } + + throw new CoreError('Activity not found.'); + } + + /** + * Get a survey by course module ID. + * + * @param courseId Course ID. + * @param cmId Course module ID. + * @param options Other options. + * @return Promise resolved when the survey is retrieved. + */ + getSurvey(courseId: number, cmId: number, options: CoreSitesCommonWSOptions = {}): Promise { + return this.getSurveyDataByKey(courseId, 'coursemodule', cmId, options); + } + + /** + * Get a survey by ID. + * + * @param courseId Course ID. + * @param id Survey ID. + * @param options Other options. + * @return Promise resolved when the survey is retrieved. + */ + getSurveyById(courseId: number, id: number, options: CoreSitesCommonWSOptions = {}): Promise { + return this.getSurveyDataByKey(courseId, 'id', id, options); + } + + /** + * Invalidate the prefetched content. + * + * @param moduleId The module ID. + * @param courseId Course ID of the module. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateContent(moduleId: number, courseId: number, siteId?: string): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + const promises: Promise[] = []; + + promises.push(this.getSurvey(courseId, moduleId).then(async (survey) => { + const ps: Promise[] = []; + + // Do not invalidate activity data before getting activity info, we need it! + ps.push(this.invalidateSurveyData(courseId, siteId)); + ps.push(this.invalidateQuestions(survey.id, siteId)); + + await Promise.all(ps); + + return; + })); + + promises.push(CoreFilepool.invalidateFilesByComponent(siteId, AddonModSurveyProvider.COMPONENT, moduleId)); + + await CoreUtils.allPromises(promises); + } + + /** + * Invalidates survey questions. + * + * @param surveyId Survey ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateQuestions(surveyId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getQuestionsCacheKey(surveyId)); + } + + /** + * Invalidates survey data. + * + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateSurveyData(courseId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getSurveyCacheKey(courseId)); + } + + /** + * Report the survey as being viewed. + * + * @param id Module ID. + * @param name Name of the assign. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the WS call is successful. + */ + async logView(id: number, name?: string, siteId?: string): Promise { + const params: AddonModSurveyViewSurveyWSParams = { + surveyid: id, + }; + + await CoreCourseLogHelper.logSingle( + 'mod_survey_view_survey', + params, + AddonModSurveyProvider.COMPONENT, + id, + name, + 'survey', + {}, + siteId, + ); + } + + /** + * Send survey answers. If cannot send them to Moodle, they'll be stored in offline to be sent later. + * + * @param surveyId Survey ID. + * @param name Survey name. + * @param courseId Course ID the survey belongs to. + * @param answers Answers. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean if success: true if answers were sent to server, + * false if stored in device. + */ + async submitAnswers( + surveyId: number, + name: string, + courseId: number, + answers: AddonModSurveySubmitAnswerData[], + siteId?: string, + ): Promise { + + // Convenience function to store a survey to be synchronized later. + const storeOffline = async (): Promise => { + await AddonModSurveyOffline.saveAnswers(surveyId, name, courseId, answers, siteId); + + return false; + }; + + siteId = siteId || CoreSites.getCurrentSiteId(); + + if (!CoreApp.isOnline()) { + // App is offline, store the message. + return storeOffline(); + } + + try { + // If there's already answers to be sent to the server, discard it first. + await AddonModSurveyOffline.deleteSurveyAnswers(surveyId, siteId); + + // Device is online, try to send them to server. + await this.submitAnswersOnline(surveyId, answers, siteId); + + return true; + } catch (error) { + if (CoreUtils.isWebServiceError(error)) { + // It's a WebService error, the user cannot send the message so don't store it. + throw error; + } + + return storeOffline(); + } + } + + /** + * Send survey answers to Moodle. + * + * @param surveyId Survey ID. + * @param answers Answers. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when answers are successfully submitted. + */ + async submitAnswersOnline(surveyId: number, answers: AddonModSurveySubmitAnswerData[], siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + const params: AddonModSurveySubmitAnswersWSParams = { + surveyid: surveyId, + answers: answers, + }; + + const response = await site.write('mod_survey_submit_answers', params); + if (!response.status) { + throw new CoreError('Error submitting answers.'); + } + } + +} +export const AddonModSurvey = makeSingleton(AddonModSurveyProvider); + +/** + * Params of mod_survey_view_survey WS. + */ +type AddonModSurveyViewSurveyWSParams = { + surveyid: number; // Survey instance id. +}; + +/** + * Survey returned by WS mod_survey_get_surveys_by_courses. + */ +export type AddonModSurveySurvey = { + id: number; // Survey id. + coursemodule: number; // Course module id. + course: number; // Course id. + name: string; // Survey name. + intro?: string; // The Survey intro. + introformat?: number; // Intro format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + introfiles?: CoreWSExternalFile[]; // @since 3.2. + template?: number; // Survey type. + days?: number; // Days. + questions?: string; // Question ids. + surveydone?: number; // Did I finish the survey?. + timecreated?: number; // Time of creation. + timemodified?: number; // Time of last modification. + section?: number; // Course section id. + visible?: number; // Visible. + groupmode?: number; // Group mode. + groupingid?: number; // Group id. +}; + +/** + * Survey question. + */ +export type AddonModSurveyQuestion = { + id: number; // Question id. + text: string; // Question text. + shorttext: string; // Question short text. + multi: string; // Subquestions ids. + intro: string; // The question intro. + type: number; // Question type. + options: string; // Question options. + parent: number; // Parent question (for subquestions). +}; + +/** + * Params of mod_survey_get_questions WS. + */ +type AddonModSurveyGetQuestionsWSParams = { + surveyid: number; // Survey instance id. +}; + +/** + * Data returned by mod_survey_get_questions WS. + */ +export type AddonModSurveyGetQuestionsWSResponse = { + questions: AddonModSurveyQuestion[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_survey_get_surveys_by_courses WS. + */ +type AddonModSurveyGetSurveysByCoursesWSParams = { + courseids?: number[]; // Array of course ids. +}; + +/** + * Data returned by mod_survey_get_surveys_by_courses WS. + */ +export type AddonModSurveyGetSurveysByCoursesWSResponse = { + surveys: AddonModSurveySurvey[]; + warnings?: CoreWSExternalWarning[]; +}; + +export type AddonModSurveySubmitAnswerData = { + key: string; // Answer key. + value: string; // Answer value. +}; + +/** + * Params of mod_survey_submit_answers WS. + */ +type AddonModSurveySubmitAnswersWSParams = { + surveyid: number; // Survey id. + answers: AddonModSurveySubmitAnswerData[]; +}; diff --git a/src/addons/mod/survey/survey-lazy.module.ts b/src/addons/mod/survey/survey-lazy.module.ts new file mode 100644 index 000000000..bd372d315 --- /dev/null +++ b/src/addons/mod/survey/survey-lazy.module.ts @@ -0,0 +1,39 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +import { CoreSharedModule } from '@/core/shared.module'; +import { AddonModSurveyIndexPage } from './pages/index'; +import { AddonModSurveyComponentsModule } from './components/components.module'; + +const routes: Routes = [ + { + path: ':courseId/:cmId', + component: AddonModSurveyIndexPage, + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CoreSharedModule, + AddonModSurveyComponentsModule, + ], + declarations: [ + AddonModSurveyIndexPage, + ], +}) +export class AddonModSurveyLazyModule {} diff --git a/src/addons/mod/survey/survey.module.ts b/src/addons/mod/survey/survey.module.ts new file mode 100644 index 000000000..0b00f21ae --- /dev/null +++ b/src/addons/mod/survey/survey.module.ts @@ -0,0 +1,75 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { APP_INITIALIZER, NgModule, Type } from '@angular/core'; +import { Routes } from '@angular/router'; +import { CoreContentLinksDelegate } from '@features/contentlinks/services/contentlinks-delegate'; +import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate'; +import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate'; +import { CoreMainMenuTabRoutingModule } from '@features/mainmenu/mainmenu-tab-routing.module'; +import { CoreCronDelegate } from '@services/cron'; +import { CORE_SITE_SCHEMAS } from '@services/sites'; +import { AddonModSurveyComponentsModule } from './components/components.module'; +import { ADDON_MOD_SURVEY_OFFLINE_SITE_SCHEMA } from './services/database/survey'; +import { AddonModSurveyIndexLinkHandler } from './services/handlers/index-link'; +import { AddonModSurveyListLinkHandler } from './services/handlers/list-link'; +import { AddonModSurveyModuleHandler, AddonModSurveyModuleHandlerService } from './services/handlers/module'; +import { AddonModSurveyPrefetchHandler } from './services/handlers/prefetch'; +import { AddonModSurveySyncCronHandler } from './services/handlers/sync-cron'; +import { AddonModSurveyProvider } from './services/survey'; +import { AddonModSurveyHelperProvider } from './services/survey-helper'; +import { AddonModSurveyOfflineProvider } from './services/survey-offline'; +import { AddonModSurveySyncProvider } from './services/survey-sync'; + +// List of providers (without handlers). +export const ADDON_MOD_SURVEY_SERVICES: Type[] = [ + AddonModSurveyProvider, + AddonModSurveyHelperProvider, + AddonModSurveySyncProvider, + AddonModSurveyOfflineProvider, +]; + +const routes: Routes = [ + { + path: AddonModSurveyModuleHandlerService.PAGE_NAME, + loadChildren: () => import('./survey-lazy.module').then(m => m.AddonModSurveyLazyModule), + }, +]; + +@NgModule({ + imports: [ + CoreMainMenuTabRoutingModule.forChild(routes), + AddonModSurveyComponentsModule, + ], + providers: [ + { + provide: CORE_SITE_SCHEMAS, + useValue: [ADDON_MOD_SURVEY_OFFLINE_SITE_SCHEMA], + multi: true, + }, + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + CoreCourseModuleDelegate.registerHandler(AddonModSurveyModuleHandler.instance); + CoreCourseModulePrefetchDelegate.registerHandler(AddonModSurveyPrefetchHandler.instance); + CoreCronDelegate.register(AddonModSurveySyncCronHandler.instance); + CoreContentLinksDelegate.registerHandler(AddonModSurveyIndexLinkHandler.instance); + CoreContentLinksDelegate.registerHandler(AddonModSurveyListLinkHandler.instance); + }, + }, + ], +}) +export class AddonModSurveyModule {} diff --git a/src/core/features/compile/services/compile.ts b/src/core/features/compile/services/compile.ts index e07d3e619..871c943c0 100644 --- a/src/core/features/compile/services/compile.ts +++ b/src/core/features/compile/services/compile.ts @@ -137,7 +137,7 @@ import { ADDON_MOD_PAGE_SERVICES } from '@addons/mod/page/page.module'; import { ADDON_MOD_QUIZ_SERVICES } from '@addons/mod/quiz/quiz.module'; import { ADDON_MOD_RESOURCE_SERVICES } from '@addons/mod/resource/resource.module'; // @todo import { ADDON_MOD_SCORM_SERVICES } from '@addons/mod/scorm/scorm.module'; -// @todo import { ADDON_MOD_SURVEY_SERVICES } from '@addons/mod/survey/survey.module'; +import { ADDON_MOD_SURVEY_SERVICES } from '@addons/mod/survey/survey.module'; import { ADDON_MOD_URL_SERVICES } from '@addons/mod/url/url.module'; // @todo import { ADDON_MOD_WIKI_SERVICES } from '@addons/mod/wiki/wiki.module'; // @todo import { ADDON_MOD_WORKSHOP_SERVICES } from '@addons/mod/workshop/workshop.module'; @@ -302,7 +302,7 @@ export class CoreCompileProvider { ...ADDON_MOD_QUIZ_SERVICES, ...ADDON_MOD_RESOURCE_SERVICES, // @todo ...ADDON_MOD_SCORM_SERVICES, - // @todo ...ADDON_MOD_SURVEY_SERVICES, + ...ADDON_MOD_SURVEY_SERVICES, ...ADDON_MOD_URL_SERVICES, // @todo ...ADDON_MOD_WIKI_SERVICES, // @todo ...ADDON_MOD_WORKSHOP_SERVICES,