From 2991873dfec4d622d78ff86b50b52a2df57df9d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Thu, 18 Mar 2021 17:17:47 +0100 Subject: [PATCH] MOBILE-3640 database: Add database activity services --- src/addons/mod/assign/services/assign-sync.ts | 2 +- src/addons/mod/data/lang.json | 50 + .../mod/data/services/data-fields-delegate.ts | 267 +++ src/addons/mod/data/services/data-helper.ts | 790 +++++++++ src/addons/mod/data/services/data-offline.ts | 290 ++++ src/addons/mod/data/services/data-sync.ts | 499 ++++++ src/addons/mod/data/services/data.ts | 1460 +++++++++++++++++ src/addons/mod/data/services/database/data.ts | 83 + .../data/services/handlers/approve-link.ts | 63 + .../data/services/handlers/default-field.ts | 78 + .../mod/data/services/handlers/delete-link.ts | 61 + .../mod/data/services/handlers/edit-link.ts | 79 + .../mod/data/services/handlers/index-link.ts | 40 + .../mod/data/services/handlers/list-link.ts | 40 + .../mod/data/services/handlers/module.ts | 85 + .../mod/data/services/handlers/prefetch.ts | 300 ++++ .../mod/data/services/handlers/show-link.ts | 94 ++ .../mod/data/services/handlers/sync-cron.ts | 43 + .../mod/data/services/handlers/tag-area.ts | 53 + src/addons/mod/forum/services/forum-sync.ts | 4 +- src/addons/mod/mod.module.ts | 5 +- src/core/features/compile/services/compile.ts | 2 + .../rating/services/database/rating.ts | 2 +- .../rating/services/rating-offline.ts | 2 +- .../features/rating/services/rating-sync.ts | 9 +- src/core/services/filepool.ts | 2 +- src/core/services/sites.ts | 6 +- src/core/services/utils/utils.ts | 2 +- 28 files changed, 4396 insertions(+), 15 deletions(-) create mode 100644 src/addons/mod/data/lang.json create mode 100644 src/addons/mod/data/services/data-fields-delegate.ts create mode 100644 src/addons/mod/data/services/data-helper.ts create mode 100644 src/addons/mod/data/services/data-offline.ts create mode 100644 src/addons/mod/data/services/data-sync.ts create mode 100644 src/addons/mod/data/services/data.ts create mode 100644 src/addons/mod/data/services/database/data.ts create mode 100644 src/addons/mod/data/services/handlers/approve-link.ts create mode 100644 src/addons/mod/data/services/handlers/default-field.ts create mode 100644 src/addons/mod/data/services/handlers/delete-link.ts create mode 100644 src/addons/mod/data/services/handlers/edit-link.ts create mode 100644 src/addons/mod/data/services/handlers/index-link.ts create mode 100644 src/addons/mod/data/services/handlers/list-link.ts create mode 100644 src/addons/mod/data/services/handlers/module.ts create mode 100644 src/addons/mod/data/services/handlers/prefetch.ts create mode 100644 src/addons/mod/data/services/handlers/show-link.ts create mode 100644 src/addons/mod/data/services/handlers/sync-cron.ts create mode 100644 src/addons/mod/data/services/handlers/tag-area.ts diff --git a/src/addons/mod/assign/services/assign-sync.ts b/src/addons/mod/assign/services/assign-sync.ts index 14e91e5a7..2ea3fd466 100644 --- a/src/addons/mod/assign/services/assign-sync.ts +++ b/src/addons/mod/assign/services/assign-sync.ts @@ -186,7 +186,7 @@ export class AddonModAssignSyncProvider extends CoreCourseActivitySyncBaseProvid * Perform the assign submission. * * @param assignId Assign ID. - * @param siteId Site ID. If not defined, current site. + * @param siteId Site ID. * @return Promise resolved in success. */ protected async performSyncAssign(assignId: number, siteId: string): Promise { diff --git a/src/addons/mod/data/lang.json b/src/addons/mod/data/lang.json new file mode 100644 index 000000000..920d6f014 --- /dev/null +++ b/src/addons/mod/data/lang.json @@ -0,0 +1,50 @@ +{ + "addentries": "Add entries", + "advancedsearch": "Advanced search", + "alttext": "Alternative text", + "approve": "Approve", + "approved": "Approved", + "ascending": "Ascending", + "authorfirstname": "Author first name", + "authorlastname": "Author surname", + "confirmdeleterecord": "Are you sure you want to delete this entry?", + "descending": "Descending", + "disapprove": "Undo approval", + "edittagsnotsupported": "Sorry, editing tags is not supported by the app.", + "emptyaddform": "You did not fill out any fields!", + "entrieslefttoadd": "You must add {{$a.entriesleft}} more entry/entries in order to complete this activity", + "entrieslefttoaddtoview": "You must add {{$a.entrieslefttoview}} more entry/entries before you can view other participants' entries.", + "errorapproving": "Error approving or unapproving entry.", + "errordeleting": "Error deleting entry.", + "errormustsupplyvalue": "You must supply a value here.", + "expired": "Sorry, this activity closed on {{$a}} and is no longer available", + "fields": "Fields", + "foundrecords": "Found records: {{$a.num}}/{{$a.max}} (Reset filters)", + "gettinglocation": "Getting location", + "latlongboth": "Both latitude and longitude are required.", + "locationpermissiondenied": "Permission to access your location has been denied.", + "locationnotenabled": "Location is not enabled", + "menuchoose": "Choose...", + "modulenameplural": "Databases", + "more": "More", + "mylocation": "My location", + "noaccess": "You do not have access to this page", + "nomatch": "No matching entries found!", + "norecords": "No entries in database", + "notapproved": "Entry is not approved yet.", + "notopenyet": "Sorry, this activity is not available until {{$a}}", + "numrecords": "{{$a}} entries", + "other": "Other", + "recordapproved": "Entry approved", + "recorddeleted": "Entry deleted", + "recorddisapproved": "Entry unapproved", + "resetsettings": "Reset filters", + "search": "Search", + "searchbytagsnotsupported": "Sorry, searching by tags is not supported by the app.", + "selectedrequired": "All selected required", + "single": "View single", + "tagarea_data_records": "Data records", + "timeadded": "Time added", + "timemodified": "Time modified", + "usedate": "Include in search." +} \ No newline at end of file diff --git a/src/addons/mod/data/services/data-fields-delegate.ts b/src/addons/mod/data/services/data-fields-delegate.ts new file mode 100644 index 000000000..0c79e29a0 --- /dev/null +++ b/src/addons/mod/data/services/data-fields-delegate.ts @@ -0,0 +1,267 @@ +// (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, Type } from '@angular/core'; +import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate'; +import { AddonModDataDefaultFieldHandler } from './handlers/default-field'; +import { makeSingleton } from '@singletons'; +import { AddonModDataEntryField, + AddonModDataField, + AddonModDataSearchEntriesAdvancedFieldFormatted, + AddonModDataSubfieldData, +} from './data'; +import { CoreFormFields } from '@singletons/form'; +import { CoreWSExternalFile } from '@services/ws'; +import { FileEntry } from '@ionic-native/file'; + +/** + * Interface that all fields handlers must implement. + */ +export interface AddonModDataFieldHandler extends CoreDelegateHandler { + + /** + * Name of the type of data field the handler supports. E.g. 'checkbox'. + */ + type: string; + + /** + * Return the Component to use to display the plugin data. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @param field The field object. + * @return The component to use, undefined if not found. + */ + getComponent?(plugin: AddonModDataField): Type | undefined; + + /** + * Get field search data in the input data. + * + * @param field Defines the field to be rendered. + * @param inputData Data entered in the search form. + * @return With name and value of the data to be sent. + */ + getFieldSearchData?( + field: AddonModDataField, + inputData: CoreFormFields, + ): AddonModDataSearchEntriesAdvancedFieldFormatted[]; + + /** + * Get field edit data in the input data. + * + * @param field Defines the field to be rendered. + * @param inputData Data entered in the edit form. + * @return With name and value of the data to be sent. + */ + getFieldEditData?( + field: AddonModDataField, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + inputData: CoreFormFields, + originalFieldData: AddonModDataEntryField, + ): AddonModDataSubfieldData[]; + + /** + * Get field data in changed. + * + * @param field Defines the field to be rendered. + * @param inputData Data entered in the edit form. + * @param originalFieldData Original field entered data. + * @return If the field has changes. + */ + hasFieldDataChanged?( + field: AddonModDataField, + inputData: CoreFormFields, + originalFieldData: AddonModDataEntryField, + ): boolean; + + /** + * Get field edit files in the input data. + * + * @param field Defines the field.. + * @return With name and value of the data to be sent. + */ + getFieldEditFiles?( + field: AddonModDataField, + inputData: CoreFormFields, + originalFieldData: AddonModDataEntryField, + ): (CoreWSExternalFile | FileEntry)[]; + + /** + * Check and get field requeriments. + * + * @param field Defines the field to be rendered. + * @param inputData Data entered in the edit form. + * @return String with the notification or false. + */ + getFieldsNotifications?(field: AddonModDataField, inputData: AddonModDataSubfieldData[]): string | undefined; + + /** + * Override field content data with offline submission. + * + * @param originalContent Original data to be overriden. + * @param offlineContent Array with all the offline data to override. + * @param offlineFiles Array with all the offline files in the field. + * @return Data overriden + */ + overrideData?( + originalContent: AddonModDataEntryField, + offlineContent: CoreFormFields, + offlineFiles?: FileEntry[], + ): AddonModDataEntryField; +} + +/** + * Delegate to register database fields handlers. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModDataFieldsDelegateService extends CoreDelegate { + + protected handlerNameProperty = 'type'; + + constructor( + protected defaultHandler: AddonModDataDefaultFieldHandler, + ) { + super('AddonModDataFieldsDelegate', true); + } + + /** + * Get the component to use for a certain field field. + * + * @param field The field object. + * @return Promise resolved with the component to use, undefined if not found. + */ + getComponentForField(field: AddonModDataField): Promise | undefined> { + return Promise.resolve(this.executeFunctionOnEnabled(field.type, 'getComponent', [field])); + } + + /** + * Get database data in the input data to search. + * + * @param field Defines the field to be rendered. + * @param inputData Data entered in the search form. + * @return Name and data field. + */ + getFieldSearchData(field: AddonModDataField, inputData: CoreFormFields): AddonModDataSearchEntriesAdvancedFieldFormatted[] { + return this.executeFunctionOnEnabled(field.type, 'getFieldSearchData', [field, inputData]) || []; + } + + /** + * Get database data in the input data to add or update entry. + * + * @param field Defines the field to be rendered. + * @param inputData Data entered in the search form. + * @param originalFieldData Original field entered data. + * @return Name and data field. + */ + getFieldEditData( + field: AddonModDataField, + inputData: CoreFormFields, + originalFieldData: AddonModDataEntryField, + ): AddonModDataSubfieldData[] { + return this.executeFunctionOnEnabled(field.type, 'getFieldEditData', [field, inputData, originalFieldData]) || []; + } + + /** + * Get database data in the input files to add or update entry. + * + * @param field Defines the field to be rendered. + * @param inputData Data entered in the search form. + * @param originalFieldData Original field entered data. + * @return Name and data field. + */ + getFieldEditFiles( + field: AddonModDataField, + inputData: CoreFormFields, + originalFieldData: CoreFormFields, + ): (CoreWSExternalFile | FileEntry)[] { + return this.executeFunctionOnEnabled(field.type, 'getFieldEditFiles', [field, inputData, originalFieldData]) || []; + } + + /** + * Check and get field requeriments. + * + * @param field Defines the field to be rendered. + * @param inputData Data entered in the edit form. + * @return String with the notification or false. + */ + getFieldsNotifications(field: AddonModDataField, inputData: AddonModDataSubfieldData[]): string | undefined { + return this.executeFunctionOnEnabled(field.type, 'getFieldsNotifications', [field, inputData]); + } + + /** + * Check if field type manage files or not. + * + * @param field Defines the field to be checked. + * @return If the field type manages files. + */ + hasFiles(field: AddonModDataField): boolean { + return this.hasFunction(field.type, 'getFieldEditFiles'); + } + + /** + * Check if the data has changed for a certain field. + * + * @param field Defines the field to be rendered. + * @param inputData Data entered in the search form. + * @param originalFieldData Original field entered data. + * @return If the field has changes. + */ + hasFieldDataChanged( + field: AddonModDataField, + inputData: CoreFormFields, + originalFieldData: CoreFormFields, + ): boolean { + return !!this.executeFunctionOnEnabled( + field.type, + 'hasFieldDataChanged', + [field, inputData, originalFieldData], + ); + } + + /** + * Check if a field plugin is supported. + * + * @param pluginType Type of the plugin. + * @return True if supported, false otherwise. + */ + isPluginSupported(pluginType: string): boolean { + return this.hasHandler(pluginType, true); + } + + /** + * Override field content data with offline submission. + * + * @param field Defines the field to be rendered. + * @param originalContent Original data to be overriden. + * @param offlineContent Array with all the offline data to override. + * @param offlineFiles Array with all the offline files in the field. + * @return Data overriden + */ + overrideData( + field: AddonModDataField, + originalContent: AddonModDataEntryField, + offlineContent: CoreFormFields, + offlineFiles?: FileEntry[], + ): AddonModDataEntryField { + originalContent = originalContent || {}; + + if (!offlineContent) { + return originalContent; + } + + return this.executeFunctionOnEnabled(field.type, 'overrideData', [originalContent, offlineContent, offlineFiles]) || + originalContent; + } + +} +export const AddonModDataFieldsDelegate = makeSingleton(AddonModDataFieldsDelegateService); diff --git a/src/addons/mod/data/services/data-helper.ts b/src/addons/mod/data/services/data-helper.ts new file mode 100644 index 000000000..b2a1f4c3b --- /dev/null +++ b/src/addons/mod/data/services/data-helper.ts @@ -0,0 +1,790 @@ +// (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 { ContextLevel } from '@/core/constants'; +import { Injectable } from '@angular/core'; +import { CoreCourse } from '@features/course/services/course'; +import { CoreFileUploader, CoreFileUploaderStoreFilesResult } from '@features/fileuploader/services/fileuploader'; +import { CoreRatingOffline } from '@features/rating/services/rating-offline'; +import { FileEntry } from '@ionic-native/file'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreFormFields } from '@singletons/form'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreWSExternalFile } from '@services/ws'; +import { makeSingleton, Translate } from '@singletons'; +import { CoreEvents } from '@singletons/events'; +import { + AddonModDataEntry, + AddonModData, + AddonModDataProvider, + AddonModDataSearchEntriesOptions, + AddonModDataEntries, + AddonModDataEntryFields, + AddonModDataAction, + AddonModDataGetEntryFormatted, + AddonModDataData, + AddonModDataTemplateType, + AddonModDataGetDataAccessInformationWSResponse, + AddonModDataTemplateMode, + AddonModDataField, + AddonModDataEntryWSField, +} from './data'; +import { AddonModDataFieldsDelegate } from './data-fields-delegate'; +import { AddonModDataOffline, AddonModDataOfflineAction } from './data-offline'; + +/** + * Service that provides helper functions for datas. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModDataHelperProvider { + + /** + * Returns the record with the offline actions applied. + * + * @param record Entry to modify. + * @param offlineActions Offline data with the actions done. + * @param fields Entry defined fields indexed by fieldid. + * @return Promise resolved when done. + */ + protected async applyOfflineActions( + record: AddonModDataEntry, + offlineActions: AddonModDataOfflineAction[], + fields: AddonModDataField[], + ): Promise { + const promises: Promise[] = []; + + offlineActions.forEach((action) => { + record.timemodified = action.timemodified; + record.hasOffline = true; + const offlineContents: Record = {}; + + switch (action.action) { + case AddonModDataAction.APPROVE: + record.approved = true; + break; + case AddonModDataAction.DISAPPROVE: + record.approved = false; + break; + case AddonModDataAction.DELETE: + record.deleted = true; + break; + case AddonModDataAction.ADD: + case AddonModDataAction.EDIT: + record.groupid = action.groupid; + + action.fields.forEach((offlineContent) => { + if (typeof offlineContents[offlineContent.fieldid] == 'undefined') { + offlineContents[offlineContent.fieldid] = {}; + } + + if (offlineContent.subfield) { + offlineContents[offlineContent.fieldid][offlineContent.subfield] = + CoreTextUtils.parseJSON(offlineContent.value); + } else { + offlineContents[offlineContent.fieldid][''] = CoreTextUtils.parseJSON(offlineContent.value); + } + }); + + // Override field contents. + fields.forEach((field) => { + if (AddonModDataFieldsDelegate.hasFiles(field)) { + promises.push(this.getStoredFiles(record.dataid, record.id, field.id).then((offlineFiles) => { + record.contents[field.id] = AddonModDataFieldsDelegate.overrideData( + field, + record.contents[field.id], + offlineContents[field.id], + offlineFiles, + ); + record.contents[field.id].fieldid = field.id; + + return; + })); + } else { + record.contents[field.id] = AddonModDataFieldsDelegate.overrideData( + field, + record.contents[field.id], + offlineContents[field.id], + ); + record.contents[field.id].fieldid = field.id; + } + }); + break; + default: + break; + } + }); + + await Promise.all(promises); + + return record; + } + + /** + * Approve or disapprove a database entry. + * + * @param dataId Database ID. + * @param entryId Entry ID. + * @param approve True to approve, false to disapprove. + * @param courseId Course ID. It not defined, it will be fetched. + * @param siteId Site ID. If not defined, current site. + */ + async approveOrDisapproveEntry( + dataId: number, + entryId: number, + approve: boolean, + courseId?: number, + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + const modal = await CoreDomUtils.showModalLoading('core.sending', true); + + try { + courseId = await this.getActivityCourseIdIfNotSet(dataId, courseId, siteId); + + try { + // Approve/disapprove entry. + await AddonModData.approveEntry(dataId, entryId, approve, courseId, siteId); + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'addon.mod_data.errorapproving', true); + + throw error; + } + + const promises: Promise[] = []; + + promises.push(AddonModData.invalidateEntryData(dataId, entryId, siteId)); + promises.push(AddonModData.invalidateEntriesData(dataId, siteId)); + + await CoreUtils.ignoreErrors(Promise.all(promises)); + + CoreEvents.trigger(AddonModDataProvider.ENTRY_CHANGED, { dataId: dataId, entryId: entryId }, siteId); + + CoreDomUtils.showToast(approve ? 'addon.mod_data.recordapproved' : 'addon.mod_data.recorddisapproved', true, 3000); + } catch { + // Ignore error, it was already displayed. + } finally { + modal.dismiss(); + } + } + + /** + * Displays fields for being shown. + * + * @param template Template HMTL. + * @param fields Fields that defines every content in the entry. + * @param entry Entry. + * @param offset Entry offset. + * @param mode Mode list or show. + * @param actions Actions that can be performed to the record. + * @return Generated HTML. + */ + displayShowFields( + template: string, + fields: AddonModDataField[], + entry: AddonModDataEntry, + offset = 0, + mode: AddonModDataTemplateMode, + actions: Record, + ): string { + + if (!template) { + return ''; + } + + // Replace the fields found on template. + fields.forEach((field) => { + let replace = '[[' + field.name + ']]'; + replace = replace.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&'); + const replaceRegex = new RegExp(replace, 'gi'); + + // Replace field by a generic directive. + const render = ''; + template = template.replace(replaceRegex, render); + }); + + for (const action in actions) { + const replaceRegex = new RegExp('##' + action + '##', 'gi'); + // Is enabled? + if (actions[action]) { + let render = ''; + if (action == AddonModDataAction.MOREURL) { + // Render more url directly because it can be part of an HTML attribute. + render = CoreSites.getCurrentSite()!.getURL() + '/mod/data/view.php?d={{database.id}}&rid=' + entry.id; + } else if (action == 'approvalstatus') { + render = Translate.instant('addon.mod_data.' + (entry.approved ? 'approved' : 'notapproved')); + } else { + render = ''; + } + template = template.replace(replaceRegex, render); + } else { + template = template.replace(replaceRegex, ''); + } + } + + return template; + } + + /** + * Get online and offline entries, or search entries. + * + * @param database Database object. + * @param fields The fields that define the contents. + * @param options Other options. + * @return Promise resolved when the database is retrieved. + */ + async fetchEntries( + database: AddonModDataData, + fields: AddonModDataField[], + options: AddonModDataSearchEntriesOptions = {}, + ): Promise { + const site = await CoreSites.getSite(options.siteId); + options.groupId = options.groupId || 0; + options.page = options.page || 0; + + const offlineActions: Record = {}; + const result: AddonModDataEntries = { + entries: [], + totalcount: 0, + offlineEntries: [], + }; + options.siteId = site.id; + + const offlinePromise = AddonModDataOffline.getDatabaseEntries(database.id, site.id).then((actions) => { + result.hasOfflineActions = !!actions.length; + + actions.forEach((action) => { + if (typeof offlineActions[action.entryid] == 'undefined') { + offlineActions[action.entryid] = []; + } + offlineActions[action.entryid].push(action); + + // We only display new entries in the first page when not searching. + if (action.action == AddonModDataAction.ADD && options.page == 0 && !options.search && !options.advSearch && + (!action.groupid || !options.groupId || action.groupid == options.groupId)) { + result.offlineEntries!.push({ + id: action.entryid, + canmanageentry: true, + approved: !database.approval || database.manageapproved, + dataid: database.id, + groupid: action.groupid, + timecreated: -action.entryid, + timemodified: -action.entryid, + userid: site.getUserId(), + fullname: site.getInfo()?.fullname, + contents: {}, + }); + } + + }); + + // Sort offline entries by creation time. + result.offlineEntries!.sort((a, b) => b.timecreated - a.timecreated); + + return; + }); + + const ratingsPromise = CoreRatingOffline.hasRatings('mod_data', 'entry', ContextLevel.MODULE, database.coursemodule) + .then((hasRatings) => { + result.hasOfflineRatings = hasRatings; + + return; + }); + + let fetchPromise: Promise; + if (options.search || options.advSearch) { + fetchPromise = AddonModData.searchEntries(database.id, options).then((searchResult) => { + result.entries = searchResult.entries; + result.totalcount = searchResult.totalcount; + result.maxcount = searchResult.maxcount; + + return; + }); + } else { + fetchPromise = AddonModData.getEntries(database.id, options).then((entriesResult) => { + result.entries = entriesResult.entries; + result.totalcount = entriesResult.totalcount; + + return; + }); + } + await Promise.all([offlinePromise, ratingsPromise, fetchPromise]); + + // Apply offline actions to online and offline entries. + const promises: Promise[] = []; + result.entries.forEach((entry) => { + promises.push(this.applyOfflineActions(entry, offlineActions[entry.id] || [], fields)); + }); + + result.offlineEntries!.forEach((entry) => { + promises.push(this.applyOfflineActions(entry, offlineActions[entry.id] || [], fields)); + }); + + await Promise.all(promises); + + return result; + } + + /** + * Fetch an online or offline entry. + * + * @param database Database. + * @param fields List of database fields. + * @param entryId Entry ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the entry. + */ + async fetchEntry( + database: AddonModDataData, + fields: AddonModDataField[], + entryId: number, + siteId?: string, + ): Promise { + const site = await CoreSites.getSite(siteId); + + const offlineActions = await AddonModDataOffline.getEntryActions(database.id, entryId, site.id); + + let response: AddonModDataGetEntryFormatted; + if (entryId > 0) { + // Online entry. + response = await AddonModData.getEntry(database.id, entryId, { cmId: database.coursemodule, siteId: site.id }); + } else { + // Offline entry or new entry. + response = { + entry: { + id: entryId, + userid: site.getUserId(), + groupid: 0, + dataid: database.id, + timecreated: -entryId, + timemodified: -entryId, + approved: !database.approval || database.manageapproved, + canmanageentry: true, + fullname: site.getInfo()?.fullname, + contents: {}, + }, + }; + } + + await this.applyOfflineActions(response.entry, offlineActions, fields); + + return response; + } + + /** + * Returns an object with all the actions that the user can do over the record. + * + * @param database Database activity. + * @param accessInfo Access info to the activity. + * @param entry Entry or record where the actions will be performed. + * @return Keyed with the action names and boolean to evalute if it can or cannot be done. + */ + getActions( + database: AddonModDataData, + accessInfo: AddonModDataGetDataAccessInformationWSResponse, + entry: AddonModDataEntry, + ): Record { + return { + add: false, // Not directly used on entries. + more: true, + moreurl: true, + user: true, + userpicture: true, + timeadded: true, + timemodified: true, + tags: true, + + edit: entry.canmanageentry && !entry.deleted, // This already checks capabilities and readonly period. + delete: entry.canmanageentry, + approve: database.approval && accessInfo.canapprove && !entry.approved && !entry.deleted, + disapprove: database.approval && accessInfo.canapprove && entry.approved && !entry.deleted, + + approvalstatus: database.approval, + comments: database.comments, + + // Unsupported actions. + delcheck: false, + export: false, + }; + } + + /** + * Convenience function to get the course id of the database. + * + * @param dataId Database id. + * @param courseId Course id, if known. + * @param siteId Site id, if not set, current site will be used. + * @return Resolved with course Id when done. + */ + protected async getActivityCourseIdIfNotSet(dataId: number, courseId?: number, siteId?: string): Promise { + if (courseId) { + return courseId; + } + + const module = await CoreCourse.getModuleBasicInfoByInstance(dataId, 'data', siteId); + + return module.course; + } + + /** + * Returns the default template of a certain type. + * + * Based on Moodle function data_generate_default_template. + * + * @param type Type of template. + * @param fields List of database fields. + * @return Template HTML. + */ + getDefaultTemplate(type: AddonModDataTemplateType, fields: AddonModDataField[]): string { + if (type == AddonModDataTemplateType.LIST_HEADER || type == AddonModDataTemplateType.LIST_FOOTER) { + return ''; + } + + const html: string[] = []; + + if (type == AddonModDataTemplateType.LIST) { + html.push('##delcheck##
'); + } + + html.push( + '
', + '', + '', + ); + + fields.forEach((field) => { + html.push( + '', + '', + '', + '', + ); + }); + + if (type == AddonModDataTemplateType.LIST) { + html.push( + '', + '', + '', + ); + } else if (type == AddonModDataTemplateType.SINGLE) { + html.push( + '', + '', + '', + ); + } else if (type == AddonModDataTemplateType.SEARCH) { + html.push( + '', + '', + '', + '', + '', + '', + '', + '', + ); + } + + html.push( + '', + '
', + field.name, + ': [[', + field.name, + ']]
', + '##edit## ##more## ##delete## ##approve## ##disapprove## ##export##', + '
', + '##edit## ##delete## ##approve## ##disapprove## ##export##', + '
Author first name: ##firstname##
Author surname: ##lastname##
', + '
', + ); + + if (type == AddonModDataTemplateType.LIST) { + html.push('
'); + } + + return html.join(''); + } + + /** + * Retrieve the entered data in the edit form. + * We don't use ng-model because it doesn't detect changes done by JavaScript. + * + * @param inputData Array with the entered form values. + * @param fields Fields that defines every content in the entry. + * @param dataId Database Id. If set, files will be uploaded and itemId set. + * @param entryId Entry Id. + * @param entryContents Original entry contents. + * @param offline True to prepare the data for an offline uploading, false otherwise. + * @param siteId Site ID. If not defined, current site. + * @return That contains object with the answers. + */ + async getEditDataFromForm( + inputData: CoreFormFields, + fields: AddonModDataField[], + dataId: number, + entryId: number, + entryContents: AddonModDataEntryFields, + offline: boolean = false, + siteId?: string, + ): Promise { + if (!inputData) { + return []; + } + + siteId = siteId || CoreSites.getCurrentSiteId(); + + // Filter and translate fields to each field plugin. + const entryFieldDataToSend: AddonModDataEntryWSField[] = []; + + const promises = fields.map(async (field) => { + const fieldData = AddonModDataFieldsDelegate.getFieldEditData(field, inputData, entryContents[field.id]); + if (!fieldData) { + return; + } + const proms = fieldData.map(async (fieldSubdata) => { + let value = fieldSubdata.value; + + // Upload Files if asked. + if (dataId && fieldSubdata.files) { + value = await this.uploadOrStoreFiles( + dataId, + 0, + entryId, + fieldSubdata.fieldid, + fieldSubdata.files, + offline, + siteId, + ); + } + + // WS wants values in JSON format. + entryFieldDataToSend.push({ + fieldid: fieldSubdata.fieldid, + subfield: fieldSubdata.subfield || '', + value: fieldSubdata.value ? JSON.stringify(value) : '', + }); + + return; + }); + + await Promise.all(proms); + }); + + await Promise.all(promises); + + return entryFieldDataToSend; + } + + /** + * Retrieve the temp files to be updated. + * + * @param inputData Array with the entered form values. + * @param fields Fields that defines every content in the entry. + * @param entryContents Original entry contents indexed by field id. + * @return That contains object with the files. + */ + async getEditTmpFiles( + inputData: CoreFormFields, + fields: AddonModDataField[], + entryContents: AddonModDataEntryFields, + ): Promise<(CoreWSExternalFile | FileEntry)[]> { + if (!inputData) { + return []; + } + + // Filter and translate fields to each field plugin. + const promises = fields.map((field) => + AddonModDataFieldsDelegate.getFieldEditFiles(field, inputData, entryContents[field.id])); + + const fieldsFiles = await Promise.all(promises); + + return fieldsFiles.reduce((files, fieldFiles) => files.concat(fieldFiles), []); + } + + /** + * Get a list of stored attachment files for a new entry. See $mmaModDataHelper#storeFiles. + * + * @param dataId Database ID. + * @param entryId Entry ID or, if creating, timemodified. + * @param fieldId Field ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the files. + */ + async getStoredFiles(dataId: number, entryId: number, fieldId: number, siteId?: string): Promise { + const folderPath = await AddonModDataOffline.getEntryFieldFolder(dataId, entryId, fieldId, siteId); + + try { + return CoreFileUploader.getStoredFiles(folderPath); + } catch { + // Ignore not found files. + return []; + } + } + + /** + * Returns the template of a certain type. + * + * @param data Database object. + * @param type Type of template. + * @param fields List of database fields. + * @return Template HTML. + */ + getTemplate(data: AddonModDataData, type: AddonModDataTemplateType, fields: AddonModDataField[]): string { + let template = data[type] || this.getDefaultTemplate(type, fields); + + if (type != AddonModDataTemplateType.LIST_HEADER && type != AddonModDataTemplateType.LIST_FOOTER) { + // Try to fix syntax errors so the template can be parsed by Angular. + template = CoreDomUtils.fixHtml(template); + } + + // Add core-link directive to links. + template = template.replace( + /]*href="[^>]*)>/ig, + (match, attributes) => '', + ); + + return template; + } + + /** + * Check if data has been changed by the user. + * + * @param inputData Object with the entered form values. + * @param fields Fields that defines every content in the entry. + * @param dataId Database Id. If set, fils will be uploaded and itemId set. + * @param entryContents Original entry contents indexed by field id. + * @return True if changed, false if not. + */ + hasEditDataChanged( + inputData: CoreFormFields, + fields: AddonModDataField[], + entryContents: AddonModDataEntryFields, + ): boolean { + return fields.some((field) => + AddonModDataFieldsDelegate.hasFieldDataChanged(field, inputData, entryContents[field.id])); + } + + /** + * Displays a confirmation modal for deleting an entry. + * + * @param dataId Database ID. + * @param entryId Entry ID. + * @param courseId Course ID. It not defined, it will be fetched. + * @param siteId Site ID. If not defined, current site. + */ + async showDeleteEntryModal(dataId: number, entryId: number, courseId?: number, siteId?: string): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + try { + await CoreDomUtils.showDeleteConfirm('addon.mod_data.confirmdeleterecord'); + + const modal = await CoreDomUtils.showModalLoading(); + + try { + if (entryId > 0) { + courseId = await this.getActivityCourseIdIfNotSet(dataId, courseId, siteId); + } + + AddonModData.deleteEntry(dataId, entryId, courseId!, siteId); + } catch (message) { + CoreDomUtils.showErrorModalDefault(message, 'addon.mod_data.errordeleting', true); + + modal.dismiss(); + + return; + } + + try { + await AddonModData.invalidateEntryData(dataId, entryId, siteId); + await AddonModData.invalidateEntriesData(dataId, siteId); + } catch (error) { + // Ignore errors. + } + + CoreEvents.trigger(AddonModDataProvider.ENTRY_CHANGED, { dataId, entryId, deleted: true }, siteId); + + CoreDomUtils.showToast('addon.mod_data.recorddeleted', true, 3000); + + modal.dismiss(); + } catch { + // Ignore error, it was already displayed. + } + + } + + /** + * Given a list of files (either online files or local files), store the local files in a local folder + * to be submitted later. + * + * @param dataId Database ID. + * @param entryId Entry ID or, if creating, timemodified. + * @param fieldId Field ID. + * @param files List of files. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if success, rejected otherwise. + */ + async storeFiles( + dataId: number, + entryId: number, + fieldId: number, + files: (CoreWSExternalFile | FileEntry)[], + siteId?: string, + ): Promise { + // Get the folder where to store the files. + const folderPath = await AddonModDataOffline.getEntryFieldFolder(dataId, entryId, fieldId, siteId); + + return CoreFileUploader.storeFilesToUpload(folderPath, files); + } + + /** + * Upload or store some files, depending if the user is offline or not. + * + * @param dataId Database ID. + * @param itemId Draft ID to use. Undefined or 0 to create a new draft ID. + * @param entryId Entry ID or, if creating, timemodified. + * @param fieldId Field ID. + * @param files List of files. + * @param offline True if files sould be stored for offline, false to upload them. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the itemId for the uploaded file/s. + */ + async uploadOrStoreFiles( + dataId: number, + itemId: number = 0, + entryId: number, + fieldId: number, + files: (CoreWSExternalFile | FileEntry)[], + offline: boolean, + siteId?: string, + ): Promise { + if (!files.length) { + return 0; + } + + if (offline) { + return this.storeFiles(dataId, entryId, fieldId, files, siteId); + } + + return CoreFileUploader.uploadOrReuploadFiles(files, AddonModDataProvider.COMPONENT, itemId, siteId); + } + +} +export const AddonModDataHelper = makeSingleton(AddonModDataHelperProvider); diff --git a/src/addons/mod/data/services/data-offline.ts b/src/addons/mod/data/services/data-offline.ts new file mode 100644 index 000000000..ffc317d4d --- /dev/null +++ b/src/addons/mod/data/services/data-offline.ts @@ -0,0 +1,290 @@ +// (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 { CoreFileUploader, CoreFileUploaderStoreFilesResult } from '@features/fileuploader/services/fileuploader'; +import { CoreFile } from '@services/file'; +import { CoreSites } from '@services/sites'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton } from '@singletons'; +import { AddonModDataAction, AddonModDataEntryWSField } from './data'; +import { AddonModDataEntryDBRecord, DATA_ENTRY_TABLE } from './database/data'; + +/** + * Service to handle Offline data. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModDataOfflineProvider { + + /** + * Delete all the actions of an entry. + * + * @param dataId Database ID. + * @param entryId Database entry ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if deleted, rejected if failure. + */ + async deleteAllEntryActions(dataId: number, entryId: number, siteId?: string): Promise { + const actions = await this.getEntryActions(dataId, entryId, siteId); + + const promises = actions.map((action) => { + this.deleteEntry(dataId, entryId, action.action, siteId); + }); + + await Promise.all(promises); + } + + /** + * Delete an stored entry. + * + * @param dataId Database ID. + * @param entryId Database entry Id. + * @param action Action to be done + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if deleted, rejected if failure. + */ + async deleteEntry(dataId: number, entryId: number, action: AddonModDataAction, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await this.deleteEntryFiles(dataId, entryId, action, site.id); + + await site.getDb().deleteRecords(DATA_ENTRY_TABLE, { + dataid: dataId, + entryid: entryId, + action, + }); + } + + /** + * Delete entry offline files. + * + * @param dataId Database ID. + * @param entryId Database entry ID. + * @param action Action to be done. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if deleted, rejected if failure. + */ + protected async deleteEntryFiles(dataId: number, entryId: number, action: AddonModDataAction, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + const entry = await CoreUtils.ignoreErrors(this.getEntry(dataId, entryId, action, site.id)); + + if (!entry || !entry.fields) { + // Entry not found or no fields, ignore. + return; + } + + const promises: Promise[] = []; + + entry.fields.forEach((field) => { + const value = CoreTextUtils.parseJSON(field.value); + + if (!value.offline) { + return; + } + + const promise = this.getEntryFieldFolder(dataId, entryId, field.fieldid, site.id).then((folderPath) => + CoreFileUploader.getStoredFiles(folderPath)).then((files) => + CoreFileUploader.clearTmpFiles(files)).catch(() => { // Files not found, ignore. + }); + + promises.push(promise); + }); + + await Promise.all(promises); + } + + /** + * Get all the stored entry data from all the databases. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with entries. + */ + async getAllEntries(siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + const entries = await site.getDb().getAllRecords(DATA_ENTRY_TABLE); + + return entries.map(this.parseRecord.bind(this)); + } + + /** + * Get all the stored entry actions from a certain database, sorted by modification time. + * + * @param dataId Database ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with entries. + */ + async getDatabaseEntries(dataId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + const entries = await site.getDb().getRecords( + DATA_ENTRY_TABLE, + { dataid: dataId }, + 'timemodified', + ); + + return entries.map(this.parseRecord.bind(this)); + } + + /** + * Get an stored entry data. + * + * @param dataId Database ID. + * @param entryId Database entry Id. + * @param action Action to be done + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with entry. + */ + async getEntry( + dataId: number, + entryId: number, + action: AddonModDataAction, + siteId?: string, + ): Promise { + const site = await CoreSites.getSite(siteId); + + const entry = await site.getDb().getRecord(DATA_ENTRY_TABLE, { + dataid: dataId, entryid: entryId, + action, + }); + + return this.parseRecord(entry); + } + + /** + * Get an all stored entry actions data. + * + * @param dataId Database ID. + * @param entryId Database entry Id. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with entry actions. + */ + async getEntryActions(dataId: number, entryId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + const entries = await site.getDb().getRecords( + DATA_ENTRY_TABLE, + { dataid: dataId, entryid: entryId }, + ); + + return entries.map(this.parseRecord.bind(this)); + } + + /** + * Check if there are offline entries to send. + * + * @param dataId Database ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: true if has offline answers, false otherwise. + */ + async hasOfflineData(dataId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + return CoreUtils.promiseWorks( + site.getDb().recordExists(DATA_ENTRY_TABLE, { dataid: dataId }), + ); + } + + /** + * Get the path to the folder where to store files for offline files in a database. + * + * @param dataId Database ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the path. + */ + protected async getDatabaseFolder(dataId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + const siteFolderPath = CoreFile.getSiteFolder(site.getId()); + const folderPath = 'offlinedatabase/' + dataId; + + return CoreTextUtils.concatenatePaths(siteFolderPath, folderPath); + } + + /** + * Get the path to the folder where to store files for a new offline entry. + * + * @param dataId Database ID. + * @param entryId The ID of the entry. + * @param fieldId Field ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the path. + */ + async getEntryFieldFolder(dataId: number, entryId: number, fieldId: number, siteId?: string): Promise { + const folderPath = await this.getDatabaseFolder(dataId, siteId); + + return CoreTextUtils.concatenatePaths(folderPath, entryId + '_' + fieldId); + } + + /** + * Parse "fields" of an offline record. + * + * @param record Record object + * @return Record object with columns parsed. + */ + protected parseRecord(record: AddonModDataEntryDBRecord): AddonModDataOfflineAction { + return Object.assign(record, { + fields: CoreTextUtils.parseJSON(record.fields), + }); + } + + /** + * Save an entry data to be sent later. + * + * @param dataId Database ID. + * @param entryId Database entry Id. If action is add entryId should be 0 and -timemodified will be used. + * @param action Action to be done to the entry: [add, edit, delete, approve, disapprove] + * @param courseId Course ID of the database. + * @param groupId Group ID. Only provided when adding. + * @param fields Array of field data of the entry if needed. + * @param timemodified The time the entry was modified. If not defined, current time. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if stored, rejected if failure. + */ + async saveEntry( + dataId: number, + entryId: number, + action: AddonModDataAction, + courseId: number, + groupId = 0, + fields?: AddonModDataEntryWSField[], + timemodified?: number, + siteId?: string, + ): Promise { + const site = await CoreSites.getSite(siteId); + + timemodified = timemodified || new Date().getTime(); + entryId = typeof entryId == 'undefined' || entryId === null ? -timemodified : entryId; + + const entry: AddonModDataEntryDBRecord = { + dataid: dataId, + courseid: courseId, + groupid: groupId, + action, + entryid: entryId, + fields: JSON.stringify(fields || []), + timemodified, + }; + + await site.getDb().insertRecord(DATA_ENTRY_TABLE, entry); + + return entry; + } + +} +export const AddonModDataOffline = makeSingleton(AddonModDataOfflineProvider); + +/** + * Entry action stored offline. + */ +export type AddonModDataOfflineAction = Omit & { + fields: AddonModDataEntryWSField[]; +}; diff --git a/src/addons/mod/data/services/data-sync.ts b/src/addons/mod/data/services/data-sync.ts new file mode 100644 index 000000000..92d0d94c9 --- /dev/null +++ b/src/addons/mod/data/services/data-sync.ts @@ -0,0 +1,499 @@ +// (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 { ContextLevel } from '@/core/constants'; +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 { CoreCourseCommonModWSOptions } from '@features/course/services/course'; +import { CoreCourseLogHelper } from '@features/course/services/log-helper'; +import { CoreFileUploaderStoreFilesResult } from '@features/fileuploader/services/fileuploader'; +import { CoreRatingSync } from '@features/rating/services/rating-sync'; +import { FileEntry } from '@ionic-native/file'; +import { CoreApp } from '@services/app'; +import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreSync } from '@services/sync'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreWSExternalFile } from '@services/ws'; +import { Translate, makeSingleton } from '@singletons'; +import { CoreEvents } from '@singletons/events'; +import { AddonModDataProvider, AddonModData, AddonModDataData, AddonModDataAction } from './data'; +import { AddonModDataHelper } from './data-helper'; +import { AddonModDataOffline, AddonModDataOfflineAction } from './data-offline'; + +/** + * Service to sync databases. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModDataSyncProvider extends CoreCourseActivitySyncBaseProvider { + + static readonly AUTO_SYNCED = 'addon_mod_data_autom_synced'; + + protected componentTranslatableString = 'data'; + + constructor() { + super('AddonModDataSyncProvider'); + } + + /** + * Check if a database has data to synchronize. + * + * @param dataId Database ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: true if has data to sync, false otherwise. + */ + hasDataToSync(dataId: number, siteId?: string): Promise { + return AddonModDataOffline.hasOfflineData(dataId, siteId); + } + + /** + * Try to synchronize all the databases 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. + */ + syncAllDatabases(siteId?: string, force?: boolean): Promise { + return this.syncOnSites('all databases', this.syncAllDatabasesFunc.bind(this, !!force), siteId); + } + + /** + * Sync all pending databases 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 syncAllDatabasesFunc(force: boolean, siteId: string): Promise { + const promises: Promise[] = []; + + // Get all data answers pending to be sent in the site. + promises.push(AddonModDataOffline.getAllEntries(siteId).then(async (offlineActions) => { + // Get data id. + let dataIds: number[] = offlineActions.map((action) => action.dataid); + // Get unique values. + dataIds = dataIds.filter((id, pos) => dataIds.indexOf(id) == pos); + + const entriesPromises = dataIds.map(async (dataId) => { + const result = force + ? await this.syncDatabase(dataId, siteId) + : await this.syncDatabaseIfNeeded(dataId, siteId); + + if (result && result.updated) { + // Sync done. Send event. + CoreEvents.trigger(AddonModDataSyncProvider.AUTO_SYNCED, { + dataId: dataId, + warnings: result.warnings, + }, siteId); + } + }); + + await Promise.all(entriesPromises); + + return; + })); + + promises.push(this.syncRatings(undefined, force, siteId)); + + await Promise.all(promises); + } + + /** + * Sync a database only if a certain time has passed since the last time. + * + * @param dataId Database ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is synced or if it doesn't need to be synced. + */ + async syncDatabaseIfNeeded(dataId: number, siteId?: string): Promise { + const needed = await this.isSyncNeeded(dataId, siteId); + + if (needed) { + return this.syncDatabase(dataId, siteId); + } + } + + /** + * Synchronize a data. + * + * @param dataId Data ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if sync is successful, rejected otherwise. + */ + syncDatabase(dataId: number, siteId?: string): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + if (this.isSyncing(dataId, siteId)) { + // There's already a sync ongoing for this database, return the promise. + return this.getOngoingSync(dataId, siteId)!; + } + + // Verify that database isn't blocked. + if (CoreSync.isBlocked(AddonModDataProvider.COMPONENT, dataId, siteId)) { + this.logger.debug(`Cannot sync database '${dataId}' because it is blocked.`); + + throw new CoreSyncBlockedError(Translate.instant('core.errorsyncblocked', { $a: this.componentTranslate })); + } + + this.logger.debug(`Try to sync data '${dataId}' in site ${siteId}'`); + + const syncPromise = this.performSyncDatabase(dataId, siteId); + + return this.addOngoingSync(dataId, syncPromise, siteId); + } + + /** + * Perform the database syncronization. + * + * @param dataId Data ID. + * @param siteId Site ID. + * @return Promise resolved if sync is successful, rejected otherwise. + */ + protected async performSyncDatabase(dataId: number, siteId: string): Promise { + // Sync offline logs. + await CoreUtils.ignoreErrors( + CoreCourseLogHelper.syncActivity(AddonModDataProvider.COMPONENT, dataId, siteId), + ); + + const result: AddonModDataSyncResult = { + warnings: [], + updated: false, + }; + + // Get answers to be sent. + const offlineActions: AddonModDataOfflineAction[] = + await CoreUtils.ignoreErrors(AddonModDataOffline.getDatabaseEntries(dataId, siteId), []); + + if (!offlineActions.length) { + // Nothing to sync. + await CoreUtils.ignoreErrors(this.setSyncTime(dataId, siteId)); + + return result; + } + + if (!CoreApp.isOnline()) { + // Cannot sync in offline. + throw new CoreNetworkError(); + } + + const courseId = offlineActions[0].courseid; + + // Send the answers. + const database = await AddonModData.getDatabaseById(courseId, dataId, { siteId }); + + const offlineEntries: Record = {}; + + offlineActions.forEach((entry) => { + if (typeof offlineEntries[entry.entryid] == 'undefined') { + offlineEntries[entry.entryid] = []; + } + + offlineEntries[entry.entryid].push(entry); + }); + + const promises = CoreUtils.objectToArray(offlineEntries).map((entryActions) => + this.syncEntry(database, entryActions, result, siteId)); + + await Promise.all(promises); + + if (result.updated) { + // Data has been sent to server. Now invalidate the WS calls. + await CoreUtils.ignoreErrors(AddonModData.invalidateContent(database.coursemodule, courseId, siteId)); + } + + // Sync finished, set sync time. + await CoreUtils.ignoreErrors(this.setSyncTime(dataId, siteId)); + + return result; + } + + /** + * Synchronize an entry. + * + * @param database Database. + * @param entryActions Entry actions. + * @param result Object with the result of the sync. + * @param siteId Site ID. + * @return Promise resolved if success, rejected otherwise. + */ + protected async syncEntry( + database: AddonModDataData, + entryActions: AddonModDataOfflineAction[], + result: AddonModDataSyncResult, + siteId: string, + ): Promise { + const synEntryResult = await this.performSyncEntry(database, entryActions, result, siteId); + + if (synEntryResult.discardError) { + // Submission was discarded, add a warning. + const message = Translate.instant('core.warningofflinedatadeleted', { + component: this.componentTranslate, + name: database.name, + error: synEntryResult.discardError, + }); + + if (result.warnings.indexOf(message) == -1) { + result.warnings.push(message); + } + } + + // Sync done. Send event. + CoreEvents.trigger(AddonModDataSyncProvider.AUTO_SYNCED, { + dataId: database.id, + entryId: synEntryResult.entryId, + offlineEntryId: synEntryResult.offlineId, + warnings: result.warnings, + deleted: synEntryResult.deleted, + }, siteId); + } + + /** + * Perform the synchronization of an entry. + * + * @param database Database. + * @param entryActions Entry actions. + * @param result Object with the result of the sync. + * @param siteId Site ID. + * @return Promise resolved if success, rejected otherwise. + */ + protected async performSyncEntry( + database: AddonModDataData, + entryActions: AddonModDataOfflineAction[], + result: AddonModDataSyncResult, + siteId: string, + ): Promise { + let entryId = entryActions[0].entryid; + + const entryResult: AddonModDataSyncEntryResult = { + deleted: false, + entryId: entryId, + }; + + const editAction = entryActions.find((action) => + action.action == AddonModDataAction.ADD || action.action == AddonModDataAction.EDIT); + const approveAction = entryActions.find((action) => + action.action == AddonModDataAction.APPROVE || action.action == AddonModDataAction.DISAPPROVE); + const deleteAction = entryActions.find((action) => action.action == AddonModDataAction.DELETE); + + const options: CoreCourseCommonModWSOptions = { + cmId: database.coursemodule, + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + siteId, + }; + + let timemodified = 0; + if (entryId > 0) { + try { + const entry = await AddonModData.getEntry(database.id, entryId, options); + + timemodified = entry.entry.timemodified; + } catch (error) { + if (error && CoreUtils.isWebServiceError(error)) { + // The WebService has thrown an error, this means the entry has been deleted. + timemodified = -1; + } else { + throw error; + } + } + + } else if (editAction) { + // New entry. + entryResult.offlineId = entryId; + timemodified = 0; + } else { + // New entry but the add action is missing, discard. + timemodified = -1; + } + + if (timemodified < 0 || timemodified >= entryActions[0].timemodified) { + // The entry was not found in Moodle or the entry has been modified, discard the action. + result.updated = true; + entryResult.discardError = Translate.instant('addon.mod_data.warningsubmissionmodified'); + + await AddonModDataOffline.deleteAllEntryActions(database.id, entryId, siteId); + + return entryResult; + } + + if (deleteAction) { + try { + await AddonModData.deleteEntryOnline(entryId, siteId); + entryResult.deleted = true; + } catch (error) { + if (error && CoreUtils.isWebServiceError(error)) { + // The WebService has thrown an error, this means it cannot be performed. Discard. + entryResult.discardError = CoreTextUtils.getErrorMessageFromError(error); + } else { + // Couldn't connect to server, reject. + throw error; + } + } + + // Delete the offline data. + result.updated = true; + + await AddonModDataOffline.deleteAllEntryActions(deleteAction.dataid, deleteAction.entryid, siteId); + + return entryResult; + } + + if (editAction) { + try { + await Promise.all(editAction.fields.map(async (field) => { + // Upload Files if asked. + const value = CoreTextUtils.parseJSON(field.value || ''); + if (value.online || value.offline) { + let files: (CoreWSExternalFile | FileEntry)[] = value.online || []; + + const offlineFiles = value.offline + ? await AddonModDataHelper.getStoredFiles(editAction.dataid, entryId, field.fieldid) + : []; + + files = files.concat(offlineFiles); + + const filesResult = await AddonModDataHelper.uploadOrStoreFiles( + editAction.dataid, + 0, + entryId, + field.fieldid, + files, + false, + siteId, + ); + + field.value = JSON.stringify(filesResult); + } + })); + + if (editAction.action == AddonModDataAction.ADD) { + const result = await AddonModData.addEntryOnline( + editAction.dataid, + editAction.fields, + editAction.groupid, + siteId, + ); + entryId = result.newentryid; + entryResult.entryId = entryId; + } else { + await AddonModData.editEntryOnline(entryId, editAction.fields, siteId); + } + } catch (error) { + if (error && CoreUtils.isWebServiceError(error)) { + // The WebService has thrown an error, this means it cannot be performed. Discard. + entryResult.discardError = CoreTextUtils.getErrorMessageFromError(error); + } else { + // Couldn't connect to server, reject. + throw error; + } + } + // Delete the offline data. + result.updated = true; + + await AddonModDataOffline.deleteEntry(editAction.dataid, editAction.entryid, editAction.action, siteId); + } + + if (approveAction) { + try { + await AddonModData.approveEntryOnline(entryId, approveAction.action == AddonModDataAction.APPROVE, siteId); + } catch (error) { + if (error && CoreUtils.isWebServiceError(error)) { + // The WebService has thrown an error, this means it cannot be performed. Discard. + entryResult.discardError = CoreTextUtils.getErrorMessageFromError(error); + } else { + // Couldn't connect to server, reject. + throw error; + } + } + // Delete the offline data. + result.updated = true; + + await AddonModDataOffline.deleteEntry(approveAction.dataid, approveAction.entryid, approveAction.action, siteId); + } + + return entryResult; + } + + /** + * Synchronize offline ratings. + * + * @param cmId Course module to be synced. If not defined, sync all databases. + * @param force Wether to force sync not depending on last execution. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if sync is successful, rejected otherwise. + */ + async syncRatings(cmId?: number, force?: boolean, siteId?: string): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + const results = await CoreRatingSync.syncRatings('mod_data', 'entry', ContextLevel.MODULE, cmId, 0, force, siteId); + let updated = false; + const warnings = []; + + const promises = results.map((result) => + AddonModData.getDatabase(result.itemSet.courseId, result.itemSet.instanceId, { siteId }) + .then((database) => { + const subPromises: Promise[] = []; + + if (result.updated.length) { + updated = true; + + // Invalidate entry of updated ratings. + result.updated.forEach((itemId) => { + subPromises.push(AddonModData.invalidateEntryData(database.id, itemId, siteId)); + }); + } + + if (result.warnings.length) { + result.warnings.forEach((warning) => { + this.addOfflineDataDeletedWarning(warnings, database.name, warning); + }); + } + + return CoreUtils.allPromises(subPromises); + })); + + await Promise.all(promises); + + return ({ updated, warnings }); + } + +} +export const AddonModDataSync = makeSingleton(AddonModDataSyncProvider); + +/** + * Data returned by a database sync. + */ +export type AddonModDataSyncEntryResult = { + discardError?: string; + offlineId?: number; + entryId: number; + deleted: boolean; +}; + +/** + * Data returned by a database sync. + */ +export type AddonModDataSyncResult = { + warnings: string[]; // List of warnings. + updated: boolean; // Whether some data was sent to the server or offline data was updated. +}; + +export type AddonModDataAutoSyncData = { + dataId: number; + warnings: string[]; + entryId?: number; + offlineEntryId?: number; + deleted?: boolean; +}; diff --git a/src/addons/mod/data/services/data.ts b/src/addons/mod/data/services/data.ts new file mode 100644 index 000000000..4b3af40c1 --- /dev/null +++ b/src/addons/mod/data/services/data.ts @@ -0,0 +1,1460 @@ +// (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 { CoreRatingInfo } from '@features/rating/services/rating'; +import { CoreTagItem } from '@features/tag/services/tag'; +import { FileEntry } from '@ionic-native/file'; +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 } from '@services/ws'; +import { makeSingleton } from '@singletons'; +import { AddonModDataFieldsDelegate } from './data-fields-delegate'; +import { AddonModDataOffline } from './data-offline'; +import { AddonModDataAutoSyncData, AddonModDataSyncProvider } from './data-sync'; + +const ROOT_CACHE_KEY = 'mmaModData:'; + +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 { + [AddonModDataSyncProvider.AUTO_SYNCED]: AddonModDataAutoSyncData; + [AddonModDataProvider.ENTRY_CHANGED]: AddonModDataEntryChangedEventData; + } +} + +export enum AddonModDataAction { + ADD = 'add', + EDIT = 'edit', + DELETE = 'delete', + APPROVE = 'approve', + DISAPPROVE = 'disapprove', + USER = 'user', + USERPICTURE = 'userpicture', + MORE = 'more', + MOREURL = 'moreurl', + COMMENTS = 'comments', + TIMEADDED = 'timeadded', + TIMEMODIFIED = 'timemodified', + TAGS = 'tags', + APPROVALSTATUS = 'approvalstatus', + DELCHECK = 'delcheck', // Unused. + EXPORT = 'export', // Unused. +} + +export enum AddonModDataTemplateType { + LIST_HEADER = 'listtemplateheader', + LIST = 'listtemplate', + LIST_FOOTER = 'listtemplatefooter', + ADD = 'addtemplate', + SEARCH = 'asearchtemplate', + SINGLE = 'singletemplate', +} + +export enum AddonModDataTemplateMode { + LIST = 'list', + EDIT = 'edit', + SHOW = 'show', + SEARCH = 'search', +} + +/** + * Service that provides some features for databases. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModDataProvider { + + static readonly COMPONENT = 'mmaModData'; + static readonly PER_PAGE = 25; + static readonly ENTRY_CHANGED = 'addon_mod_data_entry_changed'; + + /** + * Adds a new entry to a database. + * + * @param dataId Data instance ID. + * @param entryId EntryId or provisional entry ID when offline. + * @param courseId Course ID. + * @param contents The fields data to be created. + * @param groupId Group id, 0 means that the function will determine the user group. + * @param fields The fields that define the contents. + * @param siteId Site ID. If not defined, current site. + * @param forceOffline Force editing entry in offline. + * @return Promise resolved when the action is done. + */ + async addEntry( + dataId: number, + entryId: number, + courseId: number, + contents: AddonModDataEntryWSField[], + groupId: number = 0, + fields: AddonModDataField[], + siteId?: string, + forceOffline: boolean = false, + ): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + // Convenience function to store a data to be synchronized later. + const storeOffline = async (): Promise => { + const entry = await AddonModDataOffline.saveEntry( + dataId, + entryId, + AddonModDataAction.ADD, + courseId, + groupId, + contents, + undefined, + siteId, + ); + + return { + // Return provissional entry Id. + newentryid: entry.entryid, + sent: false, + }; + }; + + // Checks to store offline. + if (!CoreApp.isOnline() || forceOffline) { + const notifications = this.checkFields(fields, contents); + if (notifications.length > 0) { + return { fieldnotifications: notifications }; + } + } + + // Remove unnecessary not synced actions. + await this.deleteEntryOfflineAction(dataId, entryId, AddonModDataAction.ADD, siteId); + + // App is offline, store the action. + if (!CoreApp.isOnline() || forceOffline) { + return storeOffline(); + } + + try { + const result: AddonModDataAddEntryResult = await this.addEntryOnline(dataId, contents, groupId, siteId); + result.sent = true; + + return result; + } 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(); + } + } + + /** + * Adds a new entry to a database. It does not cache calls. It will fail if offline or cannot connect. + * + * @param dataId Database ID. + * @param data The fields data to be created. + * @param groupId Group id, 0 means that the function will determine the user group. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the action is done. + */ + async addEntryOnline( + dataId: number, + data: AddonModDataEntryWSField[], + groupId?: number, + siteId?: string, + ): Promise { + const site = await CoreSites.getSite(siteId); + const params: AddonModDataAddEntryWSParams = { + databaseid: dataId, + data, + }; + + if (typeof groupId !== 'undefined') { + params.groupid = groupId; + } + + return site.write('mod_data_add_entry', params); + } + + /** + * Approves or unapproves an entry. + * + * @param dataId Database ID. + * @param entryId Entry ID. + * @param approve Whether to approve (true) or unapprove the entry. + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the action is done. + */ + async approveEntry( + dataId: number, + entryId: number, + approve: boolean, + courseId: number, + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + // Convenience function to store a data to be synchronized later. + const storeOffline = async (): Promise => { + const action = approve ? AddonModDataAction.APPROVE : AddonModDataAction.DISAPPROVE; + + await AddonModDataOffline.saveEntry(dataId, entryId, action, courseId, undefined, undefined, undefined, siteId); + + return { + sent: false, + }; + }; + + // Get if the opposite action is not synced. + const oppositeAction = approve ? AddonModDataAction.DISAPPROVE : AddonModDataAction.APPROVE; + + const found = await this.deleteEntryOfflineAction(dataId, entryId, oppositeAction, siteId); + if (found) { + // Offline action has been found and deleted. Stop here. + return; + } + + if (!CoreApp.isOnline()) { + // App is offline, store the action. + return storeOffline(); + } + + try { + await this.approveEntryOnline(entryId, approve, siteId); + + return { + sent: true, + }; + } 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(); + } + } + + /** + * Approves or unapproves an entry. It does not cache calls. It will fail if offline or cannot connect. + * + * @param entryId Entry ID. + * @param approve Whether to approve (true) or unapprove the entry. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the action is done. + */ + async approveEntryOnline(entryId: number, approve: boolean, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + const params: AddonModDataApproveEntryWSParams = { + entryid: entryId, + approve, + }; + + await site.write('mod_data_approve_entry', params); + } + + /** + * Convenience function to check fields requeriments here named "notifications". + * + * @param fields The fields that define the contents. + * @param contents The contents data of the fields. + * @return Array of notifications if any or false. + */ + protected checkFields(fields: AddonModDataField[], contents: AddonModDataSubfieldData[]): AddonModDataFieldNotification[] { + const notifications: AddonModDataFieldNotification[] = []; + const contentsIndexed = CoreUtils.arrayToObjectMultiple(contents, 'fieldid'); + + // App is offline, check required fields. + fields.forEach((field) => { + const notification = AddonModDataFieldsDelegate.getFieldsNotifications(field, contentsIndexed[field.id]); + + if (notification) { + notifications.push({ + fieldname: field.name, + notification, + }); + } + }); + + return notifications; + } + + /** + * Deletes an entry. + * + * @param dataId Database ID. + * @param entryId Entry ID. + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the action is done. + */ + async deleteEntry(dataId: number, entryId: number, courseId: number, siteId?: string): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + // Convenience function to store a data to be synchronized later. + const storeOffline = async (): Promise => { + await AddonModDataOffline.saveEntry( + dataId, + entryId, + AddonModDataAction.DELETE, + courseId, + undefined, + undefined, + undefined, + siteId, + ); + }; + + // Check if the opposite action is not synced and just delete it. + const addedOffline = await this.deleteEntryOfflineAction(dataId, entryId, AddonModDataAction.ADD, siteId); + if (addedOffline) { + // Offline add action found and deleted. Stop here. + return; + } + + if (!CoreApp.isOnline()) { + // App is offline, store the action. + return storeOffline(); + } + + try { + await this.deleteEntryOnline(entryId, 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(); + } + } + + /** + * Deletes an entry. It does not cache calls. It will fail if offline or cannot connect. + * + * @param entryId Entry ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the action is done. + */ + async deleteEntryOnline(entryId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + const params: AddonModDataDeleteEntryWSParams = { + entryid: entryId, + }; + + await site.write('mod_data_delete_entry', params); + } + + /** + * Delete entry offline action. + * + * @param dataId Database ID. + * @param entryId Entry ID. + * @param action Action name to delete. + * @param siteId Site ID. + * @return Resolved with true if the action has been found and deleted. + */ + protected async deleteEntryOfflineAction( + dataId: number, + entryId: number, + action: AddonModDataAction, + siteId: string, + ): Promise { + try { + // Get other not not synced actions. + await AddonModDataOffline.getEntry(dataId, entryId, action, siteId); + await AddonModDataOffline.deleteEntry(dataId, entryId, action, siteId); + + return true; + } catch { + // Not found. + return false; + } + } + + /** + * Updates an existing entry. + * + * @param dataId Database ID. + * @param entryId Entry ID. + * @param courseId Course ID. + * @param contents The contents data to be updated. + * @param fields The fields that define the contents. + * @param siteId Site ID. If not defined, current site. + * @param forceOffline Force editing entry in offline. + * @return Promise resolved when the action is done. + */ + async editEntry( + dataId: number, + entryId: number, + courseId: number, + contents: AddonModDataEntryWSField[], + fields: AddonModDataField[], + siteId?: string, + forceOffline: boolean = false, + ): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + // Convenience function to store a data to be synchronized later. + const storeOffline = async (): Promise => { + await AddonModDataOffline.saveEntry( + dataId, + entryId, + AddonModDataAction.EDIT, + courseId, + undefined, + contents, + undefined, + siteId, + ); + + return { + updated: true, + sent: false, + }; + }; + + if (!CoreApp.isOnline() || forceOffline) { + const notifications = this.checkFields(fields, contents); + if (notifications.length > 0) { + return { fieldnotifications: notifications }; + } + } + + // Remove unnecessary not synced actions. + await this.deleteEntryOfflineAction(dataId, entryId, AddonModDataAction.EDIT, siteId); + + if (!CoreApp.isOnline() || forceOffline) { + // App is offline, store the action. + return storeOffline(); + } + + try { + const result: AddonModDataEditEntryResult = await this.editEntryOnline(entryId, contents, siteId); + result.sent = true; + + return result; + } 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(); + } + } + + /** + * Updates an existing entry. It does not cache calls. It will fail if offline or cannot connect. + * + * @param entryId Entry ID. + * @param data The fields data to be updated. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the action is done. + */ + async editEntryOnline( + entryId: number, + data: AddonModDataEntryWSField[], + siteId?: string, + ): Promise { + const site = await CoreSites.getSite(siteId); + const params: AddonModDataUpdateEntryWSParams = { + entryid: entryId, + data, + }; + + return site.write('mod_data_update_entry', params); + } + + /** + * Performs the whole fetch of the entries in the database. + * + * @param dataId Data ID. + * @param options Other options. + * @return Promise resolved when done. + */ + fetchAllEntries(dataId: number, options: AddonModDataGetEntriesOptions = {}): Promise { + options.siteId = options.siteId || CoreSites.getCurrentSiteId(); + options = Object.assign({ + page: 0, + perPage: AddonModDataProvider.PER_PAGE, + }, options); + + return this.fetchEntriesRecursive(dataId, [], options); + } + + /** + * Recursive call on fetch all entries. + * + * @param dataId Data ID. + * @param entries Entries already fetch (just to concatenate them). + * @param options Other options. + * @return Promise resolved when done. + */ + protected async fetchEntriesRecursive( + dataId: number, + entries: AddonModDataEntry[], + options: AddonModDataGetEntriesOptions, + ): Promise { + const result = await this.getEntries(dataId, options); + entries = entries.concat(result.entries); + + const canLoadMore = options.perPage! > 0 && ((options.page! + 1) * options.perPage!) < result.totalcount; + if (canLoadMore) { + options.page!++; + + return this.fetchEntriesRecursive(dataId, entries, options); + } + + return entries; + } + + /** + * Get cache key for data data WS calls. + * + * @param courseId Course ID. + * @return Cache key. + */ + protected getDatabaseDataCacheKey(courseId: number): string { + return ROOT_CACHE_KEY + 'data:' + courseId; + } + + /** + * Get prefix cache key for all database activity data WS calls. + * + * @param dataId Data ID. + * @return Cache key. + */ + protected getDatabaseDataPrefixCacheKey(dataId: number): string { + return ROOT_CACHE_KEY + dataId; + } + + /** + * Get a database data. 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 data is retrieved. + */ + protected async getDatabaseByKey( + courseId: number, + key: string, + value: number, + options: CoreSitesCommonWSOptions = {}, + ): Promise { + const site = await CoreSites.getSite(options.siteId); + + const params: AddonModDataGetDatabasesByCoursesWSParams = { + courseids: [courseId], + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getDatabaseDataCacheKey(courseId), + updateFrequency: CoreSite.FREQUENCY_RARELY, + component: AddonModDataProvider.COMPONENT, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + const response = + await site.read('mod_data_get_databases_by_courses', params, preSets); + + const currentData = response.databases.find((data) => data[key] == value); + if (currentData) { + return currentData; + } + + throw new CoreError('Activity not found'); + } + + /** + * Get a data by course module ID. + * + * @param courseId Course ID. + * @param cmId Course module ID. + * @param options Other options. + * @return Promise resolved when the data is retrieved. + */ + getDatabase(courseId: number, cmId: number, options: CoreSitesCommonWSOptions = {}): Promise { + return this.getDatabaseByKey(courseId, 'coursemodule', cmId, options); + } + + /** + * Get a data by ID. + * + * @param courseId Course ID. + * @param id Data ID. + * @param options Other options. + * @return Promise resolved when the data is retrieved. + */ + getDatabaseById(courseId: number, id: number, options: CoreSitesCommonWSOptions = {}): Promise { + return this.getDatabaseByKey(courseId, 'id', id, options); + } + + /** + * Get prefix cache key for all database access information data WS calls. + * + * @param dataId Data ID. + * @return Cache key. + */ + protected getDatabaseAccessInformationDataPrefixCacheKey(dataId: number): string { + return this.getDatabaseDataPrefixCacheKey(dataId) + ':access:'; + } + + /** + * Get cache key for database access information data WS calls. + * + * @param dataId Data ID. + * @param groupId Group ID. + * @return Cache key. + */ + protected getDatabaseAccessInformationDataCacheKey(dataId: number, groupId: number = 0): string { + return this.getDatabaseAccessInformationDataPrefixCacheKey(dataId) + groupId; + } + + /** + * Get access information for a given database. + * + * @param dataId Data ID. + * @param options Other options. + * @return Promise resolved when the database is retrieved. + */ + async getDatabaseAccessInformation( + dataId: number, + options: AddonModDataAccessInfoOptions = {}, + ): Promise { + const site = await CoreSites.getSite(options.siteId); + + options.groupId = options.groupId || 0; + + const params: AddonModDataGetDataAccessInformationWSParams = { + databaseid: dataId, + groupid: options.groupId, + }; + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getDatabaseAccessInformationDataCacheKey(dataId, options.groupId), + component: AddonModDataProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + return site.read('mod_data_get_data_access_information', params, preSets); + } + + /** + * Get entries for a specific database and group. + * + * @param dataId Data ID. + * @param options Other options. + * @return Promise resolved when the database is retrieved. + */ + async getEntries(dataId: number, options: AddonModDataGetEntriesOptions = {}): Promise { + options = Object.assign({ + groupId: 0, + sort: 0, + order: 'DESC', + page: 0, + perPage: AddonModDataProvider.PER_PAGE, + }, options); + + const site = await CoreSites.getSite(options.siteId); + // Always use sort and order params to improve cache usage (entries are identified by params). + const params: AddonModDataGetEntriesWSParams = { + databaseid: dataId, + returncontents: true, + page: options.page, + perpage: options.perPage, + groupid: options.groupId, + sort: options.sort, + order: options.order, + }; + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getEntriesCacheKey(dataId, options.groupId), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES, + component: AddonModDataProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const response = await site.read('mod_data_get_entries', params, preSets); + + const entriesFormatted = response.entries.map((entry) => this.formatEntryContents(entry)); + + return Object.assign(response, { + entries: entriesFormatted, + }); + } + + /** + * Get cache key for database entries data WS calls. + * + * @param dataId Data ID. + * @param groupId Group ID. + * @return Cache key. + */ + protected getEntriesCacheKey(dataId: number, groupId: number = 0): string { + return this.getEntriesPrefixCacheKey(dataId) + groupId; + } + + /** + * Get prefix cache key for database all entries data WS calls. + * + * @param dataId Data ID. + * @return Cache key. + */ + protected getEntriesPrefixCacheKey(dataId: number): string { + return this.getDatabaseDataPrefixCacheKey(dataId) + ':entries:'; + } + + /** + * Get an entry of the database activity. + * + * @param dataId Data ID for caching purposes. + * @param entryId Entry ID. + * @param options Other options. + * @return Promise resolved when the entry is retrieved. + */ + async getEntry( + dataId: number, + entryId: number, + options: CoreCourseCommonModWSOptions = {}, + ): Promise { + const site = await CoreSites.getSite(options.siteId); + + const params: AddonModDataGetEntryWSParams = { + entryid: entryId, + returncontents: true, + }; + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getEntryCacheKey(dataId, entryId), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES, + component: AddonModDataProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const response = await site.read('mod_data_get_entry', params, preSets); + + return Object.assign(response, { + entry: this.formatEntryContents(response.entry), + }); + } + + /** + * Formats the contents of an entry. + * + * @param entry Original WS entry. + * @returns Entry with contents formatted. + */ + protected formatEntryContents(entry: AddonModDataEntryWS): AddonModDataEntry { + return Object.assign(entry, { + contents: CoreUtils.arrayToObject(entry.contents, 'fieldid'), + }); + } + + /** + * Get cache key for database entry data WS calls. + * + * @param dataId Data ID for caching purposes. + * @param entryId Entry ID. + * @return Cache key. + */ + protected getEntryCacheKey(dataId: number, entryId: number): string { + return this.getDatabaseDataPrefixCacheKey(dataId) + ':entry:' + entryId; + } + + /** + * Get the list of configured fields for the given database. + * + * @param dataId Data ID. + * @param options Other options. + * @return Promise resolved when the fields are retrieved. + */ + async getFields(dataId: number, options: CoreCourseCommonModWSOptions = {}): Promise { + const site = await CoreSites.getSite(options.siteId); + + const params: AddonModDataGetFieldsWSParams = { + databaseid: dataId, + }; + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getFieldsCacheKey(dataId), + updateFrequency: CoreSite.FREQUENCY_RARELY, + component: AddonModDataProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const response = await site.read('mod_data_get_fields', params, preSets); + if (response.fields) { + return response.fields; + } + + throw new CoreError('No fields were returned.'); + } + + /** + * Get cache key for database fields data WS calls. + * + * @param dataId Data ID. + * @return Cache key. + */ + protected getFieldsCacheKey(dataId: number): string { + return this.getDatabaseDataPrefixCacheKey(dataId) + ':fields'; + } + + /** + * Invalidate the prefetched content. + * To invalidate files, use AddonModDataProvider#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 promises: Promise[] = []; + + promises.push(this.getDatabase(courseId, moduleId).then(async (database) => { + const ps: Promise[] = []; + + // Do not invalidate module data before getting module info, we need it! + ps.push(this.invalidateDatabaseData(courseId, siteId)); + ps.push(this.invalidateDatabaseWSData(database.id, siteId)); + ps.push(this.invalidateFieldsData(database.id, siteId)); + + await Promise.all(ps); + + return; + })); + + promises.push(this.invalidateFiles(moduleId, siteId)); + + await CoreUtils.allPromises(promises); + } + + /** + * Invalidates database access information data. + * + * @param dataId Data ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateDatabaseAccessInformationData(dataId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getDatabaseAccessInformationDataPrefixCacheKey(dataId)); + } + + /** + * Invalidates database entries data. + * + * @param dataId Data ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateEntriesData(dataId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getEntriesPrefixCacheKey(dataId)); + } + + /** + * Invalidates database fields data. + * + * @param dataId Data ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateFieldsData(dataId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getFieldsCacheKey(dataId)); + } + + /** + * 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 { + await CoreFilepool.invalidateFilesByComponent(siteId, AddonModDataProvider.COMPONENT, moduleId); + } + + /** + * Invalidates database data. + * + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateDatabaseData(courseId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getDatabaseDataCacheKey(courseId)); + } + + /** + * Invalidates database data except files and module info. + * + * @param databaseId Data ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateDatabaseWSData(databaseId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getDatabaseDataPrefixCacheKey(databaseId)); + } + + /** + * Invalidates database entry data. + * + * @param dataId Data ID for caching purposes. + * @param entryId Entry ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateEntryData(dataId: number, entryId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getEntryCacheKey(dataId, entryId)); + } + + /** + * Return whether or not the plugin is enabled in a certain site. Plugin is enabled if the database 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_data_get_data_access_information'); + } + + /** + * Report the database as being viewed. + * + * @param id Module ID. + * @param name Name of the data. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the WS call is successful. + */ + async logView(id: number, name?: string, siteId?: string): Promise { + const params: AddonModDataViewDatabaseWSParams = { + databaseid: id, + }; + + await CoreCourseLogHelper.logSingle( + 'mod_data_view_database', + params, + AddonModDataProvider.COMPONENT, + id, + name, + 'data', + {}, + siteId, + ); + } + + /** + * Performs search over a database. + * + * @param dataId The data instance id. + * @param options Other options. + * @return Promise resolved when the action is done. + */ + async searchEntries(dataId: number, options: AddonModDataSearchEntriesOptions = {}): Promise { + const site = await CoreSites.getSite(options.siteId); + + options.groupId = options.groupId || 0; + options.sort = options.sort || 0; + options.order || options.order || 'DESC'; + options.page = options.page || 0; + options.perPage = options.perPage || AddonModDataProvider.PER_PAGE; + options.readingStrategy = options.readingStrategy || CoreSitesReadingStrategy.PreferNetwork; + + const params: AddonModDataSearchEntriesWSParams = { + databaseid: dataId, + groupid: options.groupId, + returncontents: true, + page: options.page, + perpage: options.perPage, + }; + const preSets: CoreSiteWSPreSets = { + component: AddonModDataProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + if (typeof options.sort != 'undefined') { + params.sort = options.sort; + } + if (typeof options.order !== 'undefined') { + params.order = options.order; + } + if (typeof options.search !== 'undefined') { + params.search = options.search; + } + if (typeof options.advSearch !== 'undefined') { + params.advsearch = options.advSearch; + } + const response = await site.read('mod_data_search_entries', params, preSets); + + const entriesFormatted = response.entries.map((entry) => this.formatEntryContents(entry)); + + return Object.assign(response, { + entries: entriesFormatted, + }); + } + +} +export const AddonModData = makeSingleton(AddonModDataProvider); + +/** + * Params of mod_data_view_database WS. + */ +type AddonModDataViewDatabaseWSParams = { + databaseid: number; // Data instance id. +}; + +/** + * Params of mod_data_search_entries WS. + */ +type AddonModDataSearchEntriesWSParams = { + databaseid: number; // Data instance id. + groupid?: number; // Group id, 0 means that the function will determine the user group. + returncontents?: boolean; // Whether to return contents or not. + search?: string; // Search string (empty when using advanced). + advsearch?: AddonModDataSearchEntriesAdvancedField[]; + sort?: number; // Sort the records by this field id, reserved ids are: + // 0: timeadded + // -1: firstname + // -2: lastname + // -3: approved + // -4: timemodified. + // Empty for using the default database setting. + order?: string; // The direction of the sorting: 'ASC' or 'DESC'. Empty for using the default database setting. + page?: number; // The page of records to return. + perpage?: number; // The number of records to return per page. +}; + +/** + * Data returned by mod_data_search_entries WS. + */ +export type AddonModDataSearchEntriesWSResponse = { + entries: AddonModDataEntryWS[]; + totalcount: number; // Total count of records returned by the search. + maxcount?: number; // Total count of records that the user could see in the database (if all the search criterias were removed). + listviewcontents?: string; // The list view contents as is rendered in the site. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Options to pass to get access info. + */ +export type AddonModDataAccessInfoOptions = CoreCourseCommonModWSOptions & { + groupId?: number; // Group Id. +}; + +/** + * Options to pass to get entries. + */ +export type AddonModDataGetEntriesOptions = CoreCourseCommonModWSOptions & { + groupId?: number; // Group Id. + sort?: number; // Sort the records by this field id, defaults to 0. Reserved ids are: + // 0: timeadded + // -1: firstname + // -2: lastname + // -3: approved + // -4: timemodified + order?: string; // The direction of the sorting: 'ASC' or 'DESC'. Defaults to 'DESC'. + page?: number; // Page of records to return. Defaults to 0. + perPage?: number; // Records per page to return. Defaults to AddonModDataProvider.PER_PAGE. +}; + +/** + * Options to pass to search entries. + */ +export type AddonModDataSearchEntriesOptions = AddonModDataGetEntriesOptions & { + search?: string; // Search text. It will be used if advSearch is not defined. + advSearch?: AddonModDataSearchEntriesAdvancedField[]; +}; + +/** + * Database entry (online or offline). + */ +export type AddonModDataEntry = Omit & { + contents: AddonModDataEntryFields; // The record contents. + tags?: CoreTagItem[]; // Tags. + // Calculated data. + deleted?: boolean; // Entry is deleted offline. + hasOffline?: boolean; // Entry has offline actions. +}; + +/** + * Database entry data from WS. + */ +export type AddonModDataEntryWS = { + id: number; // Record id. + userid: number; // The id of the user who created the record. + groupid: number; // The group id this record belongs to (0 for no groups). + dataid: number; // The database id this record belongs to. + timecreated: number; // Time the record was created. + timemodified: number; // Last time the record was modified. + approved: boolean; // Whether the entry has been approved (if the database is configured in that way). + canmanageentry: boolean; // Whether the current user can manage this entry. + fullname?: string; // The user who created the entry fullname. + contents?: AddonModDataEntryField[]; + tags?: CoreTagItem[]; // Tags. +}; + +/** + * Entry field content. + */ +export type AddonModDataEntryField = { + id: number; // Content id. + fieldid: number; // The field type of the content. + recordid: number; // The record this content belongs to. + content: string; // Contents. + content1: string; // Contents. + content2: string; // Contents. + content3: string; // Contents. + content4: string; // Contents. + files: (CoreWSExternalFile | FileEntry)[]; +}; + +/** + * Entry contents indexed by field id. + */ +export type AddonModDataEntryFields = { + [fieldid: number]: AddonModDataEntryField; +}; + +/** + * List of entries returned by web service and helper functions. + */ +export type AddonModDataEntries = { + entries: AddonModDataEntry[]; // Online entries. + totalcount: number; // Total count of online entries or found entries. + maxcount?: number; // Total count of online entries. Only returned when searching. + offlineEntries?: AddonModDataEntry[]; // Offline entries. + hasOfflineActions?: boolean; // Whether the database has offline data. + hasOfflineRatings?: boolean; // Whether the database has offline ratings. +}; + +/** + * Subfield form data. + */ +export type AddonModDataSubfieldData = { + fieldid: number; + subfield?: string; + value?: unknown; // Value encoded in JSON. + files?: (CoreWSExternalFile | FileEntry)[]; +}; + +/** + * Params of mod_data_get_data_access_information WS. + */ +type AddonModDataGetDataAccessInformationWSParams = { + databaseid: number; // Database instance id. + groupid?: number; // Group id, 0 means that the function will determine the user group. +}; + +/** + * Data returned by mod_data_get_data_access_information WS. + */ +export type AddonModDataGetDataAccessInformationWSResponse = { + groupid: number; // User current group id (calculated). + canaddentry: boolean; // Whether the user can add entries or not. + canmanageentries: boolean; // Whether the user can manage entries or not. + canapprove: boolean; // Whether the user can approve entries or not. + timeavailable: boolean; // Whether the database is available or not by time restrictions. + inreadonlyperiod: boolean; // Whether the database is in read mode only. + numentries: number; // The number of entries the current user added. + entrieslefttoadd: number; // The number of entries left to complete the activity. + entrieslefttoview: number; // The number of entries left to view other users entries. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_data_get_databases_by_courses WS. + */ +type AddonModDataGetDatabasesByCoursesWSParams = { + courseids?: number[]; // Array of course ids. +}; + +/** + * Data returned by mod_data_get_databases_by_courses WS. + */ +type AddonModDataGetDatabasesByCoursesWSResponse = { + databases: AddonModDataData[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Database data returned by mod_assign_get_assignments. + */ +export type AddonModDataData = { + id: number; // Database id. + course: number; // Course id. + name: string; // Database name. + intro: string; // The Database intro. + introformat?: number; // Intro format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + comments: boolean; // Comments enabled. + timeavailablefrom: number; // Timeavailablefrom field. + timeavailableto: number; // Timeavailableto field. + timeviewfrom: number; // Timeviewfrom field. + timeviewto: number; // Timeviewto field. + requiredentries: number; // Requiredentries field. + requiredentriestoview: number; // Requiredentriestoview field. + maxentries: number; // Maxentries field. + rssarticles: number; // Rssarticles field. + singletemplate: string; // Singletemplate field. + listtemplate: string; // Listtemplate field. + listtemplateheader: string; // Listtemplateheader field. + listtemplatefooter: string; // Listtemplatefooter field. + addtemplate: string; // Addtemplate field. + rsstemplate: string; // Rsstemplate field. + rsstitletemplate: string; // Rsstitletemplate field. + csstemplate: string; // Csstemplate field. + jstemplate: string; // Jstemplate field. + asearchtemplate: string; // Asearchtemplate field. + approval: boolean; // Approval field. + manageapproved: boolean; // Manageapproved field. + scale?: number; // Scale field. + assessed?: number; // Assessed field. + assesstimestart?: number; // Assesstimestart field. + assesstimefinish?: number; // Assesstimefinish field. + defaultsort: number; // Defaultsort field. + defaultsortdir: number; // Defaultsortdir field. + editany?: boolean; // Editany field (not used any more). + notification?: number; // Notification field (not used any more). + timemodified?: number; // Time modified. + coursemodule: number; // Coursemodule. + introfiles?: CoreWSExternalFile[]; +}; + +/** + * Params of mod_data_add_entry WS. + */ +type AddonModDataAddEntryWSParams = { + databaseid: number; // Data instance id. + groupid?: number; // Group id, 0 means that the function will determine the user group. + data: AddonModDataEntryWSField[]; // The fields data to be created. +}; + +/** + * Data returned by mod_data_add_entry WS. + */ +export type AddonModDataAddEntryWSResponse = { + newentryid: number; // True new created entry id. 0 if the entry was not created. + generalnotifications: string[]; + fieldnotifications: AddonModDataFieldNotification[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_data_approve_entry WS. + */ +type AddonModDataApproveEntryWSParams = { + entryid: number; // Record entry id. + approve?: boolean; // Whether to approve (true) or unapprove the entry. +}; + +/** + * Params of mod_data_delete_entry WS. + */ +type AddonModDataDeleteEntryWSParams = { + entryid: number; // Record entry id. +}; + +/** + * Params of mod_data_update_entry WS. + */ +type AddonModDataUpdateEntryWSParams = { + entryid: number; // The entry record id. + data: AddonModDataEntryWSField[]; // The fields data to be updated. +}; + +/** + * Data returned by mod_data_update_entry WS. + */ +export type AddonModDataUpdateEntryWSResponse = { + updated: boolean; // True if the entry was successfully updated, false other wise. + generalnotifications: string[]; + fieldnotifications: AddonModDataFieldNotification[]; + warnings?: CoreWSExternalWarning[]; +}; + +// The fields data to be created or updated. +export type AddonModDataEntryWSField = { + fieldid: number; // The field id. AddonModDataSubfieldData + subfield?: string; // The subfield name (if required). + value: string; // The contents for the field always JSON encoded. +}; + +/** + * Params of mod_data_get_entries WS. + */ +type AddonModDataGetEntriesWSParams = { + databaseid: number; // Data instance id. + groupid?: number; // Group id, 0 means that the function will determine the user group. + returncontents?: boolean; // Whether to return contents or not. This will return each entry raw contents and the complete list + // view(using the template). + sort?: number; // Sort the records by this field id, reserved ids are: + // 0: timeadded + // -1: firstname + // -2: lastname + // -3: approved + // -4: timemodified. + // Empty for using the default database setting. + order?: string; // The direction of the sorting: 'ASC' or 'DESC'. Empty for using the default database setting. + page?: number; // The page of records to return. + perpage?: number; // The number of records to return per page. +}; + +/** + * Data returned by mod_data_get_entries WS. + */ +export type AddonModDataGetEntriesWSResponse = { + entries: AddonModDataEntryWS[]; + totalcount: number; // Total count of records. + totalfilesize: number; // Total size (bytes) of the files included in the records. + listviewcontents?: string; // The list view contents as is rendered in the site. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_data_get_entry WS. + */ +type AddonModDataGetEntryWSParams = { + entryid: number; // Record entry id. + returncontents?: boolean; // Whether to return contents or not. +}; + +/** + * Data returned by mod_data_get_entry WS. + */ +type AddonModDataGetEntryWSResponse = { + entry: AddonModDataEntryWS; + entryviewcontents?: string; // The entry as is rendered in the site. + ratinginfo?: CoreRatingInfo; // Rating information. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Data returned by mod_data_get_entry WS. + */ +export type AddonModDataGetEntryFormatted = { + entry: AddonModDataEntry; + entryviewcontents?: string; // The entry as is rendered in the site. + ratinginfo?: CoreRatingInfo; // Rating information. + warnings?: CoreWSExternalWarning[]; +}; + +export type AddonModDataFieldNotification = { + fieldname: string; // The field name. + notification: string; // The notification for the field. +}; + +/** + * Params of mod_data_get_fields WS. + */ +type AddonModDataGetFieldsWSParams = { + databaseid: number; // Database instance id. +}; + +/** + * Data returned by mod_data_get_fields WS. + */ +type AddonModDataGetFieldsWSResponse = { + fields: AddonModDataField[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Field data returned by mod_data_get_fields WS. + */ +export type AddonModDataField = { + id: number; // Field id. + dataid: number; // The field type of the content. + type: string; // The field type. + name: string; // The field name. + description: string; // The field description. + required: boolean; // Whether is a field required or not. + param1: string; // Field parameters. + param2: string; // Field parameters. + param3: string; // Field parameters. + param4: string; // Field parameters. + param5: string; // Field parameters. + param6: string; // Field parameters. + param7: string; // Field parameters. + param8: string; // Field parameters. + param9: string; // Field parameters. + param10: string; // Field parameters. +}; + +export type AddonModDataEntryChangedEventData = { + dataId: number; + entryId?: number; + deleted?: boolean; +}; + +/** + * Advanced search field. + */ +export type AddonModDataSearchEntriesAdvancedField = { + name: string; // Field key for search. Use fn or ln for first or last name. + value: string; // JSON encoded value for search. +}; + +/** + * Advanced search field. + */ +export type AddonModDataSearchEntriesAdvancedFieldFormatted = { + name: string; // Field key for search. Use fn or ln for first or last name. + value: unknown; // JSON encoded value for search. +}; + +export type AddonModDataAddEntryResult = Partial & { + sent?: boolean; // True if sent, false if stored offline. +}; + +export type AddonModDataApproveEntryResult = { + sent?: boolean; // True if sent, false if stored offline. +}; + +export type AddonModDataEditEntryResult = Partial & { + sent?: boolean; // True if sent, false if stored offline. +}; diff --git a/src/addons/mod/data/services/database/data.ts b/src/addons/mod/data/services/database/data.ts new file mode 100644 index 000000000..203274e78 --- /dev/null +++ b/src/addons/mod/data/services/database/data.ts @@ -0,0 +1,83 @@ +// (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 { SQLiteDB } from '@classes/sqlitedb'; +import { CoreSiteSchema } from '@services/sites'; +import { AddonModDataAction } from '../data'; + +/** + * Database variables for AddonModDataOfflineProvider. + */ +export const DATA_ENTRY_TABLE = 'addon_mod_data_entry_1'; +export const ADDON_MOD_DATA_OFFLINE_SITE_SCHEMA: CoreSiteSchema = { + name: 'AddonModDataOfflineProvider', + version: 1, + tables: [ + { + name: DATA_ENTRY_TABLE, + columns: [ + { + name: 'dataid', + type: 'INTEGER', + }, + { + name: 'courseid', + type: 'INTEGER', + }, + { + name: 'groupid', + type: 'INTEGER', + }, + { + name: 'action', + type: 'TEXT', + }, + { + name: 'entryid', + type: 'INTEGER', + }, + { + name: 'fields', + type: 'TEXT', + }, + { + name: 'timemodified', + type: 'INTEGER', + }, + ], + primaryKeys: ['dataid', 'entryid', 'action'], + }, + ], + async migrate(db: SQLiteDB, oldVersion: number): Promise { + if (oldVersion > 0) { + return; + } + + // Move the records from the old table. + await db.migrateTable('addon_mod_data_entry', DATA_ENTRY_TABLE); + }, +}; + +/** + * Data about data entries to sync. + */ +export type AddonModDataEntryDBRecord = { + dataid: number; // Primary key. + entryid: number; // Primary key. Negative for offline entries. + action: AddonModDataAction; // Primary key. + courseid: number; + groupid: number; + fields: string; + timemodified: number; +}; diff --git a/src/addons/mod/data/services/handlers/approve-link.ts b/src/addons/mod/data/services/handlers/approve-link.ts new file mode 100644 index 000000000..7dfdc3173 --- /dev/null +++ b/src/addons/mod/data/services/handlers/approve-link.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 { Injectable } from '@angular/core'; +import { Params } from '@angular/router'; +import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate'; +import { makeSingleton } from '@singletons'; +import { AddonModData } from '../data'; +import { AddonModDataHelper } from '../data-helper'; + +/** + * Content links handler for database approve/disapprove entry. + * Match mod/data/view.php?d=6&approve=5 with a valid data id and entryid. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModDataApproveLinkHandlerService extends CoreContentLinksHandlerBase { + + name = 'AddonModDataApproveLinkHandler'; + featureName = 'CoreCourseModuleDelegate_AddonModData'; + pattern = /\/mod\/data\/view\.php.*([?&](d|approve|disapprove)=\d+)/; + priority = 50; // Higher priority than the default link handler for view.php. + + /** + * @inheritdoc + */ + getActions(siteIds: string[], url: string, params: Params, courseId?: number): CoreContentLinksAction[] { + return [{ + action: (siteId): void => { + const dataId = parseInt(params.d, 10); + const entryId = parseInt(params.approve, 10) || parseInt(params.disapprove, 10); + const approve = parseInt(params.approve, 10) ? true : false; + + AddonModDataHelper.approveOrDisapproveEntry(dataId, entryId, approve, courseId, siteId); + }, + }]; + } + + /** + * @inheritdoc + */ + async isEnabled(siteId: string, url: string, params: Params): Promise { + if (typeof params.d == 'undefined' || (typeof params.approve == 'undefined' && typeof params.disapprove == 'undefined')) { + // Required fields not defined. Cannot treat the URL. + return false; + } + + return AddonModData.isPluginEnabled(siteId); + } + +} +export const AddonModDataApproveLinkHandler = makeSingleton(AddonModDataApproveLinkHandlerService); diff --git a/src/addons/mod/data/services/handlers/default-field.ts b/src/addons/mod/data/services/handlers/default-field.ts new file mode 100644 index 000000000..3eec28e1a --- /dev/null +++ b/src/addons/mod/data/services/handlers/default-field.ts @@ -0,0 +1,78 @@ +// (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 { FileEntry } from '@ionic-native/file'; +import { CoreWSExternalFile } from '@services/ws'; +import { AddonModDataEntryField, AddonModDataSearchEntriesAdvancedFieldFormatted, AddonModDataSubfieldData } from '../data'; +import { AddonModDataFieldHandler } from '../data-fields-delegate'; + +/** + * Default handler used when a field plugin doesn't have a specific implementation. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModDataDefaultFieldHandler implements AddonModDataFieldHandler { + + name = 'AddonModDataDefaultFieldHandler'; + type = 'default'; + + /** + * @inheritdoc + */ + getFieldSearchData(): AddonModDataSearchEntriesAdvancedFieldFormatted[] { + return []; + } + + /** + * @inheritdoc + */ + getFieldEditData(): AddonModDataSubfieldData[] { + return []; + } + + /** + * @inheritdoc + */ + hasFieldDataChanged(): boolean { + return false; + } + + /** + * @inheritdoc + */ + getFieldEditFiles(): (CoreWSExternalFile | FileEntry)[] { + return []; + } + + /** + * @inheritdoc + */ + getFieldsNotifications(): undefined { + return; + } + + /** + * @inheritdoc + */ + overrideData(originalContent: AddonModDataEntryField): AddonModDataEntryField { + return originalContent; + } + + /** + * @inheritdoc + */ + async isEnabled(): Promise { + return true; + } + +} diff --git a/src/addons/mod/data/services/handlers/delete-link.ts b/src/addons/mod/data/services/handlers/delete-link.ts new file mode 100644 index 000000000..6fd66bc4b --- /dev/null +++ b/src/addons/mod/data/services/handlers/delete-link.ts @@ -0,0 +1,61 @@ +// (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 { Params } from '@angular/router'; +import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate'; +import { makeSingleton } from '@singletons'; +import { AddonModData } from '../data'; +import { AddonModDataHelper } from '../data-helper'; + +/** + * Content links handler for database delete entry. + * Match mod/data/view.php?d=6&delete=5 with a valid data id and entryid. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModDataDeleteLinkHandlerService extends CoreContentLinksHandlerBase { + + name = 'AddonModDataDeleteLinkHandler'; + featureName = 'CoreCourseModuleDelegate_AddonModData'; + pattern = /\/mod\/data\/view\.php.*([?&](d|delete)=\d+)/; + + /** + * @inheritdoc + */ + getActions(siteIds: string[], url: string, params: Params, courseId?: number): CoreContentLinksAction[] { + return [{ + action: (siteId): void => { + const dataId = parseInt(params.d, 10); + const entryId = parseInt(params.delete, 10); + + AddonModDataHelper.showDeleteEntryModal(dataId, entryId, courseId, siteId); + }, + }]; + } + + /** + * @inheritdoc + */ + async isEnabled(siteId: string, url: string, params: Params): Promise { + if (typeof params.d == 'undefined' || typeof params.delete == 'undefined') { + // Required fields not defined. Cannot treat the URL. + return false; + } + + return AddonModData.isPluginEnabled(siteId); + } + +} +export const AddonModDataDeleteLinkHandler = makeSingleton(AddonModDataDeleteLinkHandlerService); diff --git a/src/addons/mod/data/services/handlers/edit-link.ts b/src/addons/mod/data/services/handlers/edit-link.ts new file mode 100644 index 000000000..36acd0cfa --- /dev/null +++ b/src/addons/mod/data/services/handlers/edit-link.ts @@ -0,0 +1,79 @@ +// (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 { Params } from '@angular/router'; +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 { AddonModData } from '../data'; +import { AddonModDataModuleHandlerService } from './module'; + +/** + * Content links handler for database add or edit entry. + * Match mod/data/edit.php?d=6&rid=6 with a valid data and optional record id. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModDataEditLinkHandlerService extends CoreContentLinksHandlerBase { + + name = 'AddonModDataEditLinkHandler'; + featureName = 'CoreCourseModuleDelegate_AddonModData'; + pattern = /\/mod\/data\/edit\.php.*([?&](d|rid)=\d+)/; + + /** + * @inheritdoc + */ + getActions(siteIds: string[], url: string, params: Params): CoreContentLinksAction[] { + return [{ + action: async (siteId): Promise => { + const modal = await CoreDomUtils.showModalLoading(); + const dataId = parseInt(params.d, 10); + const rId = params.rid || ''; + + try { + const module = await CoreCourse.getModuleBasicInfoByInstance(dataId, 'data', siteId); + const pageParams: Params = { + module, + courseId: module.course, + }; + + CoreNavigator.navigateToSitePath( + `${AddonModDataModuleHandlerService.PAGE_NAME}/${module.course}/${module.id}/edit/${rId}`, + { siteId, params: pageParams }, + ); + } finally { + // Just in case. In fact we need to dismiss the modal before showing a toast or error message. + modal.dismiss(); + } + }, + }]; + } + + /** + * @inheritdoc + */ + async isEnabled(siteId: string, url: string, params: Params): Promise { + if (typeof params.d == 'undefined') { + // Id not defined. Cannot treat the URL. + return false; + } + + return AddonModData.isPluginEnabled(siteId); + } + +} +export const AddonModDataEditLinkHandler = makeSingleton(AddonModDataEditLinkHandlerService); diff --git a/src/addons/mod/data/services/handlers/index-link.ts b/src/addons/mod/data/services/handlers/index-link.ts new file mode 100644 index 000000000..a831add07 --- /dev/null +++ b/src/addons/mod/data/services/handlers/index-link.ts @@ -0,0 +1,40 @@ +// (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 { AddonModData } from '../data'; + +/** + * Handler to treat links to data. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModDataIndexLinkHandlerService extends CoreContentLinksModuleIndexHandler { + + name = 'AddonModDataLinkHandler'; + + constructor() { + super('AddonModData', 'data', 'd'); + } + + /** + * @inheritdoc + */ + isEnabled(siteId: string): Promise { + return AddonModData.isPluginEnabled(siteId); + } + +} +export const AddonModDataIndexLinkHandler = makeSingleton(AddonModDataIndexLinkHandlerService); diff --git a/src/addons/mod/data/services/handlers/list-link.ts b/src/addons/mod/data/services/handlers/list-link.ts new file mode 100644 index 000000000..1bbd94082 --- /dev/null +++ b/src/addons/mod/data/services/handlers/list-link.ts @@ -0,0 +1,40 @@ +// (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 { AddonModData } from '../data'; + +/** + * Handler to treat links to data list page. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModDataListLinkHandlerService extends CoreContentLinksModuleListHandler { + + name = 'AddonModDataListLinkHandler'; + + constructor() { + super('AddonModData', 'data'); + } + + /** + * @inheritdoc + */ + isEnabled(siteId?: string): Promise { + return AddonModData.isPluginEnabled(siteId); + } + +} +export const AddonModDataListLinkHandler = makeSingleton(AddonModDataListLinkHandlerService); diff --git a/src/addons/mod/data/services/handlers/module.ts b/src/addons/mod/data/services/handlers/module.ts new file mode 100644 index 000000000..c48f8bb97 --- /dev/null +++ b/src/addons/mod/data/services/handlers/module.ts @@ -0,0 +1,85 @@ +// (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 { AddonModDataIndexComponent } from '../../components/index'; +import { AddonModData } from '../data'; + +/** + * Handler to support data modules. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModDataModuleHandlerService implements CoreCourseModuleHandler { + + static readonly PAGE_NAME = 'mod_data'; + + name = 'AddonModData'; + modName = 'data'; + + 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]: true, + [CoreConstants.FEATURE_GRADE_OUTCOMES]: true, + [CoreConstants.FEATURE_BACKUP_MOODLE2]: true, + [CoreConstants.FEATURE_SHOW_DESCRIPTION]: true, + [CoreConstants.FEATURE_RATE]: true, + [CoreConstants.FEATURE_COMMENT]: true, + }; + + /** + * @inheritdoc + */ + isEnabled(): Promise { + return AddonModData.isPluginEnabled(); + } + + /** + * @inheritdoc + */ + getData(module: CoreCourseAnyModuleData): CoreCourseModuleHandlerData { + return { + icon: CoreCourse.getModuleIconSrc(this.modName, 'modicon' in module ? module.modicon : undefined), + title: module.name, + class: 'addon-mod_data-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(AddonModDataModuleHandlerService.PAGE_NAME + routeParams, options); + }, + }; + } + + /** + * @inheritdoc + */ + async getMainComponent(): Promise> { + return AddonModDataIndexComponent; + } + +} +export const AddonModDataModuleHandler = makeSingleton(AddonModDataModuleHandlerService); diff --git a/src/addons/mod/data/services/handlers/prefetch.ts b/src/addons/mod/data/services/handlers/prefetch.ts new file mode 100644 index 000000000..7a8106596 --- /dev/null +++ b/src/addons/mod/data/services/handlers/prefetch.ts @@ -0,0 +1,300 @@ +// (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 { CoreComments } from '@features/comments/services/comments'; +import { CoreCourseActivityPrefetchHandlerBase } from '@features/course/classes/activity-prefetch-handler'; +import { CoreCourseCommonModWSOptions, CoreCourse, CoreCourseAnyModuleData } from '@features/course/services/course'; +import { CoreFilepool } from '@services/filepool'; +import { CoreGroup, CoreGroups } from '@services/groups'; +import { CoreSitesCommonWSOptions, CoreSites, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreWSExternalFile } from '@services/ws'; +import { makeSingleton } from '@singletons'; +import { AddonModDataProvider, AddonModDataEntry, AddonModData, AddonModDataData } from '../data'; +import { AddonModDataSync, AddonModDataSyncResult } from '../data-sync'; + +/** + * Handler to prefetch databases. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModDataPrefetchHandlerService extends CoreCourseActivityPrefetchHandlerBase { + + name = 'AddonModData'; + modName = 'data'; + component = AddonModDataProvider.COMPONENT; + updatesNames = /^configuration$|^.*files$|^entries$|^gradeitems$|^outcomes$|^comments$|^ratings/; + + /** + * Retrieves all the entries for all the groups and then returns only unique entries. + * + * @param dataId Database Id. + * @param groups Array of groups in the activity. + * @param options Other options. + * @return All unique entries. + */ + protected async getAllUniqueEntries( + dataId: number, + groups: CoreGroup[], + options: CoreSitesCommonWSOptions = {}, + ): Promise { + + const promises = groups.map((group) => AddonModData.fetchAllEntries(dataId, { + groupId: group.id, + ...options, // Include all options. + })); + + const responses = await Promise.all(promises); + + const uniqueEntries: Record = {}; + responses.forEach((groupEntries) => { + groupEntries.forEach((entry) => { + uniqueEntries[entry.id] = entry; + }); + }); + + return CoreUtils.objectToArray(uniqueEntries); + } + + /** + * Helper function to get all database info just once. + * + * @param module Module to get the files. + * @param courseId Course ID the module belongs to. + * @param omitFail True to always return even if fails. Default false. + * @param options Other options. + * @return Promise resolved with the info fetched. + */ + protected async getDatabaseInfoHelper( + module: CoreCourseAnyModuleData, + courseId: number, + omitFail: boolean, + options: CoreCourseCommonModWSOptions = {}, + ): Promise<{ database: AddonModDataData; groups: CoreGroup[]; entries: AddonModDataEntry[]; files: CoreWSExternalFile[]}> { + let groups: CoreGroup[] = []; + let entries: AddonModDataEntry[] = []; + let files: CoreWSExternalFile[] = []; + + options.cmId = options.cmId || module.id; + options.siteId = options.siteId || CoreSites.getCurrentSiteId(); + + const database = await AddonModData.getDatabase(courseId, module.id, options); + + try { + files = this.getIntroFilesFromInstance(module, database); + + const groupInfo = await CoreGroups.getActivityGroupInfo(module.id, false, undefined, options.siteId); + if (!groupInfo.groups || groupInfo.groups.length == 0) { + groupInfo.groups = [{ id: 0, name: '' }]; + } + groups = groupInfo.groups || []; + + entries = await this.getAllUniqueEntries(database.id, groups, options); + files = files.concat(this.getEntriesFiles(entries)); + + return { + database, + groups, + entries, + files, + }; + } catch (error) { + if (omitFail) { + // Any error, return the info we have. + return { + database, + groups, + entries, + files, + }; + } + + throw error; + } + } + + /** + * Returns the file contained in the entries. + * + * @param entries List of entries to get files from. + * @return List of files. + */ + protected getEntriesFiles(entries: AddonModDataEntry[]): CoreWSExternalFile[] { + let files: CoreWSExternalFile[] = []; + + entries.forEach((entry) => { + CoreUtils.objectToArray(entry.contents).forEach((content) => { + files = files.concat(content.files); + }); + }); + + return files; + } + + /** + * @inheritdoc + */ + async getFiles(module: CoreCourseAnyModuleData, courseId: number): Promise { + return this.getDatabaseInfoHelper(module, courseId, true).then((info) => info.files); + } + + /** + * @inheritdoc + */ + async getIntroFiles(module: CoreCourseAnyModuleData, courseId: number): Promise { + const data = await CoreUtils.ignoreErrors(AddonModData.getDatabase(courseId, module.id)); + + return this.getIntroFilesFromInstance(module, data); + } + + /** + * @inheritdoc + */ + async invalidateContent(moduleId: number, courseId: number): Promise { + await AddonModData.invalidateContent(moduleId, courseId); + } + + /** + * @inheritdoc + */ + async invalidateModule(module: CoreCourseAnyModuleData, courseId: number): Promise { + const promises: Promise[] = []; + promises.push(AddonModData.invalidateDatabaseData(courseId)); + promises.push(AddonModData.invalidateDatabaseAccessInformationData(module.instance!)); + + await Promise.all(promises); + } + + /** + * @inheritdoc + */ + async isDownloadable(module: CoreCourseAnyModuleData, courseId: number): Promise { + const database = await AddonModData.getDatabase(courseId, module.id, { + readingStrategy: CoreSitesReadingStrategy.PreferCache, + }); + + const accessData = await AddonModData.getDatabaseAccessInformation(database.id, { cmId: module.id }); + // Check if database is restricted by time. + if (!accessData.timeavailable) { + const time = CoreTimeUtils.timestamp(); + + // It is restricted, checking times. + if (database.timeavailablefrom && time < database.timeavailablefrom) { + return false; + } + if (database.timeavailableto && time > database.timeavailableto) { + return false; + } + } + + return true; + } + + /** + * @inheritdoc + */ + async isEnabled(): Promise { + return AddonModData.isPluginEnabled(); + } + + /** + * @inheritdoc + */ + prefetch(module: CoreCourseAnyModuleData, courseId?: number): Promise { + return this.prefetchPackage(module, courseId, this.prefetchDatabase.bind(this, module, courseId)); + } + + /** + * Prefetch a database. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @return Promise resolved when done. + */ + protected async prefetchDatabase(module: CoreCourseAnyModuleData, courseId?: number): Promise { + const siteId = CoreSites.getCurrentSiteId(); + courseId = courseId || module.course || CoreSites.getCurrentSiteHomeId(); + + const options = { + cmId: module.id, + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + siteId, + }; + + const info = await this.getDatabaseInfoHelper(module, courseId, false, options); + + // Prefetch the database data. + const database = info.database; + + const commentsEnabled = !CoreComments.areCommentsDisabledInSite(); + + const promises: Promise[] = []; + + promises.push(AddonModData.getFields(database.id, options)); + promises.push(CoreFilepool.addFilesToQueue(siteId, info.files, this.component, module.id)); + + info.groups.forEach((group) => { + promises.push(AddonModData.getDatabaseAccessInformation(database.id, { + groupId: group.id, + ...options, // Include all options. + })); + }); + + info.entries.forEach((entry) => { + promises.push(AddonModData.getEntry(database.id, entry.id, options)); + + if (commentsEnabled && database.comments) { + promises.push(CoreComments.getComments( + 'module', + database.coursemodule, + 'mod_data', + entry.id, + 'database_entry', + 0, + siteId, + )); + } + }); + + // Add Basic Info to manage links. + promises.push(CoreCourse.getModuleBasicInfoByInstance(database.id, 'data', siteId)); + + await Promise.all(promises); + } + + /** + * Sync a module. + * + * @param module Module. + * @param courseId Course ID the module belongs to + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async sync(module: CoreCourseAnyModuleData, courseId: number, siteId?: string): Promise { + const promises = [ + AddonModDataSync.syncDatabase(module.instance!, siteId), + AddonModDataSync.syncRatings(module.id, true, siteId), + ]; + + const results = await Promise.all(promises); + + return results.reduce((a, b) => ({ + updated: a.updated || b.updated, + warnings: (a.warnings || []).concat(b.warnings || []), + }), { updated: false , warnings: [] }); + } + +} +export const AddonModDataPrefetchHandler = makeSingleton(AddonModDataPrefetchHandlerService); diff --git a/src/addons/mod/data/services/handlers/show-link.ts b/src/addons/mod/data/services/handlers/show-link.ts new file mode 100644 index 000000000..63beb6200 --- /dev/null +++ b/src/addons/mod/data/services/handlers/show-link.ts @@ -0,0 +1,94 @@ +// (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 { Params } from '@angular/router'; +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 { AddonModData } from '../data'; +import { AddonModDataModuleHandlerService } from './module'; + +/** + * Content links handler for database show entry. + * Match mod/data/view.php?d=6&rid=5 with a valid data id and entryid. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModDataShowLinkHandlerService extends CoreContentLinksHandlerBase { + + name = 'AddonModDataShowLinkHandler'; + featureName = 'CoreCourseModuleDelegate_AddonModData'; + pattern = /\/mod\/data\/view\.php.*([?&](d|rid|page|group|mode)=\d+)/; + priority = 50; // Higher priority than the default link handler for view.php. + + /** + * @inheritdoc + */ + getActions(siteIds: string[], url: string, params: Params): CoreContentLinksAction[] { + return [{ + action: async (siteId): Promise => { + const modal = await CoreDomUtils.showModalLoading(); + const dataId = parseInt(params.d, 10); + const rId = params.rid || ''; + const group = parseInt(params.group, 10) || false; + const page = parseInt(params.page, 10) || false; + + try { + const module = await CoreCourse.getModuleBasicInfoByInstance(dataId, 'data', siteId); + const pageParams: Params = { + module: module, + courseId: module.course, + }; + + if (group) { + pageParams.group = group; + } + + if (params.mode && params.mode == 'single') { + pageParams.offset = page || 0; + } + + CoreNavigator.navigateToSitePath( + `${AddonModDataModuleHandlerService.PAGE_NAME}/${module.course}/${module.id}/${rId}`, + { siteId, params: pageParams }, + ); + } finally { + // Just in case. In fact we need to dismiss the modal before showing a toast or error message. + modal.dismiss(); + } + }, + }]; + } + + /** + * @inheritdoc + */ + async isEnabled(siteId: string, url: string, params: Params): Promise { + if (typeof params.d == 'undefined') { + // Id not defined. Cannot treat the URL. + return false; + } + + if ((!params.mode || params.mode != 'single') && typeof params.rid == 'undefined') { + return false; + } + + return AddonModData.isPluginEnabled(siteId); + } + +} +export const AddonModDataShowLinkHandler = makeSingleton(AddonModDataShowLinkHandlerService); diff --git a/src/addons/mod/data/services/handlers/sync-cron.ts b/src/addons/mod/data/services/handlers/sync-cron.ts new file mode 100644 index 000000000..daab609e2 --- /dev/null +++ b/src/addons/mod/data/services/handlers/sync-cron.ts @@ -0,0 +1,43 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreCronHandler } from '@services/cron'; +import { makeSingleton } from '@singletons'; +import { AddonModDataSync } from '../data-sync'; + +/** + * Synchronization cron handler. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModDataSyncCronHandlerService implements CoreCronHandler { + + name = 'AddonModDataSyncCronHandler'; + + /** + * @inheritdoc + */ + execute(siteId?: string, force?: boolean): Promise { + return AddonModDataSync.syncAllDatabases(siteId, force); + } + + /** + * @inheritdoc + */ + getInterval(): number { + return AddonModDataSync.syncInterval; + } + +} +export const AddonModDataSyncCronHandler = makeSingleton(AddonModDataSyncCronHandlerService); diff --git a/src/addons/mod/data/services/handlers/tag-area.ts b/src/addons/mod/data/services/handlers/tag-area.ts new file mode 100644 index 000000000..31341389e --- /dev/null +++ b/src/addons/mod/data/services/handlers/tag-area.ts @@ -0,0 +1,53 @@ +// (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, Type } from '@angular/core'; +import { CoreTagFeedComponent } from '@features/tag/components/feed/feed'; +import { CoreTagAreaHandler } from '@features/tag/services/tag-area-delegate'; +import { CoreTagFeedElement, CoreTagHelper } from '@features/tag/services/tag-helper'; +import { makeSingleton } from '@singletons'; +import { AddonModData } from '../data'; + +/** + * Handler to support tags. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModDataTagAreaHandlerService implements CoreTagAreaHandler { + + name = 'AddonModDataTagAreaHandler'; + type = 'mod_data/data_records'; + + /** + * @inheritdoc + */ + async isEnabled(): Promise { + return AddonModData.isPluginEnabled(); + } + + /** + * @inheritdoc + */ + parseContent(content: string): CoreTagFeedElement[] { + return CoreTagHelper.parseFeedContent(content); + } + + /** + * @inheritdoc + */ + getComponent(): Type { + return CoreTagFeedComponent; + } + +} +export const AddonModDataTagAreaHandler = makeSingleton(AddonModDataTagAreaHandlerService); diff --git a/src/addons/mod/forum/services/forum-sync.ts b/src/addons/mod/forum/services/forum-sync.ts index 2cec0080d..fae896b0c 100644 --- a/src/addons/mod/forum/services/forum-sync.ts +++ b/src/addons/mod/forum/services/forum-sync.ts @@ -330,12 +330,12 @@ export class AddonModForumSyncProvider extends CoreCourseActivitySyncBaseProvide updated = true; // Invalidate discussions of updated ratings. - promises.push(AddonModForum.invalidateDiscussionPosts(result.itemSet!.itemSetId, undefined, siteId)); + promises.push(AddonModForum.invalidateDiscussionPosts(result.itemSet.itemSetId, undefined, siteId)); } if (result.warnings.length) { // Fetch forum to construct the warning message. - promises.push(AddonModForum.getForum(result.itemSet!.courseId!, result.itemSet!.instanceId, { siteId }) + promises.push(AddonModForum.getForum(result.itemSet.courseId, result.itemSet.instanceId, { siteId }) .then((forum) => { result.warnings.forEach((warning) => { this.addOfflineDataDeletedWarning(warnings, forum.name, warning); diff --git a/src/addons/mod/mod.module.ts b/src/addons/mod/mod.module.ts index f16e25ea1..a3c6ba745 100644 --- a/src/addons/mod/mod.module.ts +++ b/src/addons/mod/mod.module.ts @@ -16,6 +16,7 @@ import { NgModule } from '@angular/core'; import { AddonModAssignModule } from './assign/assign.module'; import { AddonModBookModule } from './book/book.module'; +import { AddonModDataModule } from './data/data.module'; import { AddonModFolderModule } from './folder/folder.module'; import { AddonModForumModule } from './forum/forum.module'; import { AddonModLabelModule } from './label/label.module'; @@ -32,10 +33,10 @@ import { AddonModScormModule } from './scorm/scorm.module'; import { AddonModChoiceModule } from './choice/choice.module'; @NgModule({ - declarations: [], imports: [ AddonModAssignModule, AddonModBookModule, + AddonModDataModule, AddonModForumModule, AddonModLessonModule, AddonModPageModule, @@ -51,7 +52,5 @@ import { AddonModChoiceModule } from './choice/choice.module'; AddonModScormModule, AddonModChoiceModule, ], - providers: [], - exports: [], }) export class AddonModModule { } diff --git a/src/core/features/compile/services/compile.ts b/src/core/features/compile/services/compile.ts index e7738fca3..3ec49affb 100644 --- a/src/core/features/compile/services/compile.ts +++ b/src/core/features/compile/services/compile.ts @@ -126,6 +126,7 @@ import { ADDON_MOD_ASSIGN_SERVICES } from '@addons/mod/assign/assign.module'; import { ADDON_MOD_BOOK_SERVICES } from '@addons/mod/book/book.module'; // @todo 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_FOLDER_SERVICES } from '@addons/mod/folder/folder.module'; import { ADDON_MOD_FORUM_SERVICES } from '@addons/mod/forum/forum.module'; @@ -291,6 +292,7 @@ export class CoreCompileProvider { ...ADDON_MOD_BOOK_SERVICES, // @todo ...ADDON_MOD_CHAT_SERVICES, ...ADDON_MOD_CHOICE_SERVICES, + ...ADDON_MOD_DATA_SERVICES, // @todo ...ADDON_MOD_FEEDBACK_SERVICES, ...ADDON_MOD_FOLDER_SERVICES, ...ADDON_MOD_FORUM_SERVICES, diff --git a/src/core/features/rating/services/database/rating.ts b/src/core/features/rating/services/database/rating.ts index 9db9f7410..3f9359d94 100644 --- a/src/core/features/rating/services/database/rating.ts +++ b/src/core/features/rating/services/database/rating.ts @@ -92,7 +92,7 @@ export type CoreRatingDBPrimaryData = { */ export type CoreRatingDBRecord = CoreRatingDBPrimaryData & { itemsetid: number; - courseid?: number; + courseid: number; scaleid: number; rating: number; rateduserid: number; diff --git a/src/core/features/rating/services/rating-offline.ts b/src/core/features/rating/services/rating-offline.ts index 88727b45b..0867e3294 100644 --- a/src/core/features/rating/services/rating-offline.ts +++ b/src/core/features/rating/services/rating-offline.ts @@ -28,7 +28,7 @@ export interface CoreRatingItemSet { contextLevel: ContextLevel; instanceId: number; itemSetId: number; - courseId?: number; + courseId: number; } /** diff --git a/src/core/features/rating/services/rating-sync.ts b/src/core/features/rating/services/rating-sync.ts index b0ff4b011..9e6027ca9 100644 --- a/src/core/features/rating/services/rating-sync.ts +++ b/src/core/features/rating/services/rating-sync.ts @@ -59,12 +59,12 @@ export class CoreRatingSyncProvider extends CoreSyncBaseProvider { + ): Promise { siteId = siteId || CoreSites.getCurrentSiteId(); const itemSets = await CoreRatingOffline.getItemSets(component, ratingArea, contextLevel, instanceId, itemSetId, siteId); - const results: CoreRatingSyncItem[] = []; + const results: CoreRatingSyncItemResult[] = []; await Promise.all(itemSets.map(async (itemSet) => { const result = force ? await this.syncItemSet( @@ -301,11 +301,14 @@ declare module '@singletons/events' { } export type CoreRatingSyncItem = { - itemSet?: CoreRatingItemSet; warnings: string[]; updated: number[]; }; +export type CoreRatingSyncItemResult = CoreRatingSyncItem & { + itemSet: CoreRatingItemSet; +}; + /** * Data passed to SYNCED_EVENT event. */ diff --git a/src/core/services/filepool.ts b/src/core/services/filepool.ts index c81d56178..80739f24b 100644 --- a/src/core/services/filepool.ts +++ b/src/core/services/filepool.ts @@ -2181,7 +2181,7 @@ export class CoreFilepoolProvider { * @return Resolved when done. */ async invalidateFilesByComponent( - siteId: string, + siteId: string | undefined, component: string, componentId?: string | number, onlyUnknown: boolean = true, diff --git a/src/core/services/sites.ts b/src/core/services/sites.ts index b1bfd4dd3..63c7e9014 100644 --- a/src/core/services/sites.ts +++ b/src/core/services/sites.ts @@ -1036,8 +1036,10 @@ export class CoreSitesProvider { * @param siteId The site ID. If not defined, current site (if available). * @return Promise resolved with the database. */ - getSiteDb(siteId?: string): Promise { - return this.getSite(siteId).then((site) => site.getDb()); + async getSiteDb(siteId?: string): Promise { + const site = await this.getSite(siteId); + + return site.getDb(); } /** diff --git a/src/core/services/utils/utils.ts b/src/core/services/utils/utils.ts index d043557d1..bf83806e2 100644 --- a/src/core/services/utils/utils.ts +++ b/src/core/services/utils/utils.ts @@ -117,7 +117,7 @@ export class CoreUtilsProvider { * @return The object. */ arrayToObject( - array: T[], + array: T[] = [], propertyName?: string, result: Record = {}, ): Record {