MOBILE-3640 database: Add database activity services

main
Pau Ferrer Ocaña 2021-03-18 17:17:47 +01:00
parent 992f1ca1ad
commit 2991873dfe
28 changed files with 4396 additions and 15 deletions

View File

@ -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<AddonModAssignSyncResult> {

View File

@ -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}} (<a href=\"{{$a.reseturl}}\">Reset filters</a>)",
"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."
}

View File

@ -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<unknown> | 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<any>,
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<AddonModDataFieldHandler> {
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<Type<unknown> | 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);

View File

@ -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<AddonModDataEntry> {
const promises: Promise<void>[] = [];
offlineActions.forEach((action) => {
record.timemodified = action.timemodified;
record.hasOffline = true;
const offlineContents: Record<number, CoreFormFields> = {};
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<void> {
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<void>[] = [];
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<AddonModDataAction, boolean>,
): 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 = '<addon-mod-data-field-plugin [field]="fields[' + field.id + ']" [value]="entries[' + entry.id +
'].contents[' + field.id + ']" mode="' + mode + '" [database]="database" (gotoEntry)="gotoEntry(' + entry.id +
')"></addon-mod-data-field-plugin>';
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 = '<addon-mod-data-action action="' + action + '" [entry]="entries[' + entry.id + ']" mode="' + mode +
'" [database]="database" [module]="module" [offset]="' + offset + '" [group]="group" ></addon-mod-data-action>';
}
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<AddonModDataEntries> {
const site = await CoreSites.getSite(options.siteId);
options.groupId = options.groupId || 0;
options.page = options.page || 0;
const offlineActions: Record<number, AddonModDataOfflineAction[]> = {};
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<void>;
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<AddonModDataEntry>[] = [];
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<AddonModDataGetEntryFormatted> {
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<AddonModDataAction, boolean> {
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<number> {
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##<br />');
}
html.push(
'<div class="defaulttemplate">',
'<table class="mod-data-default-template ##approvalstatus##">',
'<tbody>',
);
fields.forEach((field) => {
html.push(
'<tr class="">',
'<td class="template-field cell c0" style="">',
field.name,
': </td>',
'<td class="template-token cell c1 lastcol" style="">[[',
field.name,
']]</td>',
'</tr>',
);
});
if (type == AddonModDataTemplateType.LIST) {
html.push(
'<tr class="lastrow">',
'<td class="controls template-field cell c0 lastcol" style="" colspan="2">',
'##edit## ##more## ##delete## ##approve## ##disapprove## ##export##',
'</td>',
'</tr>',
);
} else if (type == AddonModDataTemplateType.SINGLE) {
html.push(
'<tr class="lastrow">',
'<td class="controls template-field cell c0 lastcol" style="" colspan="2">',
'##edit## ##delete## ##approve## ##disapprove## ##export##',
'</td>',
'</tr>',
);
} else if (type == AddonModDataTemplateType.SEARCH) {
html.push(
'<tr class="searchcontrols">',
'<td class="template-field cell c0" style="">Author first name: </td>',
'<td class="template-token cell c1 lastcol" style="">##firstname##</td>',
'</tr>',
'<tr class="searchcontrols lastrow">',
'<td class="template-field cell c0" style="">Author surname: </td>',
'<td class="template-token cell c1 lastcol" style="">##lastname##</td>',
'</tr>',
);
}
html.push(
'</tbody>',
'</table>',
'</div>',
);
if (type == AddonModDataTemplateType.LIST) {
html.push('<hr />');
}
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<AddonModDataEntryWSField[]> {
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<FileEntry[]> {
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(
/<a ([^>]*href="[^>]*)>/ig,
(match, attributes) => '<a core-link capture="true" ' + 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<void> {
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<CoreFileUploaderStoreFilesResult> {
// 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<number | CoreFileUploaderStoreFilesResult> {
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);

View File

@ -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<void> {
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<void> {
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<void> {
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<void>[] = [];
entry.fields.forEach((field) => {
const value = CoreTextUtils.parseJSON<CoreFileUploaderStoreFilesResult>(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<AddonModDataOfflineAction[]> {
const site = await CoreSites.getSite(siteId);
const entries = await site.getDb().getAllRecords<AddonModDataEntryDBRecord>(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<AddonModDataOfflineAction[]> {
const site = await CoreSites.getSite(siteId);
const entries = await site.getDb().getRecords<AddonModDataEntryDBRecord>(
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<AddonModDataOfflineAction> {
const site = await CoreSites.getSite(siteId);
const entry = await site.getDb().getRecord<AddonModDataEntryDBRecord>(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<AddonModDataOfflineAction[]> {
const site = await CoreSites.getSite(siteId);
const entries = await site.getDb().getRecords<AddonModDataEntryDBRecord>(
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<boolean> {
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<string> {
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<string> {
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<AddonModDataEntryWSField[]>(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<AddonModDataEntryDBRecord> {
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<AddonModDataEntryDBRecord, 'fields'> & {
fields: AddonModDataEntryWSField[];
};

View File

@ -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<AddonModDataSyncResult> {
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<boolean> {
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<void> {
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<void> {
const promises: Promise<unknown>[] = [];
// 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<AddonModDataSyncResult | undefined> {
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<AddonModDataSyncResult> {
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<AddonModDataSyncResult> {
// 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<number, AddonModDataOfflineAction[]> = {};
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<void> {
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<AddonModDataSyncEntryResult> {
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<CoreFileUploaderStoreFilesResult>(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<AddonModDataSyncResult> {
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<void>[] = [];
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;
};

File diff suppressed because it is too large Load Diff

View File

@ -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<void> {
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;
};

View File

@ -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<boolean> {
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);

View File

@ -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<boolean> {
return true;
}
}

View File

@ -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<boolean> {
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);

View File

@ -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<void> => {
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<boolean> {
if (typeof params.d == 'undefined') {
// Id not defined. Cannot treat the URL.
return false;
}
return AddonModData.isPluginEnabled(siteId);
}
}
export const AddonModDataEditLinkHandler = makeSingleton(AddonModDataEditLinkHandlerService);

View File

@ -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<boolean> {
return AddonModData.isPluginEnabled(siteId);
}
}
export const AddonModDataIndexLinkHandler = makeSingleton(AddonModDataIndexLinkHandlerService);

View File

@ -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<boolean> {
return AddonModData.isPluginEnabled(siteId);
}
}
export const AddonModDataListLinkHandler = makeSingleton(AddonModDataListLinkHandlerService);

View File

@ -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<boolean> {
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<Type<unknown>> {
return AddonModDataIndexComponent;
}
}
export const AddonModDataModuleHandler = makeSingleton(AddonModDataModuleHandlerService);

View File

@ -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<AddonModDataEntry[]> {
const promises = groups.map((group) => AddonModData.fetchAllEntries(dataId, {
groupId: group.id,
...options, // Include all options.
}));
const responses = await Promise.all(promises);
const uniqueEntries: Record<number, AddonModDataEntry> = {};
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(<CoreWSExternalFile[]>content.files);
});
});
return files;
}
/**
* @inheritdoc
*/
async getFiles(module: CoreCourseAnyModuleData, courseId: number): Promise<CoreWSExternalFile[]> {
return this.getDatabaseInfoHelper(module, courseId, true).then((info) => info.files);
}
/**
* @inheritdoc
*/
async getIntroFiles(module: CoreCourseAnyModuleData, courseId: number): Promise<CoreWSExternalFile[]> {
const data = await CoreUtils.ignoreErrors(AddonModData.getDatabase(courseId, module.id));
return this.getIntroFilesFromInstance(module, data);
}
/**
* @inheritdoc
*/
async invalidateContent(moduleId: number, courseId: number): Promise<void> {
await AddonModData.invalidateContent(moduleId, courseId);
}
/**
* @inheritdoc
*/
async invalidateModule(module: CoreCourseAnyModuleData, courseId: number): Promise<void> {
const promises: Promise<void>[] = [];
promises.push(AddonModData.invalidateDatabaseData(courseId));
promises.push(AddonModData.invalidateDatabaseAccessInformationData(module.instance!));
await Promise.all(promises);
}
/**
* @inheritdoc
*/
async isDownloadable(module: CoreCourseAnyModuleData, courseId: number): Promise<boolean> {
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<boolean> {
return AddonModData.isPluginEnabled();
}
/**
* @inheritdoc
*/
prefetch(module: CoreCourseAnyModuleData, courseId?: number): Promise<void> {
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<void> {
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<unknown>[] = [];
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<AddonModDataSyncResult> {
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);

View File

@ -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<void> => {
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<boolean> {
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);

View File

@ -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<void> {
return AddonModDataSync.syncAllDatabases(siteId, force);
}
/**
* @inheritdoc
*/
getInterval(): number {
return AddonModDataSync.syncInterval;
}
}
export const AddonModDataSyncCronHandler = makeSingleton(AddonModDataSyncCronHandlerService);

View File

@ -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<boolean> {
return AddonModData.isPluginEnabled();
}
/**
* @inheritdoc
*/
parseContent(content: string): CoreTagFeedElement[] {
return CoreTagHelper.parseFeedContent(content);
}
/**
* @inheritdoc
*/
getComponent(): Type<unknown> {
return CoreTagFeedComponent;
}
}
export const AddonModDataTagAreaHandler = makeSingleton(AddonModDataTagAreaHandlerService);

View File

@ -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);

View File

@ -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 { }

View File

@ -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,

View File

@ -92,7 +92,7 @@ export type CoreRatingDBPrimaryData = {
*/
export type CoreRatingDBRecord = CoreRatingDBPrimaryData & {
itemsetid: number;
courseid?: number;
courseid: number;
scaleid: number;
rating: number;
rateduserid: number;

View File

@ -28,7 +28,7 @@ export interface CoreRatingItemSet {
contextLevel: ContextLevel;
instanceId: number;
itemSetId: number;
courseId?: number;
courseId: number;
}
/**

View File

@ -59,12 +59,12 @@ export class CoreRatingSyncProvider extends CoreSyncBaseProvider<CoreRatingSyncI
itemSetId?: number,
force?: boolean,
siteId?: string,
): Promise<CoreRatingSyncItem[]> {
): Promise<CoreRatingSyncItemResult[]> {
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.
*/

View File

@ -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,

View File

@ -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<SQLiteDB> {
return this.getSite(siteId).then((site) => site.getDb());
async getSiteDb(siteId?: string): Promise<SQLiteDB> {
const site = await this.getSite(siteId);
return site.getDb();
}
/**

View File

@ -117,7 +117,7 @@ export class CoreUtilsProvider {
* @return The object.
*/
arrayToObject<T>(
array: T[],
array: T[] = [],
propertyName?: string,
result: Record<string, T> = {},
): Record<string, T> {