From d73b5d616012ff232cbc77ccf1124017ef39790b Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Fri, 16 Apr 2021 15:34:25 +0200 Subject: [PATCH] MOBILE-3641 feedback: Migrate services --- src/addons/mod/feedback/feedback.module.ts | 87 + src/addons/mod/feedback/lang.json | 38 + .../feedback/services/database/feedback.ts | 63 + .../mod/feedback/services/feedback-helper.ts | 585 ++++++ .../mod/feedback/services/feedback-offline.ts | 162 ++ .../mod/feedback/services/feedback-sync.ts | 305 +++ src/addons/mod/feedback/services/feedback.ts | 1762 +++++++++++++++++ .../services/handlers/analysis-link.ts | 92 + .../services/handlers/complete-link.ts | 80 + .../feedback/services/handlers/index-link.ts | 47 + .../feedback/services/handlers/list-link.ts | 41 + .../mod/feedback/services/handlers/module.ts | 84 + .../feedback/services/handlers/prefetch.ts | 228 +++ .../feedback/services/handlers/print-link.ts | 80 + .../feedback/services/handlers/push-click.ts | 70 + .../services/handlers/show-entries-link.ts | 58 + .../handlers/show-non-respondents-link.ts | 75 + .../feedback/services/handlers/sync-cron.ts | 51 + src/addons/mod/mod.module.ts | 2 + src/core/features/compile/services/compile.ts | 4 +- 20 files changed, 3912 insertions(+), 2 deletions(-) create mode 100644 src/addons/mod/feedback/feedback.module.ts create mode 100644 src/addons/mod/feedback/lang.json create mode 100644 src/addons/mod/feedback/services/database/feedback.ts create mode 100644 src/addons/mod/feedback/services/feedback-helper.ts create mode 100644 src/addons/mod/feedback/services/feedback-offline.ts create mode 100644 src/addons/mod/feedback/services/feedback-sync.ts create mode 100644 src/addons/mod/feedback/services/feedback.ts create mode 100644 src/addons/mod/feedback/services/handlers/analysis-link.ts create mode 100644 src/addons/mod/feedback/services/handlers/complete-link.ts create mode 100644 src/addons/mod/feedback/services/handlers/index-link.ts create mode 100644 src/addons/mod/feedback/services/handlers/list-link.ts create mode 100644 src/addons/mod/feedback/services/handlers/module.ts create mode 100644 src/addons/mod/feedback/services/handlers/prefetch.ts create mode 100644 src/addons/mod/feedback/services/handlers/print-link.ts create mode 100644 src/addons/mod/feedback/services/handlers/push-click.ts create mode 100644 src/addons/mod/feedback/services/handlers/show-entries-link.ts create mode 100644 src/addons/mod/feedback/services/handlers/show-non-respondents-link.ts create mode 100644 src/addons/mod/feedback/services/handlers/sync-cron.ts diff --git a/src/addons/mod/feedback/feedback.module.ts b/src/addons/mod/feedback/feedback.module.ts new file mode 100644 index 000000000..d502a3afc --- /dev/null +++ b/src/addons/mod/feedback/feedback.module.ts @@ -0,0 +1,87 @@ +// (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 { CorePushNotificationsDelegate } from '@features/pushnotifications/services/push-delegate'; +import { CoreCronDelegate } from '@services/cron'; +import { CORE_SITE_SCHEMAS } from '@services/sites'; +import { AddonModFeedbackComponentsModule } from './components/components.module'; +import { OFFLINE_SITE_SCHEMA } from './services/database/feedback'; +import { AddonModFeedbackProvider } from './services/feedback'; +import { AddonModFeedbackHelperProvider } from './services/feedback-helper'; +import { AddonModFeedbackOfflineProvider } from './services/feedback-offline'; +import { AddonModFeedbackSyncProvider } from './services/feedback-sync'; +import { AddonModFeedbackAnalysisLinkHandler } from './services/handlers/analysis-link'; +import { AddonModFeedbackCompleteLinkHandler } from './services/handlers/complete-link'; +import { AddonModFeedbackIndexLinkHandler } from './services/handlers/index-link'; +import { AddonModFeedbackListLinkHandler } from './services/handlers/list-link'; +import { AddonModFeedbackModuleHandlerService, AddonModFeedbackModuleHandler } from './services/handlers/module'; +import { AddonModFeedbackPrefetchHandler } from './services/handlers/prefetch'; +import { AddonModFeedbackPrintLinkHandler } from './services/handlers/print-link'; +import { AddonModFeedbackPushClickHandler } from './services/handlers/push-click'; +import { AddonModFeedbackShowEntriesLinkHandler } from './services/handlers/show-entries-link'; +import { AddonModFeedbackShowNonRespondentsLinkHandler } from './services/handlers/show-non-respondents-link'; +import { AddonModFeedbackSyncCronHandler } from './services/handlers/sync-cron'; + +export const ADDON_MOD_FEEDBACK_SERVICES: Type[] = [ + AddonModFeedbackProvider, + AddonModFeedbackOfflineProvider, + AddonModFeedbackHelperProvider, + AddonModFeedbackSyncProvider, +]; + +const routes: Routes = [ + { + path: AddonModFeedbackModuleHandlerService.PAGE_NAME, + loadChildren: () => import('./feedback-lazy.module').then(m => m.AddonModFeedbackLazyModule), + }, +]; + +@NgModule({ + imports: [ + CoreMainMenuTabRoutingModule.forChild(routes), + AddonModFeedbackComponentsModule, + ], + providers: [ + { + provide: CORE_SITE_SCHEMAS, + useValue: [OFFLINE_SITE_SCHEMA], + multi: true, + }, + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + CoreCourseModuleDelegate.registerHandler(AddonModFeedbackModuleHandler.instance); + CoreCourseModulePrefetchDelegate.registerHandler(AddonModFeedbackPrefetchHandler.instance); + CoreCronDelegate.register(AddonModFeedbackSyncCronHandler.instance); + CoreContentLinksDelegate.registerHandler(AddonModFeedbackIndexLinkHandler.instance); + CoreContentLinksDelegate.registerHandler(AddonModFeedbackListLinkHandler.instance); + CoreContentLinksDelegate.registerHandler(AddonModFeedbackAnalysisLinkHandler.instance); + CoreContentLinksDelegate.registerHandler(AddonModFeedbackCompleteLinkHandler.instance); + CoreContentLinksDelegate.registerHandler(AddonModFeedbackPrintLinkHandler.instance); + CoreContentLinksDelegate.registerHandler(AddonModFeedbackShowEntriesLinkHandler.instance); + CoreContentLinksDelegate.registerHandler(AddonModFeedbackShowNonRespondentsLinkHandler.instance); + CorePushNotificationsDelegate.registerClickHandler(AddonModFeedbackPushClickHandler.instance); + }, + }, + ], +}) +export class AddonModFeedbackModule {} diff --git a/src/addons/mod/feedback/lang.json b/src/addons/mod/feedback/lang.json new file mode 100644 index 000000000..5e6381eb3 --- /dev/null +++ b/src/addons/mod/feedback/lang.json @@ -0,0 +1,38 @@ +{ + "analysis": "Analysis", + "anonymous": "Anonymous", + "anonymous_entries": "Anonymous entries ({{$a}})", + "average": "Average", + "captchaofflinewarning": "Feedback with CAPTCHA cannot be completed offline, or if not configured, or if the server is down.", + "complete_the_form": "Answer the questions", + "completed_feedbacks": "Submitted answers", + "continue_the_form": "Continue answering the questions", + "feedback_is_not_open": "The feedback is not open", + "feedback_submitted_offline": "This feedback has been saved to be submitted later.", + "feedbackclose": "Allow answers to", + "feedbackopen": "Allow answers from", + "mapcourses": "Map feedback to courses", + "maximal": "Maximum", + "minimal": "Minimum", + "mode": "Mode", + "modulenameplural": "Feedback", + "next_page": "Next page", + "non_anonymous": "User's name will be logged and shown with answers", + "non_anonymous_entries": "Non anonymous entries ({{$a}})", + "non_respondents_students": "Non-respondent students ({{$a}})", + "not_selected": "Not selected", + "not_started": "Not started", + "numberoutofrange": "Number out of range", + "overview": "Overview", + "page_after_submit": "Completion message", + "preview": "Preview", + "previous_page": "Previous page", + "questions": "Questions", + "response_nr": "Response number", + "responses": "Responses", + "save_entries": "Submit your answers", + "show_entries": "Show responses", + "show_nonrespondents": "Show non-respondents", + "started": "Started", + "this_feedback_is_already_submitted": "You've already completed this activity." +} \ No newline at end of file diff --git a/src/addons/mod/feedback/services/database/feedback.ts b/src/addons/mod/feedback/services/database/feedback.ts new file mode 100644 index 000000000..265ea6ed4 --- /dev/null +++ b/src/addons/mod/feedback/services/database/feedback.ts @@ -0,0 +1,63 @@ +// (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 AddonModFeedbackOfflineProvider. + */ +export const FEEDBACK_TABLE_NAME = 'addon_mod_feedback_answers'; +export const OFFLINE_SITE_SCHEMA: CoreSiteSchema = { + name: 'AddonModFeedbackOfflineProvider', + version: 1, + tables: [ + { + name: FEEDBACK_TABLE_NAME, + columns: [ + { + name: 'feedbackid', + type: 'INTEGER', + }, + { + name: 'page', + type: 'INTEGER', + }, + { + name: 'courseid', + type: 'INTEGER', + }, + { + name: 'responses', + type: 'TEXT', + }, + { + name: 'timemodified', + type: 'INTEGER', + }, + ], + primaryKeys: ['feedbackid', 'page'], + }, + ], +}; + +/** + * Response data. + */ +export type AddonModFeedbackResponseDBRecord = { + feedbackid: number; + page: number; + courseid: number; + responses: string; + timemodified: number; +}; diff --git a/src/addons/mod/feedback/services/feedback-helper.ts b/src/addons/mod/feedback/services/feedback-helper.ts new file mode 100644 index 000000000..3254c4f27 --- /dev/null +++ b/src/addons/mod/feedback/services/feedback-helper.ts @@ -0,0 +1,585 @@ +// (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 { CoreCourse } from '@features/course/services/course'; +import { CoreUser } from '@features/user/services/user'; +import { CoreNavigator } from '@services/navigator'; +import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton, Translate } from '@singletons'; +import { + AddonModFeedback, + AddonModFeedbackGetNonRespondentsWSResponse, + AddonModFeedbackGetResponsesAnalysisWSResponse, + AddonModFeedbackGroupPaginatedOptions, + AddonModFeedbackItem, + AddonModFeedbackProvider, + AddonModFeedbackResponseValue, + AddonModFeedbackWSAttempt, + AddonModFeedbackWSNonRespondent, +} from './feedback'; +import { AddonModFeedbackModuleHandlerService } from './handlers/module'; + +const MODE_RESPONSETIME = 1; +const MODE_COURSE = 2; +const MODE_CATEGORY = 3; + +/** + * Service that provides helper functions for feedbacks. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModFeedbackHelperProvider { + + /** + * Retrieves a list of students who didn't submit the feedback with extra info. + * + * @param feedbackId Feedback ID. + * @param options Other options. + * @return Promise resolved when the info is retrieved. + */ + async getNonRespondents( + feedbackId: number, + options: AddonModFeedbackGroupPaginatedOptions = {}, + ): Promise { + const responses: AddonModFeedbackGetNonRespondents = await AddonModFeedback.getNonRespondents(feedbackId, options); + + responses.users = await this.addImageProfile(responses.users); + + return responses; + } + + /** + * Get page items responses to be sent. + * + * @param items Items where the values are. + * @return Responses object to be sent. + */ + getPageItemsResponses(items: AddonModFeedbackFormItem[]): Record { + const responses: Record = {}; + + items.forEach((itemData) => { + let answered = false; + itemData.hasError = false; + + if (itemData.typ == 'captcha') { + const value = itemData.value || ''; + const name = itemData.typ + '_' + itemData.id; + + answered = !!value; + responses[name] = 1; + responses['g-recaptcha-response'] = value; + responses['recaptcha_element'] = 'dummyvalue'; + + if (itemData.required && !answered) { + // Check if it has any value. + itemData.isEmpty = true; + } else { + itemData.isEmpty = false; + } + } else if (itemData.hasvalue) { + let name: string; + let value: AddonModFeedbackResponseValue; + const nameTemp = itemData.typ + '_' + itemData.id; + + if (this.isMultiChoiceItem(itemData) && itemData.subtype == 'c') { + name = nameTemp + '[0]'; + responses[name] = 0; + itemData.choices.forEach((choice, index) => { + name = nameTemp + '[' + (index + 1) + ']'; + value = choice.checked ? choice.value : 0; + if (!answered && value) { + answered = true; + } + responses[name] = value; + }); + } else { + if (this.isMultiChoiceItem(itemData) && itemData.subtype != 'r') { + name = nameTemp + '[0]'; + } else { + name = nameTemp; + } + + if (itemData.typ == 'multichoice' || itemData.typ == 'multichoicerated') { + value = itemData.value || 0; + } else if (this.isNumericItem(itemData)) { + value = itemData.value || itemData.value == 0 ? itemData.value : ''; + + if (value != '') { + if ((itemData.rangefrom != '' && value < itemData.rangefrom) || + (itemData.rangeto != '' && value > itemData.rangeto)) { + itemData.hasError = true; + } + } + } else { + value = itemData.value || itemData.value == 0 ? itemData.value : ''; + } + + answered = !!value; + responses[name] = value; + } + + if (itemData.required && !answered) { + // Check if it has any value. + itemData.isEmpty = true; + } else { + itemData.isEmpty = false; + } + } + }); + + return responses; + } + + /** + * Returns the feedback user responses with extra info. + * + * @param feedbackId Feedback ID. + * @param options Other options. + * @return Promise resolved when the info is retrieved. + */ + async getResponsesAnalysis( + feedbackId: number, + options: AddonModFeedbackGroupPaginatedOptions = {}, + ): Promise { + const responses: AddonModFeedbackResponsesAnalysis = await AddonModFeedback.getResponsesAnalysis(feedbackId, options); + + responses.attempts = await this.addImageProfile(responses.attempts); + + return responses; + } + + /** + * Handle a show entries link. + * + * @param params URL params. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async handleShowEntriesLink(params: Record, siteId?: string): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + const modal = await CoreDomUtils.showModalLoading(); + + try { + const module = await CoreCourse.getModuleBasicInfo(Number(params.id), siteId); + + if (typeof params.showcompleted == 'undefined') { + // Param showcompleted not defined. Show entry list. + await CoreNavigator.navigateToSitePath( + AddonModFeedbackModuleHandlerService.PAGE_NAME + `/${module.course}/${module.id}/respondents`, + { siteId }, + ); + + return; + } + + const attempt = await AddonModFeedback.getAttempt(module.instance, Number(params.showcompleted), { + cmId: module.id, + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + siteId, + }); + + await CoreNavigator.navigateToSitePath( + AddonModFeedbackModuleHandlerService.PAGE_NAME + `/${module.course}/${module.id}/attempt/${attempt.id}`, + { + params: { + feedbackId: module.instance, + attempt: attempt, + }, + siteId, + }, + ); + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'Error opening link.'); + } finally { + modal.dismiss(); + } + } + + /** + * Add Image profile url field on some entries. + * + * @param entries Entries array to get profile from. + * @return Returns the same array with the profileimageurl added if found. + */ + protected async addImageProfile(entries: AddonModFeedbackWSAttempt[]): Promise; + protected async addImageProfile(entries: AddonModFeedbackWSNonRespondent[]): Promise; + protected async addImageProfile( + entries: (AddonModFeedbackWSAttempt | AddonModFeedbackWSNonRespondent)[], + ): Promise<(AddonModFeedbackAttempt | AddonModFeedbackNonRespondent)[]> { + return await Promise.all(entries.map(async (entry: AddonModFeedbackAttempt | AddonModFeedbackNonRespondent) => { + try { + const user = await CoreUser.getProfile(entry.userid, entry.courseid, true); + + entry.profileimageurl = user.profileimageurl; + } catch { + // Error getting profile, resolve promise without adding any extra data. + } + + return entry; + })); + } + + /** + * Helper funtion for item type Label. + * + * @param item Item to process. + * @return Item processed to show form. + */ + protected getItemFormLabel(item: AddonModFeedbackItem): AddonModFeedbackFormBasicItem { + item.name = ''; + item.presentation = CoreTextUtils.replacePluginfileUrls(item.presentation, item.itemfiles); + + return Object.assign(item, { + templateName: 'label', + value: '', + hasTextInput: false, + }); + } + + /** + * Helper funtion for item type Info. + * + * @param item Item to process. + * @return Item processed to show form. + */ + protected getItemFormInfo(item: AddonModFeedbackItem): AddonModFeedbackFormBasicItem | undefined { + const formItem: AddonModFeedbackFormBasicItem = Object.assign(item, { + templateName: 'label', + value: '', + hasTextInput: false, + }); + + const type = parseInt(formItem.presentation, 10); + + if (type == MODE_COURSE || type == MODE_CATEGORY) { + formItem.presentation = formItem.otherdata; + formItem.value = typeof formItem.rawValue != 'undefined' ? formItem.rawValue : formItem.otherdata; + } else if (type == MODE_RESPONSETIME) { + formItem.value = '__CURRENT__TIMESTAMP__'; + + const rawValue = Number(formItem.rawValue); + const tempValue = isNaN(rawValue) ? Date.now() : rawValue * 1000; + formItem.presentation = CoreTimeUtils.userDate(tempValue); + } else { + // Errors on item, return false. + return undefined; + } + + return formItem; + } + + /** + * Helper funtion for item type Numeric. + * + * @param item Item to process. + * @return Item processed to show form. + */ + protected getItemFormNumeric(item: AddonModFeedbackItem): AddonModFeedbackNumericItem { + + const range = item.presentation.split(AddonModFeedbackProvider.LINE_SEP) || []; + const rangeFrom = range.length > 0 ? parseInt(range[0], 10) : undefined; + const rangeTo = range.length > 1 ? parseInt(range[1], 10) : undefined; + + const formItem: AddonModFeedbackNumericItem = Object.assign(item, { + templateName: 'numeric', + value: typeof item.rawValue != 'undefined' ? Number(item.rawValue) : '', + rangefrom: typeof rangeFrom == 'number' && !isNaN(rangeFrom) ? range[0] : '', + rangeto: typeof rangeTo == 'number' && !isNaN(rangeTo) ? rangeTo : '', + hasTextInput: true, + }); + formItem.postfix = this.getNumericBoundariesForDisplay(formItem.rangefrom, formItem.rangeto); + + return formItem; + } + + /** + * Helper funtion for item type Text field. + * + * @param item Item to process. + * @return Item processed to show form. + */ + protected getItemFormTextfield(item: AddonModFeedbackItem): AddonModFeedbackTextItem { + return Object.assign(item, { + templateName: 'textfield', + length: Number(item.presentation.split(AddonModFeedbackProvider.LINE_SEP)[1]) || 255, + value: typeof item.rawValue != 'undefined' ? item.rawValue : '', + hasTextInput: true, + }); + } + + /** + * Helper funtion for item type Textarea. + * + * @param item Item to process. + * @return Item processed to show form. + */ + protected getItemFormTextarea(item: AddonModFeedbackItem): AddonModFeedbackFormBasicItem { + return Object.assign(item, { + templateName: 'textarea', + value: typeof item.rawValue != 'undefined' ? item.rawValue : '', + hasTextInput: true, + }); + } + + /** + * Helper funtion for item type Multichoice. + * + * @param item Item to process. + * @return Item processed to show form. + */ + protected getItemFormMultichoice(item: AddonModFeedbackItem): AddonModFeedbackMultichoiceItem { + + let parts = item.presentation.split(AddonModFeedbackProvider.MULTICHOICE_TYPE_SEP) || []; + const subType = parts.length > 0 && parts[0] ? parts[0] : 'r'; + + const formItem: AddonModFeedbackMultichoiceItem = Object.assign(item, { + templateName: 'multichoice-' + subType, + subtype: subType, + value: '', + choices: [], + hasTextInput: false, + }); + + formItem.presentation = parts.length > 1 ? parts[1] : ''; + if (formItem.subtype != 'd') { + parts = formItem.presentation.split(AddonModFeedbackProvider.MULTICHOICE_ADJUST_SEP) || []; + formItem.presentation = parts.length > 0 ? parts[0] : ''; + // Horizontal are not supported right now. item.horizontal = parts.length > 1 && !!parts[1]; + } + + const choices = formItem.presentation.split(AddonModFeedbackProvider.LINE_SEP) || []; + formItem.choices = choices.map((choice, index) => { + const weightValue = choice.split(AddonModFeedbackProvider.MULTICHOICERATED_VALUE_SEP) || ['']; + choice = weightValue.length == 1 ? weightValue[0] : '(' + weightValue[0] + ') ' + weightValue[1]; + + return { value: index + 1, label: choice }; + }); + + if (formItem.subtype === 'r' && formItem.options.search(AddonModFeedbackProvider.MULTICHOICE_HIDENOSELECT) == -1) { + formItem.choices.unshift({ value: 0, label: Translate.instant('addon.mod_feedback.not_selected') }); + formItem.value = typeof formItem.rawValue != 'undefined' ? Number(formItem.rawValue) : 0; + } else if (formItem.subtype === 'd') { + formItem.choices.unshift({ value: 0, label: '' }); + formItem.value = typeof formItem.rawValue != 'undefined' ? Number(formItem.rawValue) : 0; + } else if (formItem.subtype === 'c') { + if (typeof formItem.rawValue != 'undefined') { + formItem.rawValue = String(formItem.rawValue); + const values = formItem.rawValue.split(AddonModFeedbackProvider.LINE_SEP); + formItem.choices.forEach((choice) => { + for (const x in values) { + if (choice.value == Number(values[x])) { + choice.checked = true; + + return; + } + } + }); + } + } else { + formItem.value = typeof formItem.rawValue != 'undefined' ? Number(formItem.rawValue) : ''; + } + + return formItem; + } + + /** + * Helper funtion for item type Captcha. + * + * @param item Item to process. + * @return Item processed to show form. + */ + protected getItemFormCaptcha(item: AddonModFeedbackItem): AddonModFeedbackCaptchaItem { + const formItem: AddonModFeedbackCaptchaItem = Object.assign(item, { + templateName: 'captcha', + value: '', + hasTextInput: false, + }); + + const data = CoreTextUtils.parseJSON(item.otherdata); + if (data && data.length > 3) { + formItem.captcha = { + recaptchapublickey: data[3], + }; + } + + return formItem; + } + + /** + * Process and returns item to print form. + * + * @param item Item to process. + * @param preview Previewing options. + * @return Item processed to show form. + */ + getItemForm(item: AddonModFeedbackItem, preview: boolean): AddonModFeedbackFormItem | undefined { + switch (item.typ) { + case 'label': + return this.getItemFormLabel(item); + case 'info': + return this.getItemFormInfo(item); + case 'numeric': + return this.getItemFormNumeric(item); + case 'textfield': + return this.getItemFormTextfield(item); + case 'textarea': + return this.getItemFormTextarea(item); + case 'multichoice': + return this.getItemFormMultichoice(item); + case 'multichoicerated': + return this.getItemFormMultichoice(item); + case 'pagebreak': + if (!preview) { + // Pagebreaks are only used on preview. + return undefined; + } + break; + case 'captcha': + // Captcha is not supported right now. However label will be shown. + return this.getItemFormCaptcha(item); + default: + return undefined; + } + } + + /** + * Returns human-readable boundaries (min - max). + * Based on Moodle's get_boundaries_for_display. + * + * @param rangeFrom Range from. + * @param rangeTo Range to. + * @return Human-readable boundaries. + */ + protected getNumericBoundariesForDisplay(rangeFrom: number | string, rangeTo: number | string): string { + const rangeFromSet = typeof rangeFrom == 'number'; + const rangeToSet = typeof rangeTo == 'number'; + + if (!rangeFromSet && rangeToSet) { + return ' (' + Translate.instant('addon.mod_feedback.maximal') + ': ' + CoreUtils.formatFloat(rangeTo) + ')'; + } else if (rangeFromSet && !rangeToSet) { + return ' (' + Translate.instant('addon.mod_feedback.minimal') + ': ' + CoreUtils.formatFloat(rangeFrom) + ')'; + } else if (!rangeFromSet && !rangeToSet) { + return ''; + } + + return ' (' + CoreUtils.formatFloat(rangeFrom) + ' - ' + CoreUtils.formatFloat(rangeTo) + ')'; + } + + /** + * Check if a form item is multichoice. + * + * @param item Item. + * @return Whether item is multichoice. + */ + protected isMultiChoiceItem(item: AddonModFeedbackFormItem): item is AddonModFeedbackMultichoiceItem { + return item.typ == 'multichoice'; + } + + /** + * Check if a form item is numeric. + * + * @param item Item. + * @return Whether item is numeric. + */ + protected isNumericItem(item: AddonModFeedbackFormItem): item is AddonModFeedbackNumericItem { + return item.typ == 'numeric'; + } + +} + +export const AddonModFeedbackHelper = makeSingleton(AddonModFeedbackHelperProvider); + +/** + * Attempt with some calculated data. + */ +export type AddonModFeedbackAttempt = AddonModFeedbackWSAttempt & { + profileimageurl?: string; +}; + +/** + * Non respondent with some calculated data. + */ +export type AddonModFeedbackNonRespondent = AddonModFeedbackWSNonRespondent & { + profileimageurl?: string; +}; + +/** + * Non respondents with some calculated data. + */ +export type AddonModFeedbackResponsesAnalysis = Omit & { + attempts: AddonModFeedbackAttempt[]; +}; + +/** + * Non respondents with some calculated data. + */ +export type AddonModFeedbackGetNonRespondents = Omit & { + users: AddonModFeedbackNonRespondent[]; +}; + +/** + * Item with form data. + */ +export type AddonModFeedbackFormItem = + AddonModFeedbackFormBasicItem | AddonModFeedbackNumericItem | AddonModFeedbackTextItem | AddonModFeedbackMultichoiceItem | + AddonModFeedbackCaptchaItem; + +/** + * Common calculated data for all form items. + */ +export type AddonModFeedbackFormBasicItem = AddonModFeedbackItem & { + templateName: string; + value: AddonModFeedbackResponseValue; + hasTextInput: boolean; + isEmpty?: boolean; + hasError?: boolean; +}; + +/** + * Numeric item. + */ +export type AddonModFeedbackNumericItem = AddonModFeedbackFormBasicItem & { + rangefrom: number | string; + rangeto: number | string; + postfix?: string; +}; + +/** + * Text item. + */ +export type AddonModFeedbackTextItem = AddonModFeedbackFormBasicItem & { + length: number; +}; + +/** + * Multichoice item. + */ +export type AddonModFeedbackMultichoiceItem = AddonModFeedbackFormBasicItem & { + subtype: string; + choices: { value: number; label: string; checked?: boolean }[]; +}; + +/** + * Captcha item. + */ +export type AddonModFeedbackCaptchaItem = AddonModFeedbackFormBasicItem & { + captcha?: { + recaptchapublickey: string; + }; +}; diff --git a/src/addons/mod/feedback/services/feedback-offline.ts b/src/addons/mod/feedback/services/feedback-offline.ts new file mode 100644 index 000000000..1f9187328 --- /dev/null +++ b/src/addons/mod/feedback/services/feedback-offline.ts @@ -0,0 +1,162 @@ +// (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 { CoreTimeUtils } from '@services/utils/time'; +import { makeSingleton } from '@singletons'; +import { AddonModFeedbackResponseDBRecord, FEEDBACK_TABLE_NAME } from './database/feedback'; +import { AddonModFeedbackResponseValue } from './feedback'; + +/** + * Service to handle offline feedback. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModFeedbackOfflineProvider { + + /** + * Delete the stored for a certain feedback page. + * + * @param feedbackId Feedback ID. + * @param page Page of the form to delete responses from. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if deleted, rejected if failure. + */ + async deleteFeedbackPageResponses(feedbackId: number, page: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.getDb().deleteRecords(FEEDBACK_TABLE_NAME, > { + feedbackid: feedbackId, + page: page, + }); + } + + /** + * Get all the stored feedback responses data from all the feedback. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with entries. + */ + async getAllFeedbackResponses(siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + const entries = await site.getDb().getAllRecords(FEEDBACK_TABLE_NAME); + + return entries.map(entry => this.parseResponse(entry)); + } + + /** + * Get all the stored responses from a certain feedback. + * + * @param feedbackId Feedback ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with responses. + */ + async getFeedbackResponses(feedbackId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + const entries = await site.getDb().getRecords(FEEDBACK_TABLE_NAME, { + feedbackid: feedbackId, + }); + + return entries.map(entry => this.parseResponse(entry)); + } + + /** + * Get the stored responses for a certain feedback page. + * + * @param feedbackId Feedback ID. + * @param page Page of the form to get responses from. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with responses. + */ + async getFeedbackPageResponses(feedbackId: number, page: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + const conditions: Partial = { + feedbackid: feedbackId, + page: page, + }; + + const entry = await site.getDb().getRecord(FEEDBACK_TABLE_NAME, conditions); + + return this.parseResponse(entry); + } + + /** + * Get if the feedback have something to be synced. + * + * @param feedbackId Feedback ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with true if the feedback have something to be synced. + */ + async hasFeedbackOfflineData(feedbackId: number, siteId?: string): Promise { + const responses = await this.getFeedbackResponses(feedbackId, siteId); + + return !!responses.length; + } + + /** + * Parse "options" and "attachments" columns of a fetched record. + * + * @param records Record object + * @return Record object with columns parsed. + */ + protected parseResponse(record: AddonModFeedbackResponseDBRecord): AddonModFeedbackOfflineResponse { + return Object.assign(record, { + responses: > CoreTextUtils.parseJSON(record.responses), + }); + } + + /** + * Save page responses to be sent later. + * + * @param feedbackId Feedback ID. + * @param page The page being processed. + * @param responses The data to be processed the key is the field name (usually type[index]_id) + * @param courseId Course ID the feedback belongs to. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if stored, rejected if failure. + */ + async saveResponses( + feedbackId: number, + page: number, + responses: Record, + courseId: number, + siteId?: string, + ): Promise { + const site = await CoreSites.getSite(siteId); + + const entry: AddonModFeedbackResponseDBRecord = { + feedbackid: feedbackId, + page: page, + courseid: courseId, + responses: JSON.stringify(responses), + timemodified: CoreTimeUtils.timestamp(), + }; + + await site.getDb().insertRecord(FEEDBACK_TABLE_NAME, entry); + } + +} + +export const AddonModFeedbackOffline = makeSingleton(AddonModFeedbackOfflineProvider); + +/** + * Feedback offline response with parsed data. + */ +export type AddonModFeedbackOfflineResponse = Omit & { + responses: Record; +}; diff --git a/src/addons/mod/feedback/services/feedback-sync.ts b/src/addons/mod/feedback/services/feedback-sync.ts new file mode 100644 index 000000000..51c4cae18 --- /dev/null +++ b/src/addons/mod/feedback/services/feedback-sync.ts @@ -0,0 +1,305 @@ +// (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 { CoreSyncBlockedError } from '@classes/base-sync'; +import { CoreNetworkError } from '@classes/errors/network-error'; +import { CoreCourseActivitySyncBaseProvider } from '@features/course/classes/activity-sync'; +import { CoreCourse, CoreCourseAnyModuleData } from '@features/course/services/course'; +import { CoreCourseLogHelper } from '@features/course/services/log-helper'; +import { CoreApp } from '@services/app'; +import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreSync } from '@services/sync'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton, Translate } from '@singletons'; +import { CoreEvents } from '@singletons/events'; +import { AddonModFeedback, AddonModFeedbackProvider, AddonModFeedbackWSFeedback } from './feedback'; +import { AddonModFeedbackOffline, AddonModFeedbackOfflineResponse } from './feedback-offline'; +import { AddonModFeedbackPrefetchHandler, AddonModFeedbackPrefetchHandlerService } from './handlers/prefetch'; + +/** + * Service to sync feedbacks. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModFeedbackSyncProvider extends CoreCourseActivitySyncBaseProvider { + + static readonly AUTO_SYNCED = 'addon_mod_feedback_autom_synced'; + + protected componentTranslatableString = 'feedback'; + + constructor() { + super('AddonModFeedbackSyncProvider'); + } + + /** + * @inheritdoc + */ + prefetchAfterUpdate( + prefetchHandler: AddonModFeedbackPrefetchHandlerService, + module: CoreCourseAnyModuleData, + courseId: number, + regex?: RegExp, + siteId?: string, + ): Promise { + regex = regex || /^.*files$|^timers/; + + return super.prefetchAfterUpdate(prefetchHandler, module, courseId, regex, siteId); + } + + /** + * Try to synchronize all the feedbacks 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. + */ + syncAllFeedbacks(siteId?: string, force?: boolean): Promise { + return this.syncOnSites('all feedbacks', this.syncAllFeedbacksFunc.bind(this, !!force), siteId); + } + + /** + * Sync all pending feedbacks on a site. + * + * @param force Wether to force sync not depending on last execution. + * @param siteId Site ID to sync. If not defined, sync all sites. + * @param Promise resolved if sync is successful, rejected if sync fails. + */ + protected async syncAllFeedbacksFunc(force: boolean, siteId?: string): Promise { + // Sync all new responses. + const responses = await AddonModFeedbackOffline.getAllFeedbackResponses(siteId); + + // Do not sync same feedback twice. + const treated: Record = {}; + + await Promise.all(responses.map(async (response) => { + if (treated[response.feedbackid]) { + return; + } + + treated[response.feedbackid] = true; + + const result = force ? + await this.syncFeedback(response.feedbackid, siteId) : + await this.syncFeedbackIfNeeded(response.feedbackid, siteId); + + if (result?.updated) { + // Sync successful, send event. + CoreEvents.trigger(AddonModFeedbackSyncProvider.AUTO_SYNCED, { + feedbackId: response.feedbackid, + warnings: result.warnings, + }, siteId); + } + })); + } + + /** + * Sync a feedback only if a certain time has passed since the last time. + * + * @param feedbackId Feedback ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the feedback is synced or if it doesn't need to be synced. + */ + async syncFeedbackIfNeeded(feedbackId: number, siteId?: string): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + const needed = await this.isSyncNeeded(feedbackId, siteId); + + if (needed) { + return this.syncFeedback(feedbackId, siteId); + } + } + + /** + * Synchronize all offline responses of a feedback. + * + * @param feedbackId Feedback ID to be synced. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if sync is successful, rejected otherwise. + */ + syncFeedback(feedbackId: number, siteId?: string): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + if (this.isSyncing(feedbackId, siteId)) { + // There's already a sync ongoing for this feedback, return the promise. + return this.getOngoingSync(feedbackId, siteId)!; + } + + // Verify that feedback isn't blocked. + if (CoreSync.isBlocked(AddonModFeedbackProvider.COMPONENT, feedbackId, siteId)) { + this.logger.debug(`Cannot sync feedback '${feedbackId}' because it is blocked.`); + + throw new CoreSyncBlockedError(Translate.instant('core.errorsyncblocked', { $a: this.componentTranslate })); + } + + this.logger.debug(`Try to sync feedback '${feedbackId}' in site ${siteId}'`); + + return this.addOngoingSync(feedbackId, this.performSyncFeedback(feedbackId, siteId), siteId); + } + + /** + * Perform the feedback sync. + * + * @param feedbackId Feedback ID. + * @param siteId Site ID. + * @return Promise resolved in success. + */ + protected async performSyncFeedback(feedbackId: number, siteId: string): Promise { + const result: AddonModFeedbackSyncResult = { + warnings: [], + updated: false, + }; + + // Sync offline logs. + await CoreUtils.ignoreErrors(CoreCourseLogHelper.syncActivity(AddonModFeedbackProvider.COMPONENT, feedbackId, siteId)); + + // Get offline responses to be sent. + const responses = await CoreUtils.ignoreErrors(AddonModFeedbackOffline.getFeedbackResponses(feedbackId, siteId)); + + if (!responses || !responses.length) { + // Nothing to sync. + await CoreUtils.ignoreErrors(this.setSyncTime(feedbackId, siteId)); + + return result; + } + + if (!CoreApp.isOnline()) { + // Cannot sync in offline. + throw new CoreNetworkError(); + } + + const courseId = responses[0].courseid; + + const feedback = await AddonModFeedback.getFeedbackById(courseId, feedbackId, { siteId }); + + if (!feedback.multiple_submit) { + // If it does not admit multiple submits, check if it is completed to know if we can submit. + const isCompleted = await AddonModFeedback.isCompleted(feedbackId, { cmId: feedback.coursemodule, siteId }); + + if (isCompleted) { + // Cannot submit again, delete resposes. + await Promise.all(responses.map((data) => + AddonModFeedbackOffline.deleteFeedbackPageResponses(feedbackId, data.page, siteId))); + + result.updated = true; + this.addOfflineDataDeletedWarning( + result.warnings, + feedback.name, + Translate.instant('addon.mod_feedback.this_feedback_is_already_submitted'), + ); + + await CoreUtils.ignoreErrors(this.setSyncTime(feedbackId, siteId)); + + return result; + } + } + + const timemodified = await AddonModFeedback.getCurrentCompletedTimeModified(feedbackId, { + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + siteId, + }); + // Sort by page. + responses.sort((a, b) => a.page - b.page); + + const orderedData = responses.map((data) => ({ + function: this.processPage.bind(this, feedback, data, siteId, timemodified, result), + blocking: true, + })); + + // Execute all the processes in order to solve dependencies. + await CoreUtils.executeOrderedPromises(orderedData); + + if (result.updated) { + // Data has been sent to server, update data. + try { + const module = await CoreCourse.getModuleBasicInfoByInstance(feedbackId, 'feedback', siteId); + + await this.prefetchAfterUpdate(AddonModFeedbackPrefetchHandler.instance, module, courseId, undefined, siteId); + } catch { + // Ignore errors. + } + } + + // Sync finished, set sync time. + await CoreUtils.ignoreErrors(this.setSyncTime(feedbackId, siteId)); + + return result; + } + + /** + * Convenience function to sync process page calls. + * + * @param feedback Feedback object. + * @param data Response data. + * @param siteId Site Id. + * @param timemodified Current completed modification time. + * @param result Result object to be modified. + * @return Resolve when done or rejected with error. + */ + protected async processPage( + feedback: AddonModFeedbackWSFeedback, + data: AddonModFeedbackOfflineResponse, + siteId: string, + timemodified: number, + result: AddonModFeedbackSyncResult, + ): Promise { + // Delete all pages that are submitted before changing website. + if (timemodified > data.timemodified) { + return AddonModFeedbackOffline.deleteFeedbackPageResponses(feedback.id, data.page, siteId); + } + + try { + await AddonModFeedback.processPageOnline(feedback.id, data.page, data.responses, false, siteId); + + result.updated = true; + + await AddonModFeedbackOffline.deleteFeedbackPageResponses(feedback.id, data.page, siteId); + } catch (error) { + if (!CoreUtils.isWebServiceError(error)) { + // Couldn't connect to server, reject. + throw error; + } + + // The WebService has thrown an error, this means that responses cannot be submitted. Delete them. + result.updated = true; + + await AddonModFeedbackOffline.deleteFeedbackPageResponses(feedback.id, data.page, siteId); + + // Responses deleted, add a warning. + this.addOfflineDataDeletedWarning( + result.warnings, + feedback.name, + error, + ); + } + } + +} + +export const AddonModFeedbackSync = makeSingleton(AddonModFeedbackSyncProvider); + +/** + * Data returned by a feedback sync. + */ +export type AddonModFeedbackSyncResult = { + warnings: string[]; // List of warnings. + updated: boolean; // Whether some data was sent to the server or offline data was updated. +}; + +/** + * Data passed to AUTO_SYNCED event. + */ +export type AddonModFeedbackAutoSyncData = { + feedbackId: number; + warnings: string[]; +}; diff --git a/src/addons/mod/feedback/services/feedback.ts b/src/addons/mod/feedback/services/feedback.ts new file mode 100644 index 000000000..04c4e1e1e --- /dev/null +++ b/src/addons/mod/feedback/services/feedback.ts @@ -0,0 +1,1762 @@ +// (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, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreWSExternalFile, CoreWSExternalWarning, CoreWSStoredFile } from '@services/ws'; +import { makeSingleton } from '@singletons'; +import { AddonModFeedbackOffline } from './feedback-offline'; +import { AddonModFeedbackAutoSyncData, AddonModFeedbackSyncProvider } from './feedback-sync'; + +const ROOT_CACHE_KEY = 'AddonModFeedback:'; + +/** + * Service that provides some features for feedbacks. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModFeedbackProvider { + + static readonly COMPONENT = 'mmaModFeedback'; + static readonly FORM_SUBMITTED = 'addon_mod_feedback_form_submitted'; + static readonly LINE_SEP = '|'; + static readonly MULTICHOICE_TYPE_SEP = '>>>>>'; + static readonly MULTICHOICE_ADJUST_SEP = '<<<<<'; + static readonly MULTICHOICE_HIDENOSELECT = 'h'; + static readonly MULTICHOICERATED_VALUE_SEP = '####'; + static readonly PER_PAGE = 20; + + /** + * Check dependency of a question item. + * + * @param items All question items to check dependency. + * @param item Item to check. + * @return Return true if dependency is acomplished and it can be shown. False, otherwise. + */ + protected checkDependencyItem(items: AddonModFeedbackItem[], item: AddonModFeedbackItem): boolean { + const depend = items.find((itemFind) => itemFind.id == item.dependitem); + + // Item not found, looks like dependent item has been removed or is in the same or following pages. + if (!depend) { + return true; + } + + switch (depend.typ) { + case 'label': + return false; + case 'multichoice': + case 'multichoicerated': + return this.compareDependItemMultichoice(depend, item.dependvalue); + default: + break; + } + + return item.dependvalue == depend.rawValue; + } + + /** + * Check dependency item of type Multichoice. + * + * @param item Item to check. + * @param dependValue Value to compare. + * @return Return true if dependency is acomplished and it can be shown. False, otherwise. + */ + protected compareDependItemMultichoice(item: AddonModFeedbackItem, dependValue: string): boolean { + const parts = item.presentation.split(AddonModFeedbackProvider.MULTICHOICE_TYPE_SEP) || []; + const subtype = parts.length > 0 && parts[0] ? parts[0] : 'r'; + + const choicesStr = (parts[1] || '').split(AddonModFeedbackProvider.MULTICHOICE_ADJUST_SEP)[0] || ''; + const choices = choicesStr.split(AddonModFeedbackProvider.LINE_SEP) || []; + let values: AddonModFeedbackResponseValue[]; + + if (subtype === 'c') { + if (typeof item.rawValue == 'undefined') { + values = ['']; + } else { + item.rawValue = '' + item.rawValue; + values = item.rawValue.split(AddonModFeedbackProvider.LINE_SEP); + } + } else { + values = [item.rawValue || '']; + } + + for (let index = 0; index < choices.length; index++) { + for (const x in values) { + if (values[x] == index + 1) { + let value = choices[index]; + + if (item.typ == 'multichoicerated') { + value = value.split(AddonModFeedbackProvider.MULTICHOICERATED_VALUE_SEP)[1] || ''; + } + + if (value.trim() == dependValue) { + return true; + } + + // We can finish checking if only searching on one value and we found it. + if (values.length == 1) { + return false; + } + } + } + } + + return false; + } + + /** + * Fill values of item questions. + * + * @param feedbackId Feedback ID. + * @param items Item to fill the value. + * @param options Other options. + * @return Resolved with values when done. + */ + protected async fillValues( + feedbackId: number, + items: AddonModFeedbackWSItem[], + options: CoreCourseCommonModWSOptions = {}, + ): Promise { + const filledItems = items; + + try { + const valuesArray = await this.getCurrentValues(feedbackId, options); + + const values: Record = {}; + + valuesArray.forEach((value) => { + values[value.item] = value.value; + }); + + filledItems.forEach((itemData) => { + if (itemData.hasvalue && typeof values[itemData.id] != 'undefined') { + itemData.rawValue = values[itemData.id]; + } + }); + } catch { + // Ignore errors. + } + + // Merge with offline data. + const offlineResponses = await CoreUtils.ignoreErrors( + AddonModFeedbackOffline.getFeedbackResponses(feedbackId, options.siteId), + ); + + if (!offlineResponses) { + return items; + } + + const offlineValues: Record = {}; + + // Merge all values into one array. + const offlineValuesArray = offlineResponses.reduce((array, entry) => { + const responses = CoreUtils.objectToArrayOfObjects(entry.responses, 'id', 'value'); + + return array.concat(responses); + }, []).map((valueEntry) => { + const parts = valueEntry.id.split('_'); + + return { + ...valueEntry, + typ: parts[0], + item: Number(parts[1]), + }; + }); + + offlineValuesArray.forEach((value) => { + if (typeof offlineValues[value.item] == 'undefined') { + offlineValues[value.item] = []; + } + offlineValues[value.item].push(value.value); + }); + + filledItems.forEach((item) => { + if (!item.hasvalue || offlineValues[item.id] === undefined) { + return; + } + + // Treat multichoice checkboxes. + if (item.typ == 'multichoice' && item.presentation.split(AddonModFeedbackProvider.MULTICHOICE_TYPE_SEP)[0] == 'c') { + + offlineValues[item.id] = offlineValues[item.id].filter((value) => value > 0); + item.rawValue = offlineValues[item.id].join(AddonModFeedbackProvider.LINE_SEP); + } else { + item.rawValue = offlineValues[item.id][0]; + } + }); + + return filledItems; + } + + /** + * Returns all the feedback non respondents users. + * + * @param feedbackId Feedback ID. + * @param options Other options. + * @param previous Only for recurrent use. Object with the previous fetched info. + * @return Promise resolved when the info is retrieved. + */ + async getAllNonRespondents( + feedbackId: number, + options: AddonModFeedbackGroupOptions = {}, + previous?: AddonModFeedbackPreviousNonRespondents, + ): Promise { + options.siteId = options.siteId || CoreSites.getCurrentSiteId(); + previous = previous || { + page: 0, + users: [], + }; + + const response = await this.getNonRespondents(feedbackId, { + page: previous.page, + ...options, // Include all options. + }); + + if (previous.users.length < response.total) { + previous.users = previous.users.concat(response.users); + } + + if (previous.users.length < response.total) { + // Can load more. + previous.page++; + + return this.getAllNonRespondents(feedbackId, options, previous); + } + + return { + ...previous, + total: response.total, + }; + } + + /** + * Returns all the feedback user responses. + * + * @param feedbackId Feedback ID. + * @param options Other options. + * @param previous Only for recurrent use. Object with the previous fetched info. + * @return Promise resolved when the info is retrieved. + */ + async getAllResponsesAnalysis( + feedbackId: number, + options: AddonModFeedbackGroupOptions = {}, + previous?: AddonModFeedbackPreviousResponsesAnalysis, + ): Promise { + + options.siteId = options.siteId || CoreSites.getCurrentSiteId(); + previous = previous || { + page: 0, + attempts: [], + anonattempts: [], + }; + + const responses = await this.getResponsesAnalysis(feedbackId, { + page: previous.page, + ...options, // Include all options. + }); + + if (previous.anonattempts.length < responses.totalanonattempts) { + previous.anonattempts = previous.anonattempts.concat(responses.anonattempts); + } + + if (previous.attempts.length < responses.totalattempts) { + previous.attempts = previous.attempts.concat(responses.attempts); + } + + if (previous.anonattempts.length < responses.totalanonattempts || previous.attempts.length < responses.totalattempts) { + // Can load more. + previous.page++; + + return this.getAllResponsesAnalysis(feedbackId, options, previous); + } + + return { + ...previous, + totalattempts: responses.totalattempts, + totalanonattempts: responses.totalanonattempts, + }; + } + + /** + * Get analysis information for a given feedback. + * + * @param feedbackId Feedback ID. + * @param options Other options. + * @return Promise resolved when the feedback is retrieved. + */ + async getAnalysis( + feedbackId: number, + options: AddonModFeedbackGroupOptions = {}, + ): Promise { + const site = await CoreSites.getSite(options.siteId); + + const params: AddonModFeedbackGetAnalysisWSParams = { + feedbackid: feedbackId, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getAnalysisDataCacheKey(feedbackId, options.groupId), + component: AddonModFeedbackProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + if (options.groupId) { + params.groupid = options.groupId; + } + + return site.read('mod_feedback_get_analysis', params, preSets); + } + + /** + * Get cache key for feedback analysis data WS calls. + * + * @param feedbackId Feedback ID. + * @param groupId Group ID. + * @return Cache key. + */ + protected getAnalysisDataCacheKey(feedbackId: number, groupId: number = 0): string { + return this.getAnalysisDataPrefixCacheKey(feedbackId) + groupId; + } + + /** + * Get prefix cache key for feedback analysis data WS calls. + * + * @param feedbackId Feedback ID. + * @return Cache key. + */ + protected getAnalysisDataPrefixCacheKey(feedbackId: number): string { + return this.getFeedbackDataPrefixCacheKey(feedbackId) + ':analysis:'; + } + + /** + * Find an attempt in all responses analysis. + * + * @param feedbackId Feedback ID. + * @param attemptId Attempt ID to find. + * @param options Other options. + * @param previous Only for recurrent use. Object with the previous fetched info. + * @return Promise resolved when the info is retrieved. + */ + async getAttempt( + feedbackId: number, + attemptId: number, + options: CoreCourseCommonModWSOptions = {}, + previous?: AddonModFeedbackGetAttemptPreviousData, + ): Promise { + options.siteId = options.siteId || CoreSites.getCurrentSiteId(); + previous = previous || { + page: 0, + attemptsLoaded: 0, + anonAttemptsLoaded: 0, + }; + + const responses = await this.getResponsesAnalysis(feedbackId, { + page: previous.page, + groupId: 0, + ...options, // Include all options. + }); + + const attempt = responses.attempts.find((attempt) => attemptId == attempt.id); + + if (attempt) { + return attempt; + } + + const anonAttempt = responses.anonattempts.find((attempt) => attemptId == attempt.id); + + if (anonAttempt) { + return anonAttempt; + } + + if (previous.anonAttemptsLoaded < responses.totalanonattempts) { + previous.anonAttemptsLoaded += responses.anonattempts.length; + } + if (previous.attemptsLoaded < responses.totalattempts) { + previous.attemptsLoaded += responses.attempts.length; + } + + if (previous.anonAttemptsLoaded < responses.totalanonattempts || previous.attemptsLoaded < responses.totalattempts) { + // Can load more. Check there. + previous.page++; + + return this.getAttempt(feedbackId, attemptId, options, previous); + } + + // Not found and all loaded. Reject. + throw new CoreError('Attempt not found.'); + } + + /** + * Get prefix cache key for feedback completion data WS calls. + * + * @param feedbackId Feedback ID. + * @return Cache key. + */ + protected getCompletedDataCacheKey(feedbackId: number): string { + return this.getFeedbackDataPrefixCacheKey(feedbackId) + ':completed:'; + } + + /** + * Returns the temporary completion timemodified for the current user. + * + * @param feedbackId Feedback ID. + * @param options Other options. + * @return Promise resolved when the info is retrieved. + */ + async getCurrentCompletedTimeModified(feedbackId: number, options: CoreCourseCommonModWSOptions = {}): Promise { + const site = await CoreSites.getSite(options.siteId); + + const params: AddonModFeedbackGetCurrentCompletedTmpWSParams = { + feedbackid: feedbackId, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getCurrentCompletedTimeModifiedDataCacheKey(feedbackId), + component: AddonModFeedbackProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + try { + const response = await site.read( + 'mod_feedback_get_current_completed_tmp', + params, + preSets, + ); + + return response.feedback.timemodified; + } catch { + // Ignore errors. + return 0; + } + } + + /** + * Get prefix cache key for feedback current completed temp data WS calls. + * + * @param feedbackId Feedback ID. + * @return Cache key. + */ + protected getCurrentCompletedTimeModifiedDataCacheKey(feedbackId: number): string { + return this.getFeedbackDataPrefixCacheKey(feedbackId) + ':completedtime:'; + } + + /** + * Returns the temporary responses or responses of the last submission for the current user. + * + * @param feedbackId Feedback ID. + * @param options Other options. + * @return Promise resolved when the info is retrieved. + */ + async getCurrentValues( + feedbackId: number, + options: CoreCourseCommonModWSOptions = {}, + ): Promise { + const site = await CoreSites.getSite(options.siteId); + + const params: AddonModFeedbackGetUnfinishedResponsesWSParams = { + feedbackid: feedbackId, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getCurrentValuesDataCacheKey(feedbackId), + component: AddonModFeedbackProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const response = await site.read( + 'mod_feedback_get_unfinished_responses', + params, + preSets, + ); + + if (response.responses.length) { + return response.responses; + } + + // No unfinished responses, fetch responses of the last submission. + const finishedResponse = await site.read( + 'mod_feedback_get_finished_responses', + params, + preSets, + ); + + return finishedResponse.responses; + } + + /** + * Get cache key for get current values feedback data WS calls. + * + * @param feedbackId Feedback ID. + * @return Cache key. + */ + protected getCurrentValuesDataCacheKey(feedbackId: number): string { + return this.getFeedbackDataPrefixCacheKey(feedbackId) + ':currentvalues'; + } + + /** + * Get access information for a given feedback. + * + * @param feedbackId Feedback ID. + * @param options Other options. + * @return Promise resolved when the feedback is retrieved. + */ + async getFeedbackAccessInformation( + feedbackId: number, + options: CoreCourseCommonModWSOptions = {}, + ): Promise { + const site = await CoreSites.getSite(options.siteId); + + const params: AddonModFeedbackGetFeedbackAccessInformationWSParams = { + feedbackid: feedbackId, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getFeedbackAccessInformationDataCacheKey(feedbackId), + component: AddonModFeedbackProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + return site.read('mod_feedback_get_feedback_access_information', params, preSets); + } + + /** + * Get cache key for feedback access information data WS calls. + * + * @param feedbackId Feedback ID. + * @return Cache key. + */ + protected getFeedbackAccessInformationDataCacheKey(feedbackId: number): string { + return this.getFeedbackDataPrefixCacheKey(feedbackId) + ':access'; + } + + /** + * Get cache key for feedback data WS calls. + * + * @param courseId Course ID. + * @return Cache key. + */ + protected getFeedbackCacheKey(courseId: number): string { + return ROOT_CACHE_KEY + 'feedback:' + courseId; + } + + /** + * Get prefix cache key for all feedback activity data WS calls. + * + * @param feedbackId Feedback ID. + * @return Cache key. + */ + protected getFeedbackDataPrefixCacheKey(feedbackId: number): string { + return ROOT_CACHE_KEY + feedbackId; + } + + /** + * Get a feedback with key=value. If more than one is found, only the first will be returned. + * + * @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 feedback is retrieved. + */ + protected async getFeedbackDataByKey( + courseId: number, + key: string, + value: unknown, + options: CoreSitesCommonWSOptions = {}, + ): Promise { + const site = await CoreSites.getSite(options.siteId); + + const params: AddonModFeedbackGetFeedbacksByCoursesWSParams = { + courseids: [courseId], + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getFeedbackCacheKey(courseId), + updateFrequency: CoreSite.FREQUENCY_RARELY, + component: AddonModFeedbackProvider.COMPONENT, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const response = await site.read( + 'mod_feedback_get_feedbacks_by_courses', + params, + preSets, + ); + + const currentFeedback = response.feedbacks.find((feedback) => feedback[key] == value); + if (currentFeedback) { + return currentFeedback; + } + + throw new CoreError('Feedback not found.'); + } + + /** + * Get a feedback by course module ID. + * + * @param courseId Course ID. + * @param cmId Course module ID. + * @param options Other options. + * @return Promise resolved when the feedback is retrieved. + */ + getFeedback(courseId: number, cmId: number, options: CoreSitesCommonWSOptions = {}): Promise { + return this.getFeedbackDataByKey(courseId, 'coursemodule', cmId, options); + } + + /** + * Get a feedback by ID. + * + * @param courseId Course ID. + * @param id Feedback ID. + * @param options Other options. + * @return Promise resolved when the feedback is retrieved. + */ + getFeedbackById(courseId: number, id: number, options: CoreSitesCommonWSOptions = {}): Promise { + return this.getFeedbackDataByKey(courseId, 'id', id, options); + } + + /** + * Returns the items (questions) in the given feedback. + * + * @param feedbackId Feedback ID. + * @param options Other options. + * @return Promise resolved when the info is retrieved. + */ + async getItems(feedbackId: number, options: CoreCourseCommonModWSOptions = {}): Promise { + const site = await CoreSites.getSite(options.siteId); + + const params: AddonModFeedbackGetItemsWSParams = { + feedbackid: feedbackId, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getItemsDataCacheKey(feedbackId), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES, + component: AddonModFeedbackProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + return site.read('mod_feedback_get_items', params, preSets); + } + + /** + * Get cache key for get items feedback data WS calls. + * + * @param feedbackId Feedback ID. + * @return Cache key. + */ + protected getItemsDataCacheKey(feedbackId: number): string { + return this.getFeedbackDataPrefixCacheKey(feedbackId) + ':items'; + } + + /** + * Retrieves a list of students who didn't submit the feedback. + * + * @param feedbackId Feedback ID. + * @param options Other options. + * @return Promise resolved when the info is retrieved. + */ + async getNonRespondents( + feedbackId: number, + options: AddonModFeedbackGroupPaginatedOptions = {}, + ): Promise { + options.groupId = options.groupId || 0; + options.page = options.page || 0; + + const site = await CoreSites.getSite(options.siteId); + + const params: AddonModFeedbackGetNonRespondentsWSParams = { + feedbackid: feedbackId, + groupid: options.groupId, + page: options.page, + perpage: AddonModFeedbackProvider.PER_PAGE, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getNonRespondentsDataCacheKey(feedbackId, options.groupId), + component: AddonModFeedbackProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + return site.read('mod_feedback_get_non_respondents', params, preSets); + } + + /** + * Get cache key for non respondents feedback data WS calls. + * + * @param feedbackId Feedback ID. + * @param groupId Group id, 0 means that the function will determine the user group. + * @return Cache key. + */ + protected getNonRespondentsDataCacheKey(feedbackId: number, groupId: number = 0): string { + return this.getNonRespondentsDataPrefixCacheKey(feedbackId) + groupId; + } + + /** + * Get prefix cache key for feedback non respondents data WS calls. + * + * @param feedbackId Feedback ID. + * @return Cache key. + */ + protected getNonRespondentsDataPrefixCacheKey(feedbackId: number): string { + return this.getFeedbackDataPrefixCacheKey(feedbackId) + ':nonrespondents:'; + } + + /** + * Get a single feedback page items. This function is not cached, use AddonModFeedbackHelperProvider#getPageItems instead. + * + * @param feedbackId Feedback ID. + * @param page The page to get. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the info is retrieved. + */ + async getPageItems(feedbackId: number, page: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + const params: AddonModFeedbackGetPageItemsWSParams = { + feedbackid: feedbackId, + page: page, + }; + + return site.write('mod_feedback_get_page_items', params); + } + + /** + * Get a single feedback page items. If offline or server down it will use getItems to calculate dependencies. + * + * @param feedbackId Feedback ID. + * @param page The page to get. + * @param options Other options. + * @return Promise resolved when the info is retrieved. + */ + async getPageItemsWithValues( + feedbackId: number, + page: number, + options: CoreCourseCommonModWSOptions = {}, + ): Promise { + options.siteId = options.siteId || CoreSites.getCurrentSiteId(); + + try { + const response: AddonModFeedbackPageItems = await this.getPageItems(feedbackId, page, options.siteId); + + response.items = await this.fillValues(feedbackId, response.items, options); + + return response; + } catch { + // If getPageItems fail we should calculate it using getItems. + const response = await this.getItems(feedbackId, options); + + const items = await this.fillValues(feedbackId, response.items, options); + + // Separate items by pages. + let currentPage = 0; + const previousPageItems: AddonModFeedbackItem[] = []; + + const pageItems = items.filter((item) => { + // Greater page, discard all entries. + if (currentPage > page) { + return false; + } + + if (item.typ == 'pagebreak') { + currentPage++; + + return false; + } + + // Save items on previous page to check dependencies and discard entry. + if (currentPage < page) { + previousPageItems.push(item); + + return false; + } + + // Filter depending items. + if (item && item.dependitem > 0 && previousPageItems.length > 0) { + return this.checkDependencyItem(previousPageItems, item); + } + + // Filter items with errors. + return item; + }); + + return { + items: pageItems, + hasprevpage: page > 0, + hasnextpage: currentPage > page, + warnings: response.warnings, + }; + } + } + + /** + * Convenience function to get the page we can jump. + * + * @param feedbackId Feedback ID. + * @param page Page where we want to jump. + * @param changePage If page change is forward (1) or backward (-1). + * @param options Other options. + * @return Page number where to jump. Or false if completed or first page. + */ + protected async getPageJumpTo( + feedbackId: number, + page: number, + changePage: number, + options: { cmId?: number; siteId?: string }, + ): Promise { + + const response = await this.getPageItemsWithValues(feedbackId, page, { + cmId: options.cmId, + readingStrategy: CoreSitesReadingStrategy.PreferCache, + siteId: options.siteId, + }); + + // The page we are going has items. + if (response.items.length > 0) { + return page; + } + + // Check we can jump futher. + if ((changePage == 1 && response.hasnextpage) || (changePage == -1 && response.hasprevpage)) { + return this.getPageJumpTo(feedbackId, page + changePage, changePage, options); + } + + // Completed or first page. + return false; + } + + /** + * Returns the feedback user responses. + * + * @param feedbackId Feedback ID. + * @param options Other options. + * @return Promise resolved when the info is retrieved. + */ + async getResponsesAnalysis( + feedbackId: number, + options: AddonModFeedbackGroupPaginatedOptions = {}, + ): Promise { + options.groupId = options.groupId || 0; + options.page = options.page || 0; + + const site = await CoreSites.getSite(options.siteId); + + const params: AddonModFeedbackGetResponsesAnalysisWSParams = { + feedbackid: feedbackId, + groupid: options.groupId, + page: options.page, + perpage: AddonModFeedbackProvider.PER_PAGE, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getResponsesAnalysisDataCacheKey(feedbackId, options.groupId), + component: AddonModFeedbackProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + return site.read('mod_feedback_get_responses_analysis', params, preSets); + } + + /** + * Get cache key for responses analysis feedback data WS calls. + * + * @param feedbackId Feedback ID. + * @param groupId Group id, 0 means that the function will determine the user group. + * @return Cache key. + */ + protected getResponsesAnalysisDataCacheKey(feedbackId: number, groupId: number = 0): string { + return this.getResponsesAnalysisDataPrefixCacheKey(feedbackId) + groupId; + } + + /** + * Get prefix cache key for feedback responses analysis data WS calls. + * + * @param feedbackId Feedback ID. + * @return Cache key. + */ + protected getResponsesAnalysisDataPrefixCacheKey(feedbackId: number): string { + return this.getFeedbackDataPrefixCacheKey(feedbackId) + ':responsesanalysis:'; + } + + /** + * Gets the resume page information. + * + * @param feedbackId Feedback ID. + * @param options Other options. + * @return Promise resolved when the info is retrieved. + */ + async getResumePage(feedbackId: number, options: CoreCourseCommonModWSOptions = {}): Promise { + const site = await CoreSites.getSite(options.siteId); + + const params: AddonModFeedbackLaunchFeedbackWSParams = { + feedbackid: feedbackId, + }; + const preSets = { + cacheKey: this.getResumePageDataCacheKey(feedbackId), + component: AddonModFeedbackProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const response = await site.read('mod_feedback_launch_feedback', params, preSets); + + // WS will return -1 for last page but the user need to start again. + return response.gopage > 0 ? response.gopage : 0; + } + + /** + * Get prefix cache key for resume feedback page data WS calls. + * + * @param feedbackId Feedback ID. + * @return Cache key. + */ + protected getResumePageDataCacheKey(feedbackId: number): string { + return this.getFeedbackDataPrefixCacheKey(feedbackId) + ':launch'; + } + + /** + * Invalidates feedback data except files and module info. + * + * @param feedbackId Feedback ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateAllFeedbackData(feedbackId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getFeedbackDataPrefixCacheKey(feedbackId)); + } + + /** + * Invalidates feedback analysis data. + * + * @param feedbackId Feedback ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateAnalysisData(feedbackId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + return site.invalidateWsCacheForKeyStartingWith(this.getAnalysisDataPrefixCacheKey(feedbackId)); + } + + /** + * Invalidate the prefetched content. + * To invalidate files, use AddonModFeedbackProvider#invalidateFiles. + * + * @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 feedback = await this.getFeedback(courseId, moduleId, { siteId }); + + await Promise.all([ + this.invalidateFeedbackData(courseId, siteId), + this.invalidateAllFeedbackData(feedback.id, siteId), + ]); + } + + /** + * Invalidates temporary completion record data. + * + * @param feedbackId Feedback ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateCurrentValuesData(feedbackId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + return site.invalidateWsCacheForKey(this.getCurrentValuesDataCacheKey(feedbackId)); + } + + /** + * Invalidates feedback access information data. + * + * @param feedbackId Feedback ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateFeedbackAccessInformationData(feedbackId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + return site.invalidateWsCacheForKey(this.getFeedbackAccessInformationDataCacheKey(feedbackId)); + } + + /** + * Invalidates feedback data. + * + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateFeedbackData(courseId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + return site.invalidateWsCacheForKey(this.getFeedbackCacheKey(courseId)); + } + + /** + * Invalidate the prefetched files. + * + * @param moduleId The module ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the files are invalidated. + */ + async invalidateFiles(moduleId: number, siteId?: string): Promise { + return CoreFilepool.invalidateFilesByComponent(siteId, AddonModFeedbackProvider.COMPONENT, moduleId); + } + + /** + * Invalidates feedback non respondents record data. + * + * @param feedbackId Feedback ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateNonRespondentsData(feedbackId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getNonRespondentsDataPrefixCacheKey(feedbackId)); + } + + /** + * Invalidates feedback user responses record data. + * + * @param feedbackId Feedback ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateResponsesAnalysisData(feedbackId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getResponsesAnalysisDataPrefixCacheKey(feedbackId)); + } + + /** + * Invalidates launch feedback data. + * + * @param feedbackId Feedback ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateResumePageData(feedbackId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getResumePageDataCacheKey(feedbackId)); + } + + /** + * Returns if feedback has been completed + * + * @param feedbackId Feedback ID. + * @param options Other options. + * @return Promise resolved when the info is retrieved. + */ + async isCompleted(feedbackId: number, options: CoreCourseCommonModWSOptions = {}): Promise { + const site = await CoreSites.getSite(options.siteId); + + const params: AddonModFeedbackGetLastCompletedWSParams = { + feedbackid: feedbackId, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getCompletedDataCacheKey(feedbackId), + updateFrequency: CoreSite.FREQUENCY_RARELY, + component: AddonModFeedbackProvider.COMPONENT, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + return CoreUtils.promiseWorks(site.read('mod_feedback_get_last_completed', params, preSets)); + } + + /** + * Return whether or not the plugin is enabled in a certain site. Plugin is enabled if the feedback WS are available. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with true if plugin is enabled, rejected or resolved with false otherwise. + * @since 3.3 + */ + async isPluginEnabled(siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + return site.wsAvailable('mod_feedback_get_feedbacks_by_courses') && + site.wsAvailable('mod_feedback_get_feedback_access_information'); + } + + /** + * Report the feedback as being viewed. + * + * @param id Module ID. + * @param name Name of the feedback. + * @param formViewed True if form was viewed. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the WS call is successful. + */ + async logView(id: number, name?: string, formViewed: boolean = false, siteId?: string): Promise { + const params: AddonModFeedbackViewFeedbackWSParams = { + feedbackid: id, + moduleviewed: formViewed, + }; + + await CoreCourseLogHelper.logSingle( + 'mod_feedback_view_feedback', + params, + AddonModFeedbackProvider.COMPONENT, + id, + name, + 'feedback', + { moduleviewed: params.moduleviewed }, + siteId, + ); + } + + /** + * Process a jump between pages. + * + * @param feedbackId Feedback ID. + * @param page The page being processed. + * @param responses The data to be processed the key is the field name (usually type[index]_id). + * @param options Other options. + * @return Promise resolved when the info is retrieved. + */ + async processPage( + feedbackId: number, + page: number, + responses: Record, + options: AddonModFeedbackProcessPageOptions = {}, + ): Promise { + options.siteId = options.siteId || CoreSites.getCurrentSiteId(); + + // Convenience function to store a message to be synchronized later. + const storeOffline = async (): Promise => { + await AddonModFeedbackOffline.saveResponses(feedbackId, page, responses, options.courseId!, options.siteId); + + // Simulate process_page response. + const response: AddonModFeedbackProcessPageResponse = { + jumpto: page, + completed: false, + offline: true, + }; + let changePage = 0; + + if (options.goPrevious) { + if (page > 0) { + changePage = -1; + } + } else if (!options.formHasErrors) { + // We can only go next if it has no errors. + changePage = 1; + } + + if (changePage === 0) { + return response; + } + + const pageItems = await this.getPageItemsWithValues(feedbackId, page, { + cmId: options.cmId, + readingStrategy: CoreSitesReadingStrategy.PreferCache, + siteId: options.siteId, + }); + + // Check completion. + if (changePage == 1 && !pageItems.hasnextpage) { + response.completed = true; + + return response; + } + + const loadPage = await this.getPageJumpTo(feedbackId, page + changePage, changePage, options); + + if (loadPage === false) { + // Completed or first page. + if (changePage == -1) { + response.jumpto = 0; + } else { + response.completed = true; + } + } else { + response.jumpto = loadPage; + } + + return response; + }; + + if (!CoreApp.isOnline()) { + // App is offline, store the action. + return storeOffline(); + } + + // If there's already a response to be sent to the server, discard it first. + await AddonModFeedbackOffline.deleteFeedbackPageResponses(feedbackId, page, options.siteId); + + try { + return await this.processPageOnline(feedbackId, page, responses, !!options.goPrevious, options.siteId); + } catch (error) { + if (CoreUtils.isWebServiceError(error)) { + // The WebService has thrown an error, this means that responses cannot be submitted. + throw error; + } + + // Couldn't connect to server, store in offline. + return storeOffline(); + } + } + + /** + * Process a jump between pages. + * + * @param feedbackId Feedback ID. + * @param page The page being processed. + * @param responses The data to be processed the key is the field name (usually type[index]_id). + * @param goPrevious Whether we want to jump to previous page. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the info is retrieved. + */ + async processPageOnline( + feedbackId: number, + page: number, + responses: Record, + goPrevious: boolean, + siteId?: string, + ): Promise { + const site = await CoreSites.getSite(siteId); + + const params: AddonModFeedbackProcessPageWSParams = { + feedbackid: feedbackId, + page: page, + responses: CoreUtils.objectToArrayOfObjects(responses, 'name', 'value'), + goprevious: goPrevious, + }; + + const response = await site.write('mod_feedback_process_page', params); + + // Invalidate and update current values because they will change. + await CoreUtils.ignoreErrors(this.invalidateCurrentValuesData(feedbackId, site.getId())); + + await CoreUtils.ignoreErrors(this.getCurrentValues(feedbackId, { siteId: site.getId() })); + + return response; + } + +} + +export const AddonModFeedback = makeSingleton(AddonModFeedbackProvider); + +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 { + [AddonModFeedbackProvider.FORM_SUBMITTED]: AddonModFeedbackFormSubmittedData; + [AddonModFeedbackSyncProvider.AUTO_SYNCED]: AddonModFeedbackAutoSyncData; + } + +} + +/** + * Data passed to FORM_SUBMITTED event. + */ +export type AddonModFeedbackFormSubmittedData = { + feedbackId: number; + tab: string; + offline: boolean; +}; + +/** + * Params of mod_feedback_get_analysis WS. + */ +export type AddonModFeedbackGetAnalysisWSParams = { + feedbackid: number; // Feedback instance id. + groupid?: number; // Group id, 0 means that the function will determine the user group. + courseid?: number; // Course where user completes the feedback (for site feedbacks only). +}; + +/** + * Data returned by mod_feedback_get_analysis WS. + */ +export type AddonModFeedbackGetAnalysisWSResponse = { + completedcount: number; // Number of completed submissions. + itemscount: number; // Number of items (questions). + itemsdata: { + item: AddonModFeedbackWSItem; + data: string[]; + }[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Item data returneds by feedback_item_exporter. + */ +export type AddonModFeedbackWSItem = { + id: number; // The record id. + feedback: number; // The feedback instance id this records belongs to. + template: number; // If it belogns to a template, the template id. + name: string; // The item name. + label: string; // The item label. + presentation: string; // The text describing the item or the available possible answers. + typ: string; // The type of the item. + hasvalue: number; // Whether it has a value or not. + position: number; // The position in the list of questions. + required: boolean; // Whether is a item (question) required or not. + dependitem: number; // The item id this item depend on. + dependvalue: string; // The depend value. + options: string; // Different additional settings for the item (question). + itemfiles: CoreWSStoredFile[]; // Itemfiles. + itemnumber: number; // The item position number. + otherdata: string; // Additional data that may be required by external functions. +}; + +/** + * Item with some calculated data. + */ +export type AddonModFeedbackItem = AddonModFeedbackWSItem & { + rawValue?: AddonModFeedbackResponseValue; +}; + +/** + * Params of mod_feedback_get_current_completed_tmp WS. + */ +export type AddonModFeedbackGetCurrentCompletedTmpWSParams = { + feedbackid: number; // Feedback instance id. + courseid?: number; // Course where user completes the feedback (for site feedbacks only). +}; + +/** + * Data returned by mod_feedback_get_current_completed_tmp WS. + */ +export type AddonModFeedbackGetCurrentCompletedTmpWSResponse = { + feedback: { + id: number; // The record id. + feedback: number; // The feedback instance id this records belongs to. + userid: number; // The user who completed the feedback (0 for anonymous). + guestid: string; // For guests, this is the session key. + timemodified: number; // The last time the feedback was completed. + // eslint-disable-next-line @typescript-eslint/naming-convention + random_response: number; // The response number (used when shuffling anonymous responses). + // eslint-disable-next-line @typescript-eslint/naming-convention + anonymous_response: number; // Whether is an anonymous response. + courseid: number; // The course id where the feedback was completed. + }; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_feedback_get_unfinished_responses WS. + */ +export type AddonModFeedbackGetUnfinishedResponsesWSParams = { + feedbackid: number; // Feedback instance id. + courseid?: number; // Course where user completes the feedback (for site feedbacks only). +}; + +/** + * Data returned by mod_feedback_get_unfinished_responses WS. + */ +export type AddonModFeedbackGetUnfinishedResponsesWSResponse = { + responses: AddonModFeedbackWSUnfinishedResponse[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Unfinished response data returned by feedback_valuetmp_exporter. + */ +export type AddonModFeedbackWSUnfinishedResponse = { + id: number; // The record id. + // eslint-disable-next-line @typescript-eslint/naming-convention + course_id: number; // The course id this record belongs to. + item: number; // The item id that was responded. + completed: number; // Reference to the feedback_completedtmp table. + // eslint-disable-next-line @typescript-eslint/naming-convention + tmp_completed: number; // Old field - not used anymore. + value: string; // The response value. +}; + +/** + * Params of mod_feedback_get_finished_responses WS. + */ +export type AddonModFeedbackGetFinishedResponsesWSParams = { + feedbackid: number; // Feedback instance id. + courseid?: number; // Course where user completes the feedback (for site feedbacks only). +}; + +/** + * Data returned by mod_feedback_get_finished_responses WS. + */ +export type AddonModFeedbackGetFinishedResponsesWSResponse = { + responses: AddonModFeedbackWSFinishedResponse[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Unfinished response data returned by feedback_value_exporter. + */ +export type AddonModFeedbackWSFinishedResponse = { + id: number; // The record id. + // eslint-disable-next-line @typescript-eslint/naming-convention + course_id: number; // The course id this record belongs to. + item: number; // The item id that was responded. + completed: number; // Reference to the feedback_completed table. + // eslint-disable-next-line @typescript-eslint/naming-convention + tmp_completed: number; // Old field - not used anymore. + value: string; // The response value. +}; + +/** + * A response, either finished or unfinished. + */ +export type AddonModFeedbackWSResponse = AddonModFeedbackWSFinishedResponse | AddonModFeedbackWSUnfinishedResponse; + +/** + * Params of mod_feedback_get_feedback_access_information WS. + */ +export type AddonModFeedbackGetFeedbackAccessInformationWSParams = { + feedbackid: number; // Feedback instance id. + courseid?: number; // Course where user completes the feedback (for site feedbacks only). +}; + +/** + * Data returned by mod_feedback_get_feedback_access_information WS. + */ +export type AddonModFeedbackGetFeedbackAccessInformationWSResponse = { + canviewanalysis: boolean; // Whether the user can view the analysis or not. + cancomplete: boolean; // Whether the user can complete the feedback or not. + cansubmit: boolean; // Whether the user can submit the feedback or not. + candeletesubmissions: boolean; // Whether the user can delete submissions or not. + canviewreports: boolean; // Whether the user can view the feedback reports or not. + canedititems: boolean; // Whether the user can edit feedback items or not. + isempty: boolean; // Whether the feedback has questions or not. + isopen: boolean; // Whether the feedback has active access time restrictions or not. + isalreadysubmitted: boolean; // Whether the feedback is already submitted or not. + isanonymous: boolean; // Whether the feedback is anonymous or not. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_feedback_get_feedbacks_by_courses WS. + */ +export type AddonModFeedbackGetFeedbacksByCoursesWSParams = { + courseids?: number[]; // Array of course ids. +}; + +/** + * Data returned by mod_feedback_get_feedbacks_by_courses WS. + */ +export type AddonModFeedbackGetFeedbacksByCoursesWSResponse = { + feedbacks: AddonModFeedbackWSFeedback[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Feedback data returned by mod_feedback_get_feedbacks_by_courses WS. + */ +export type AddonModFeedbackWSFeedback = { + id: number; // The primary key of the record. + course: number; // Course id this feedback is part of. + name: string; // Feedback name. + intro: string; // Feedback introduction text. + introformat?: number; // Intro format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + anonymous: number; // Whether the feedback is anonymous. + // eslint-disable-next-line @typescript-eslint/naming-convention + email_notification?: boolean; // Whether email notifications will be sent to teachers. + // eslint-disable-next-line @typescript-eslint/naming-convention + multiple_submit: boolean; // Whether multiple submissions are allowed. + autonumbering: boolean; // Whether questions should be auto-numbered. + // eslint-disable-next-line @typescript-eslint/naming-convention + site_after_submit?: string; // Link to next page after submission. + // eslint-disable-next-line @typescript-eslint/naming-convention + page_after_submit?: string; // Text to display after submission. + // eslint-disable-next-line @typescript-eslint/naming-convention + page_after_submitformat?: number; // Page_after_submit format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + // eslint-disable-next-line @typescript-eslint/naming-convention + publish_stats: boolean; // Whether stats should be published. + timeopen?: number; // Allow answers from this time. + timeclose?: number; // Allow answers until this time. + timemodified?: number; // The time this record was modified. + completionsubmit: boolean; // If set to 1, then the activity will be automatically marked as complete on submission. + coursemodule: number; // Coursemodule. + introfiles: CoreWSExternalFile[]; // Introfiles. + pageaftersubmitfiles?: CoreWSExternalFile[]; // Pageaftersubmitfiles. +}; + +/** + * Params of mod_feedback_get_items WS. + */ +export type AddonModFeedbackGetItemsWSParams = { + feedbackid: number; // Feedback instance id. + courseid?: number; // Course where user completes the feedback (for site feedbacks only). +}; + +/** + * Data returned by mod_feedback_get_items WS. + */ +export type AddonModFeedbackGetItemsWSResponse = { + items: AddonModFeedbackWSItem[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_feedback_get_non_respondents WS. + */ +export type AddonModFeedbackGetNonRespondentsWSParams = { + feedbackid: number; // Feedback instance id. + groupid?: number; // Group id, 0 means that the function will determine the user group. + sort?: string; // Sort param, must be firstname, lastname or lastaccess (default). + page?: number; // The page of records to return. + perpage?: number; // The number of records to return per page. + courseid?: number; // Course where user completes the feedback (for site feedbacks only). +}; + +/** + * Data returned by mod_feedback_get_non_respondents WS. + */ +export type AddonModFeedbackGetNonRespondentsWSResponse = { + users: AddonModFeedbackWSNonRespondent[]; + total: number; // Total number of non respondents. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Data returned by mod_feedback_get_non_respondents WS. + */ +export type AddonModFeedbackWSNonRespondent = { + courseid: number; // Course id. + userid: number; // The user id. + fullname: string; // User full name. + started: boolean; // If the user has started the attempt. +}; + +/** + * Params of mod_feedback_get_page_items WS. + */ +export type AddonModFeedbackGetPageItemsWSParams = { + feedbackid: number; // Feedback instance id. + page: number; // The page to get starting by 0. + courseid?: number; // Course where user completes the feedback (for site feedbacks only). +}; + +/** + * Data returned by mod_feedback_get_page_items WS. + */ +export type AddonModFeedbackGetPageItemsWSResponse = { + items: AddonModFeedbackWSItem[]; + hasprevpage: boolean; // Whether is a previous page. + hasnextpage: boolean; // Whether there are more pages. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Page items with some calculated data. + */ +export type AddonModFeedbackPageItems = Omit & { + items: AddonModFeedbackItem[]; +}; + +/** + * Params of mod_feedback_get_responses_analysis WS. + */ +export type AddonModFeedbackGetResponsesAnalysisWSParams = { + feedbackid: number; // Feedback instance id. + groupid?: number; // Group id, 0 means that the function will determine the user group. + page?: number; // The page of records to return. + perpage?: number; // The number of records to return per page. + courseid?: number; // Course where user completes the feedback (for site feedbacks only). +}; + +/** + * Data returned by mod_feedback_get_responses_analysis WS. + */ +export type AddonModFeedbackGetResponsesAnalysisWSResponse = { + attempts: AddonModFeedbackWSAttempt[]; + totalattempts: number; // Total responses count. + anonattempts: AddonModFeedbackWSAnonAttempt[]; + totalanonattempts: number; // Total anonymous responses count. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Attempt data returned by mod_feedback_get_responses_analysis WS. + */ +export type AddonModFeedbackWSAttempt = { + id: number; // Completed id. + courseid: number; // Course id. + userid: number; // User who responded. + timemodified: number; // Time modified for the response. + fullname: string; // User full name. + responses: AddonModFeedbackWSAttemptResponse[]; +}; + +/** + * Anonymous attempt data returned by mod_feedback_get_responses_analysis WS. + */ +export type AddonModFeedbackWSAnonAttempt = { + id: number; // Completed id. + courseid: number; // Course id. + // eslint-disable-next-line id-blacklist + number: number; // Response number. + responses: AddonModFeedbackWSAttemptResponse[]; +}; + +/** + * Response data returned by mod_feedback_get_responses_analysis WS. + */ +export type AddonModFeedbackWSAttemptResponse = { + id: number; // Response id. + name: string; // Response name. + printval: string; // Response ready for output. + rawval: string; // Response raw value. +}; + +/** + * Params of mod_feedback_launch_feedback WS. + */ +export type AddonModFeedbackLaunchFeedbackWSParams = { + feedbackid: number; // Feedback instance id. + courseid?: number; // Course where user completes the feedback (for site feedbacks only). +}; + +/** + * Data returned by mod_feedback_launch_feedback WS. + */ +export type AddonModFeedbackLaunchFeedbackWSResponse = { + gopage: number; // The next page to go (-1 if we were already in the last page). 0 for first page. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_feedback_get_last_completed WS. + */ +export type AddonModFeedbackGetLastCompletedWSParams = { + feedbackid: number; // Feedback instance id. + courseid?: number; // Course where user completes the feedback (for site feedbacks only). +}; + +/** + * Params of mod_feedback_view_feedback WS. + */ +export type AddonModFeedbackViewFeedbackWSParams = { + feedbackid: number; // Feedback instance id. + moduleviewed?: boolean; // If we need to mark the module as viewed for completion. + courseid?: number; // Course where user completes the feedback (for site feedbacks only). +}; + +/** + * Params of mod_feedback_process_page WS. + */ +export type AddonModFeedbackProcessPageWSParams = { + feedbackid: number; // Feedback instance id. + page: number; // The page being processed. + responses?: { // The data to be processed. + name: string; // The response name (usually type[index]_id). + value: string | number; // The response value. + }[]; + goprevious?: boolean; // Whether we want to jump to previous page. + courseid?: number; // Course where user completes the feedback (for site feedbacks only). +}; + +/** + * Data returned by mod_feedback_process_page WS. + */ +export type AddonModFeedbackProcessPageWSResponse = { + jumpto: number; // The page to jump to. + completed: boolean; // If the user completed the feedback. + completionpagecontents: string; // The completion page contents. + siteaftersubmit: string; // The link (could be relative) to show after submit. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Data returned by process page. + */ +export type AddonModFeedbackProcessPageResponse = { + jumpto: number | null; // The page to jump to. + completed: boolean; // If the user completed the feedback. + offline?: boolean; // Whether data has been stored in offline. +} & Partial; + +/** + * Common options with a group ID. + */ +export type AddonModFeedbackGroupOptions = CoreCourseCommonModWSOptions & { + groupId?: number; // Group id, 0 means that the function will determine the user group. Defaults to 0. +}; + +/** + * Common options with a group ID and page. + */ +export type AddonModFeedbackGroupPaginatedOptions = AddonModFeedbackGroupOptions & { + page?: number; // The page of records to return. The page of records to return. +}; + +/** + * Common options with a group ID and page. + */ +export type AddonModFeedbackProcessPageOptions = { + goPrevious?: boolean; // Whether we want to jump to previous page. + formHasErrors?: boolean; // Whether the form we sent has required but empty fields (only used in offline). + cmId?: number; // Module ID. + courseId?: number; // Course ID the feedback belongs to. + siteId?: string; // Site ID. If not defined, current site.; +}; + +/** + * Possible types of responses. + */ +export type AddonModFeedbackResponseValue = string | number; + +type OfflineResponsesArray = { + id: string; + value: AddonModFeedbackResponseValue; +}[]; + +/** + * Previous non respondents when using recursive function. + */ +export type AddonModFeedbackPreviousNonRespondents = { + page: number; + users: AddonModFeedbackWSNonRespondent[]; +}; + +/** + * All non respondents. + */ +export type AddonModFeedbackAllNonRespondent = AddonModFeedbackPreviousNonRespondents & { + total: number; +}; + +export type AddonModFeedbackPreviousResponsesAnalysis = { + page: number; + attempts: AddonModFeedbackWSAttempt[]; + anonattempts: AddonModFeedbackWSAnonAttempt[]; +}; + +export type AddonModFeedbackAllResponsesAnalysis = AddonModFeedbackPreviousResponsesAnalysis & { + totalattempts: number; + totalanonattempts: number; +}; + +export type AddonModFeedbackGetAttemptPreviousData = { + page: number; + attemptsLoaded: number; + anonAttemptsLoaded: number; +}; diff --git a/src/addons/mod/feedback/services/handlers/analysis-link.ts b/src/addons/mod/feedback/services/handlers/analysis-link.ts new file mode 100644 index 000000000..d3c06a61f --- /dev/null +++ b/src/addons/mod/feedback/services/handlers/analysis-link.ts @@ -0,0 +1,92 @@ +// (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 { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate'; +import { CoreCourse } from '@features/course/services/course'; +import { CoreNavigator } from '@services/navigator'; +import { CoreDomUtils } from '@services/utils/dom'; +import { makeSingleton } from '@singletons'; +import { AddonModFeedback } from '../feedback'; +import { AddonModFeedbackModuleHandlerService } from './module'; + +/** + * Content links handler for a feedback analysis. + * Match mod/feedback/analysis.php with a valid feedback id. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModFeedbackAnalysisLinkHandlerService extends CoreContentLinksHandlerBase { + + name = 'AddonModFeedbackAnalysisLinkHandler'; + featureName = 'CoreCourseModuleDelegate_AddonModFeedback'; + pattern = /\/mod\/feedback\/analysis\.php.*([&?]id=\d+)/; + + /** + * @inheritdoc + */ + getActions(siteIds: string[], url: string, params: Record): CoreContentLinksAction[] { + return [{ + action: async (siteId: string) => { + const modal = await CoreDomUtils.showModalLoading(); + + const moduleId = Number(params.id); + + try { + const moduleBasicInfo = await CoreCourse.getModuleBasicInfo(moduleId, siteId); + + // Get the module. + const module = await CoreCourse.getModule( + moduleId, + moduleBasicInfo.course, + moduleBasicInfo.section, + false, + false, + siteId, + ); + + CoreNavigator.navigateToSitePath( + AddonModFeedbackModuleHandlerService.PAGE_NAME + `/${module.course}/${module.id}`, + { + params: { + module, + tab: 'analysis', + }, + siteId, + }, + ); + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'Error opening link.'); + } finally { + modal.dismiss(); + } + }, + }]; + } + + /** + * @inheritdoc + */ + async isEnabled(siteId: string, url: string, params: Record): Promise { + if (typeof params.id == 'undefined') { + // Cannot treat the URL. + return false; + } + + return AddonModFeedback.isPluginEnabled(siteId); + } + +} + +export const AddonModFeedbackAnalysisLinkHandler = makeSingleton(AddonModFeedbackAnalysisLinkHandlerService); diff --git a/src/addons/mod/feedback/services/handlers/complete-link.ts b/src/addons/mod/feedback/services/handlers/complete-link.ts new file mode 100644 index 000000000..94d46c83d --- /dev/null +++ b/src/addons/mod/feedback/services/handlers/complete-link.ts @@ -0,0 +1,80 @@ +// (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 { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate'; +import { CoreCourse } from '@features/course/services/course'; +import { CoreNavigator } from '@services/navigator'; +import { CoreDomUtils } from '@services/utils/dom'; +import { makeSingleton } from '@singletons'; +import { AddonModFeedback } from '../feedback'; +import { AddonModFeedbackModuleHandlerService } from './module'; + +/** + * Content links handler for feedback complete questions. + * Match mod/feedback/complete.php with a valid feedback id. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModFeedbackCompleteLinkHandlerService extends CoreContentLinksHandlerBase { + + name = 'AddonModFeedbackCompleteLinkHandler'; + featureName = 'CoreCourseModuleDelegate_AddonModFeedback'; + pattern = /\/mod\/feedback\/complete\.php.*([?&](id|gopage)=\d+)/; + + /** + * @inheritdoc + */ + getActions(siteIds: string[], url: string, params: Record): CoreContentLinksAction[] { + return [{ + action: async (siteId: string) => { + const modal = await CoreDomUtils.showModalLoading(); + + const moduleId = Number(params.id); + + try { + const module = await CoreCourse.getModuleBasicInfo(moduleId, siteId); + + CoreNavigator.navigateToSitePath( + AddonModFeedbackModuleHandlerService.PAGE_NAME + `/${module.course}/${module.id}/form`, + { + params: { + page: typeof params.gopage != 'undefined' ? Number(params.gopage) : undefined, + }, + siteId, + }, + ); + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'Error opening link.'); + } finally { + modal.dismiss(); + } + }, + }]; + } + + /** + * @inheritdoc + */ + async isEnabled(siteId: string, url: string, params: Record): Promise { + if (typeof params.id == 'undefined') { + return false; + } + + return AddonModFeedback.isPluginEnabled(siteId); + } + +} + +export const AddonModFeedbackCompleteLinkHandler = makeSingleton(AddonModFeedbackCompleteLinkHandlerService); diff --git a/src/addons/mod/feedback/services/handlers/index-link.ts b/src/addons/mod/feedback/services/handlers/index-link.ts new file mode 100644 index 000000000..319112eb6 --- /dev/null +++ b/src/addons/mod/feedback/services/handlers/index-link.ts @@ -0,0 +1,47 @@ +// (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'; +import { AddonModFeedback } from '../feedback'; + +/** + * Handler to treat links to feedback. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModFeedbackIndexLinkHandlerService extends CoreContentLinksModuleIndexHandler { + + name = 'AddonModFeedbackLinkHandler'; + + constructor() { + super('AddonModFeedback', 'feedback'); + } + + /** + * Check if the handler is enabled for a certain site (site + user) and a URL. + * + * @param siteId The site ID. + * @param url The URL to treat. + * @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param courseId Course ID related to the URL. Optional but recommended. + * @return Whether the handler is enabled for the URL and site. + */ + isEnabled(): Promise { + return AddonModFeedback.isPluginEnabled(); + } + +} + +export const AddonModFeedbackIndexLinkHandler = makeSingleton(AddonModFeedbackIndexLinkHandlerService); diff --git a/src/addons/mod/feedback/services/handlers/list-link.ts b/src/addons/mod/feedback/services/handlers/list-link.ts new file mode 100644 index 000000000..9f62a378c --- /dev/null +++ b/src/addons/mod/feedback/services/handlers/list-link.ts @@ -0,0 +1,41 @@ +// (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'; +import { AddonModFeedback } from '../feedback'; + +/** + * Handler to treat links to feedback list page. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModFeedbackListLinkHandlerService extends CoreContentLinksModuleListHandler { + + name = 'AddonModFeedbackListLinkHandler'; + + constructor() { + super('AddonModFeedback', 'feedback'); + } + + /** + * @inheritdoc + */ + isEnabled(): Promise { + return AddonModFeedback.isPluginEnabled(); + } + +} + +export const AddonModFeedbackListLinkHandler = makeSingleton(AddonModFeedbackListLinkHandlerService); diff --git a/src/addons/mod/feedback/services/handlers/module.ts b/src/addons/mod/feedback/services/handlers/module.ts new file mode 100644 index 000000000..fba3e5862 --- /dev/null +++ b/src/addons/mod/feedback/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 { AddonModFeedback } from '../feedback'; +import { AddonModFeedbackIndexComponent } from '../../components/index'; + +/** + * Handler to support feedback modules. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModFeedbackModuleHandlerService implements CoreCourseModuleHandler { + + static readonly PAGE_NAME = 'mod_feedback'; + + name = 'AddonModFeedback'; + modName = 'feedback'; + + 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 + */ + isEnabled(): Promise { + return AddonModFeedback.isPluginEnabled(); + } + + /** + * @inheritdoc + */ + getData(module: CoreCourseAnyModuleData): CoreCourseModuleHandlerData { + return { + icon: CoreCourse.getModuleIconSrc(this.modName, 'modicon' in module ? module.modicon : undefined), + title: module.name, + class: 'addon-mod_feedback-handler', + showDownloadButton: true, + action(event: Event, module: CoreCourseModule, courseId: number, options?: CoreNavigationOptions): void { + options = options || {}; + options.params = options.params || {}; + Object.assign(options.params, { module }); + const routeParams = '/' + courseId + '/' + module.id; + + CoreNavigator.navigateToSitePath(AddonModFeedbackModuleHandlerService.PAGE_NAME + routeParams, options); + }, + }; + } + + /** + * @inheritdoc + */ + async getMainComponent(): Promise> { + return AddonModFeedbackIndexComponent; + } + +} + +export const AddonModFeedbackModuleHandler = makeSingleton(AddonModFeedbackModuleHandlerService); diff --git a/src/addons/mod/feedback/services/handlers/prefetch.ts b/src/addons/mod/feedback/services/handlers/prefetch.ts new file mode 100644 index 000000000..5ed1b4746 --- /dev/null +++ b/src/addons/mod/feedback/services/handlers/prefetch.ts @@ -0,0 +1,228 @@ +// (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, CoreCourseCommonModWSOptions } from '@features/course/services/course'; +import { CoreFilepool } from '@services/filepool'; +import { CoreGroups } from '@services/groups'; +import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreWSFile } from '@services/ws'; +import { makeSingleton } from '@singletons'; +import { + AddonModFeedback, + AddonModFeedbackGetFeedbackAccessInformationWSResponse, + AddonModFeedbackProvider, + AddonModFeedbackWSFeedback, +} from '../feedback'; +import { AddonModFeedbackSync, AddonModFeedbackSyncResult } from '../feedback-sync'; + +/** + * Handler to prefetch feedbacks. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModFeedbackPrefetchHandlerService extends CoreCourseActivityPrefetchHandlerBase { + + name = 'AddonModFeedback'; + modName = 'feedback'; + component = AddonModFeedbackProvider.COMPONENT; + updatesNames = /^configuration$|^.*files$|^attemptsfinished|^attemptsunfinished$/; + + /** + * @inheritdoc + */ + async getFiles(module: CoreCourseAnyModuleData, courseId: number): Promise { + let files: CoreWSFile[] = []; + + const feedback = await AddonModFeedback.getFeedback(courseId, module.id); + + // Get intro files and page after submit files. + files = feedback.pageaftersubmitfiles || []; + files = files.concat(this.getIntroFilesFromInstance(module, feedback)); + + try { + const response = await AddonModFeedback.getItems(feedback.id); + + response.items.forEach((item) => { + files = files.concat(item.itemfiles); + }); + } catch (e) { + // Ignore errors. + } + + return files; + } + + /** + * @inheritdoc + */ + async getIntroFiles(module: CoreCourseAnyModuleData, courseId: number): Promise { + const feedback = await CoreUtils.ignoreErrors(AddonModFeedback.getFeedback(courseId, module.id)); + + return this.getIntroFilesFromInstance(module, feedback); + } + + /** + * @inheritdoc + */ + invalidateContent(moduleId: number, courseId: number): Promise { + return AddonModFeedback.invalidateContent(moduleId, courseId); + } + + /** + * @inheritdoc + */ + invalidateModule(module: CoreCourseAnyModuleData, courseId: number): Promise { + return AddonModFeedback.invalidateFeedbackData(courseId); + } + + /** + * @inheritdoc + */ + async isDownloadable(module: CoreCourseAnyModuleData, courseId: number): Promise { + const feedback = await AddonModFeedback.getFeedback(courseId, module.id, { + readingStrategy: CoreSitesReadingStrategy.PreferCache, + }); + + const now = CoreTimeUtils.timestamp(); + + // Check time first if available. + if (feedback.timeopen && feedback.timeopen > now) { + return false; + } + if (feedback.timeclose && feedback.timeclose < now) { + return false; + } + + const accessData = await AddonModFeedback.getFeedbackAccessInformation(feedback.id, { cmId: module.id }); + + return accessData.isopen; + } + + /** + * @inheritdoc + */ + isEnabled(): Promise { + return AddonModFeedback.isPluginEnabled(); + } + + /** + * @inheritdoc + */ + prefetch(module: CoreCourseAnyModuleData, courseId?: number): Promise { + return this.prefetchPackage(module, courseId, this.prefetchFeedback.bind(this, module, courseId)); + } + + /** + * Prefetch a feedback. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @return Promise resolved when done. + */ + protected async prefetchFeedback(module: CoreCourseAnyModuleData, courseId: number): Promise { + const siteId = CoreSites.getCurrentSiteId(); + const commonOptions = { + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + siteId, + }; + const modOptions = { + cmId: module.id, + ...commonOptions, // Include all common options. + }; + + // Prefetch the feedback data. + const feedback = await AddonModFeedback.getFeedback(courseId, module.id, commonOptions); + + let files: CoreWSFile[] = feedback.pageaftersubmitfiles || []; + files = files.concat(this.getIntroFilesFromInstance(module, feedback)); + + const accessData = await AddonModFeedback.getFeedbackAccessInformation(feedback.id, modOptions); + + const promises: Promise[] = []; + + if (accessData.canedititems || accessData.canviewreports) { + // Get all groups analysis. + promises.push(AddonModFeedback.getAnalysis(feedback.id, modOptions)); + promises.push(this.prefetchAllGroupsAnalysis(feedback, accessData, modOptions)); + } + + promises.push(AddonModFeedback.getItems(feedback.id, commonOptions).then((response) => { + response.items.forEach((item) => { + files = files.concat(item.itemfiles); + }); + + return CoreFilepool.addFilesToQueue(siteId, files, this.component, module.id); + })); + + if (accessData.cancomplete && accessData.cansubmit && !accessData.isempty) { + // Send empty data, so it will recover last completed feedback attempt values. + promises.push(AddonModFeedback.processPageOnline(feedback.id, 0, {}, false, siteId).then(() => Promise.all([ + AddonModFeedback.getCurrentValues(feedback.id, modOptions), + AddonModFeedback.getResumePage(feedback.id, modOptions), + ]))); + } + + await Promise.all(promises); + } + + /** + * Prefetch all groups analysis. + * + * @param feedback Feedback. + * @param accessData Access info. + * @param modOptions Options. + */ + protected async prefetchAllGroupsAnalysis( + feedback: AddonModFeedbackWSFeedback, + accessData: AddonModFeedbackGetFeedbackAccessInformationWSResponse, + modOptions: CoreCourseCommonModWSOptions, + ): Promise { + const groupInfo = await CoreGroups.getActivityGroupInfo(feedback.coursemodule, true, undefined, modOptions.siteId, true); + + const promises: Promise[] = []; + + if (!groupInfo.groups || groupInfo.groups.length == 0) { + groupInfo.groups = [{ id: 0, name: '' }]; + } + + groupInfo.groups.forEach((group) => { + const groupOptions = { + groupId: group.id, + ...modOptions, // Include all mod options. + }; + + promises.push(AddonModFeedback.getAnalysis(feedback.id, groupOptions)); + promises.push(AddonModFeedback.getAllResponsesAnalysis(feedback.id, groupOptions)); + + if (!accessData.isanonymous) { + promises.push(AddonModFeedback.getAllNonRespondents(feedback.id, groupOptions)); + } + }); + + await Promise.all(promises); + } + + /** + * @inheritdoc + */ + sync(module: CoreCourseAnyModuleData, courseId: number, siteId?: string): Promise { + return AddonModFeedbackSync.syncFeedback(module.instance!, siteId); + } + +} + +export const AddonModFeedbackPrefetchHandler = makeSingleton(AddonModFeedbackPrefetchHandlerService); diff --git a/src/addons/mod/feedback/services/handlers/print-link.ts b/src/addons/mod/feedback/services/handlers/print-link.ts new file mode 100644 index 000000000..b74bab793 --- /dev/null +++ b/src/addons/mod/feedback/services/handlers/print-link.ts @@ -0,0 +1,80 @@ +// (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 { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate'; +import { CoreCourse } from '@features/course/services/course'; +import { CoreNavigator } from '@services/navigator'; +import { CoreDomUtils } from '@services/utils/dom'; +import { makeSingleton } from '@singletons'; +import { AddonModFeedback } from '../feedback'; +import { AddonModFeedbackModuleHandlerService } from './module'; + +/** + * Content links handler for feedback print questions. + * Match mod/feedback/print.php with a valid feedback id. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModFeedbackPrintLinkHandlerService extends CoreContentLinksHandlerBase { + + name = 'AddonModFeedbackPrintLinkHandler'; + featureName = 'CoreCourseModuleDelegate_AddonModFeedback'; + pattern = /\/mod\/feedback\/print\.php.*([?&](id)=\d+)/; + + /** + * @inheritdoc + */ + getActions(siteIds: string[], url: string, params: Record): CoreContentLinksAction[] { + return [{ + action: async (siteId: string) => { + const modal = await CoreDomUtils.showModalLoading(); + + const moduleId = Number(params.id); + + try { + const module = await CoreCourse.getModuleBasicInfo(moduleId, siteId); + + CoreNavigator.navigateToSitePath( + AddonModFeedbackModuleHandlerService.PAGE_NAME + `/${module.course}/${module.id}/form`, + { + params: { + preview: true, + }, + siteId, + }, + ); + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'Error opening link.'); + } finally { + modal.dismiss(); + } + }, + }]; + } + + /** + * @inheritdoc + */ + async isEnabled(siteId: string, url: string, params: Record): Promise { + if (typeof params.id == 'undefined') { + return false; + } + + return AddonModFeedback.isPluginEnabled(siteId); + } + +} + +export const AddonModFeedbackPrintLinkHandler = makeSingleton(AddonModFeedbackPrintLinkHandlerService); diff --git a/src/addons/mod/feedback/services/handlers/push-click.ts b/src/addons/mod/feedback/services/handlers/push-click.ts new file mode 100644 index 000000000..e5119b79a --- /dev/null +++ b/src/addons/mod/feedback/services/handlers/push-click.ts @@ -0,0 +1,70 @@ +// (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 { CoreCourseHelper } from '@features/course/services/course-helper'; +import { CorePushNotificationsClickHandler } from '@features/pushnotifications/services/push-delegate'; +import { CorePushNotificationsNotificationBasicData } from '@features/pushnotifications/services/pushnotifications'; +import { CoreUrlUtils } from '@services/utils/url'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton } from '@singletons'; +import { AddonModFeedback } from '../feedback'; +import { AddonModFeedbackHelper } from '../feedback-helper'; + +/** + * Handler for feedback push notifications clicks. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModFeedbackPushClickHandlerService implements CorePushNotificationsClickHandler { + + name = 'AddonModFeedbackPushClickHandler'; + priority = 200; + featureName = 'CoreCourseModuleDelegate_AddonModFeedback'; + + /** + * @inheritdoc + */ + async handles(notification: CorePushNotificationsNotificationBasicData): Promise { + if (CoreUtils.isTrueOrOne(notification.notif) && notification.moodlecomponent == 'mod_feedback' && + (notification.name == 'submission' || notification.name == 'message')) { + + return AddonModFeedback.isPluginEnabled(notification.site); + } + + return false; + } + + /** + * @inheritdoc + */ + handleClick(notification: AddonModFeedbackPushNotificationData): Promise { + const contextUrlParams = CoreUrlUtils.extractUrlParams(notification.contexturl!); + const courseId = Number(notification.courseid); + const moduleId = Number(contextUrlParams.id); + + if (notification.name == 'submission') { + return AddonModFeedbackHelper.handleShowEntriesLink(contextUrlParams, notification.site); + } else { + return CoreCourseHelper.navigateToModule(moduleId, notification.site, courseId); + } + } + +} + +export const AddonModFeedbackPushClickHandler = makeSingleton(AddonModFeedbackPushClickHandlerService); + +type AddonModFeedbackPushNotificationData = CorePushNotificationsNotificationBasicData & { + contexturl?: string; + courseid?: number | string; +}; diff --git a/src/addons/mod/feedback/services/handlers/show-entries-link.ts b/src/addons/mod/feedback/services/handlers/show-entries-link.ts new file mode 100644 index 000000000..6f91981bc --- /dev/null +++ b/src/addons/mod/feedback/services/handlers/show-entries-link.ts @@ -0,0 +1,58 @@ +// (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 { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate'; +import { makeSingleton } from '@singletons'; +import { AddonModFeedback } from '../feedback'; +import { AddonModFeedbackHelper } from '../feedback-helper'; + +/** + * Content links handler for feedback show entries questions. + * Match mod/feedback/show_entries.php with a valid feedback id. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModFeedbackShowEntriesLinkHandlerService extends CoreContentLinksHandlerBase { + + name = 'AddonModFeedbackShowEntriesLinkHandler'; + featureName = 'CoreCourseModuleDelegate_AddonModFeedback'; + pattern = /\/mod\/feedback\/show_entries\.php.*([?&](id|showcompleted)=\d+)/; + + /** + * @inheritdoc + */ + getActions(siteIds: string[], url: string, params: Record): CoreContentLinksAction[] { + return [{ + action: (siteId: string) => { + AddonModFeedbackHelper.handleShowEntriesLink(params, siteId); + }, + }]; + } + + /** + * @inheritdoc + */ + async isEnabled(siteId: string, url: string, params: Record): Promise { + if (typeof params.id == 'undefined') { + // Cannot treat the URL. + return false; + } + + return AddonModFeedback.isPluginEnabled(siteId); + } + +} + +export const AddonModFeedbackShowEntriesLinkHandler = makeSingleton(AddonModFeedbackShowEntriesLinkHandlerService); diff --git a/src/addons/mod/feedback/services/handlers/show-non-respondents-link.ts b/src/addons/mod/feedback/services/handlers/show-non-respondents-link.ts new file mode 100644 index 000000000..c133301ec --- /dev/null +++ b/src/addons/mod/feedback/services/handlers/show-non-respondents-link.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 { Injectable } from '@angular/core'; +import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate'; +import { CoreCourse } from '@features/course/services/course'; +import { CoreNavigator } from '@services/navigator'; +import { CoreDomUtils } from '@services/utils/dom'; +import { makeSingleton } from '@singletons'; +import { AddonModFeedback } from '../feedback'; +import { AddonModFeedbackModuleHandlerService } from './module'; +/** + * Content links handler for feedback show non respondents. + * Match mod/feedback/show_nonrespondents.php with a valid feedback id. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModFeedbackShowNonRespondentsLinkHandlerService extends CoreContentLinksHandlerBase { + + name = 'AddonModFeedbackShowNonRespondentsLinkHandler'; + featureName = 'CoreCourseModuleDelegate_AddonModFeedback'; + pattern = /\/mod\/feedback\/show_nonrespondents\.php.*([?&](id)=\d+)/; + + /** + * @inheritdoc + */ + getActions(siteIds: string[], url: string, params: Record): CoreContentLinksAction[] { + return [{ + action: async (siteId: string) => { + const modal = await CoreDomUtils.showModalLoading(); + + const moduleId = Number(params.id); + + try { + const module = await CoreCourse.getModuleBasicInfo(moduleId, siteId); + + await CoreNavigator.navigateToSitePath( + AddonModFeedbackModuleHandlerService.PAGE_NAME + `/${module.course}/${module.id}/nonrespondents`, + { siteId }, + ); + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'Error opening link.'); + } finally { + modal.dismiss(); + } + }, + }]; + } + + /** + * @inheritdoc + */ + async isEnabled(siteId: string, url: string, params: Record): Promise { + if (typeof params.id == 'undefined') { + // Cannot treat the URL. + return false; + } + + return AddonModFeedback.isPluginEnabled(siteId); + } + +} + +export const AddonModFeedbackShowNonRespondentsLinkHandler = makeSingleton(AddonModFeedbackShowNonRespondentsLinkHandlerService); diff --git a/src/addons/mod/feedback/services/handlers/sync-cron.ts b/src/addons/mod/feedback/services/handlers/sync-cron.ts new file mode 100644 index 000000000..bf86e90d2 --- /dev/null +++ b/src/addons/mod/feedback/services/handlers/sync-cron.ts @@ -0,0 +1,51 @@ +// (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 { AddonModFeedbackSync } from '../feedback-sync'; + +/** + * Synchronization cron handler. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModFeedbackSyncCronHandlerService implements CoreCronHandler { + + name = 'AddonModFeedbackSyncCronHandler'; + + /** + * Execute the process. + * Receives the ID of the site affected, undefined for all sites. + * + * @param siteId ID of the site affected, undefined for all sites. + * @param force Wether the execution is forced (manual sync). + * @return Promise resolved when done, rejected if failure. + */ + execute(siteId?: string, force?: boolean): Promise { + return AddonModFeedbackSync.syncAllFeedbacks(siteId, force); + } + + /** + * Get the time between consecutive executions. + * + * @return Time between consecutive executions (in ms). + */ + getInterval(): number { + return AddonModFeedbackSync.syncInterval; + } + +} + +export const AddonModFeedbackSyncCronHandler = makeSingleton(AddonModFeedbackSyncCronHandlerService); diff --git a/src/addons/mod/mod.module.ts b/src/addons/mod/mod.module.ts index 8976cdff9..6561d2eda 100644 --- a/src/addons/mod/mod.module.ts +++ b/src/addons/mod/mod.module.ts @@ -34,6 +34,7 @@ import { AddonModChoiceModule } from './choice/choice.module'; import { AddonModWikiModule } from './wiki/wiki.module'; import { AddonModGlossaryModule } from './glossary/glossary.module'; import { AddonModChatModule } from './chat/chat.module'; +import { AddonModFeedbackModule } from './feedback/feedback.module'; @NgModule({ imports: [ @@ -57,6 +58,7 @@ import { AddonModChatModule } from './chat/chat.module'; AddonModWikiModule, AddonModGlossaryModule, AddonModChatModule, + AddonModFeedbackModule, ], }) export class AddonModModule { } diff --git a/src/core/features/compile/services/compile.ts b/src/core/features/compile/services/compile.ts index 86bc7dda4..108ae1e6c 100644 --- a/src/core/features/compile/services/compile.ts +++ b/src/core/features/compile/services/compile.ts @@ -127,7 +127,7 @@ import { ADDON_MOD_BOOK_SERVICES } from '@addons/mod/book/book.module'; import { ADDON_MOD_CHAT_SERVICES } from '@addons/mod/chat/chat.module'; import { ADDON_MOD_CHOICE_SERVICES } from '@addons/mod/choice/choice.module'; import { ADDON_MOD_DATA_SERVICES } from '@addons/mod/data/data.module'; -// @todo import { ADDON_MOD_FEEDBACK_SERVICES } from '@addons/mod/feedback/feedback.module'; +import { ADDON_MOD_FEEDBACK_SERVICES } from '@addons/mod/feedback/feedback.module'; import { ADDON_MOD_FOLDER_SERVICES } from '@addons/mod/folder/folder.module'; import { ADDON_MOD_FORUM_SERVICES } from '@addons/mod/forum/forum.module'; import { ADDON_MOD_GLOSSARY_SERVICES } from '@addons/mod/glossary/glossary.module'; @@ -293,7 +293,7 @@ export class CoreCompileProvider { ...ADDON_MOD_CHAT_SERVICES, ...ADDON_MOD_CHOICE_SERVICES, ...ADDON_MOD_DATA_SERVICES, - // @todo ...ADDON_MOD_FEEDBACK_SERVICES, + ...ADDON_MOD_FEEDBACK_SERVICES, ...ADDON_MOD_FOLDER_SERVICES, ...ADDON_MOD_FORUM_SERVICES, ...ADDON_MOD_GLOSSARY_SERVICES,