From 29062102425ee5b15703f53ec87fa14ef3d01f10 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 14 Jan 2021 09:13:37 +0100 Subject: [PATCH] MOBILE-3659 course: Implement log helper --- src/core/features/course/course.module.ts | 6 +- .../services/course-options-delegate.ts | 18 +- src/core/features/course/services/course.ts | 1 - .../features/course/services/database/log.ts | 60 +++ .../features/course/services/log-helper.ts | 365 ++++++++++++++++++ 5 files changed, 438 insertions(+), 12 deletions(-) create mode 100644 src/core/features/course/services/database/log.ts create mode 100644 src/core/features/course/services/log-helper.ts diff --git a/src/core/features/course/course.module.ts b/src/core/features/course/course.module.ts index 728272224..a064ab67e 100644 --- a/src/core/features/course/course.module.ts +++ b/src/core/features/course/course.module.ts @@ -17,15 +17,15 @@ import { NgModule } from '@angular/core'; import { CORE_SITE_SCHEMAS } from '@services/sites'; import { SITE_SCHEMA, OFFLINE_SITE_SCHEMA } from './services/database/course'; +import { SITE_SCHEMA as LOG_SITE_SCHEMA } from './services/database/log'; @NgModule({ providers: [ { provide: CORE_SITE_SCHEMAS, - useValue: [SITE_SCHEMA, OFFLINE_SITE_SCHEMA], + useValue: [SITE_SCHEMA, OFFLINE_SITE_SCHEMA, LOG_SITE_SCHEMA], multi: true, }, ], }) -export class CoreCourseModule { -} +export class CoreCourseModule {} diff --git a/src/core/features/course/services/course-options-delegate.ts b/src/core/features/course/services/course-options-delegate.ts index 2b13c9efe..b878c692a 100644 --- a/src/core/features/course/services/course-options-delegate.ts +++ b/src/core/features/course/services/course-options-delegate.ts @@ -47,8 +47,9 @@ export interface CoreCourseOptionsHandler extends CoreDelegateHandler { * @param admOptions Course admin options for current user. See CoreCoursesProvider.getUserAdministrationOptions. * @return True or promise resolved with true if enabled. */ - isEnabledForCourse(courseId: number, - accessData: CoreCourseAccessData, + isEnabledForCourse( + courseId: number, + accessData: CoreCourseAccess, navOptions?: CoreCourseUserAdminOrNavOptionIndexed, admOptions?: CoreCourseUserAdminOrNavOptionIndexed, ): boolean | Promise; @@ -56,7 +57,7 @@ export interface CoreCourseOptionsHandler extends CoreDelegateHandler { /** * Returns the data needed to render the handler. * - * @param course The course. // @todo: define type in the whole file. + * @param course The course. * @return Data or promise resolved with the data. */ getDisplayData?( @@ -226,7 +227,7 @@ export class CoreCourseOptionsDelegateService extends CoreDelegate; @@ -320,7 +321,7 @@ export class CoreCourseOptionsDelegateService extends CoreDelegate { @@ -618,7 +619,7 @@ export class CoreCourseOptionsDelegateService extends CoreDelegate { @@ -673,5 +674,6 @@ export class CoreCourseOptionsDelegateService extends CoreDelegate { const params: CoreCourseViewCourseWSParams = { diff --git a/src/core/features/course/services/database/log.ts b/src/core/features/course/services/database/log.ts new file mode 100644 index 000000000..1031ff3d4 --- /dev/null +++ b/src/core/features/course/services/database/log.ts @@ -0,0 +1,60 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreSiteSchema } from '@services/sites'; + +/** + * Database variables for CoreCourse service. + */ +export const ACTIVITY_LOG_TABLE = 'course_activity_log'; +export const SITE_SCHEMA: CoreSiteSchema = { + name: 'CoreCourseLogHelperProvider', + version: 1, + tables: [ + { + name: ACTIVITY_LOG_TABLE, + columns: [ + { + name: 'component', + type: 'TEXT', + }, + { + name: 'componentid', + type: 'INTEGER', + }, + { + name: 'ws', + type: 'TEXT', + }, + { + name: 'data', + type: 'TEXT', + }, + { + name: 'time', + type: 'INTEGER', + } + ], + primaryKeys: ['component', 'componentid', 'ws', 'time'], + }, + ], +}; + +export type CoreCourseActivityLogDBRecord = { + component: string; + componentid: number; + ws: string; + time: number; + data?: string; +}; diff --git a/src/core/features/course/services/log-helper.ts b/src/core/features/course/services/log-helper.ts new file mode 100644 index 000000000..346bcc12d --- /dev/null +++ b/src/core/features/course/services/log-helper.ts @@ -0,0 +1,365 @@ +// (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 { CoreApp } from '@services/app'; +import { CoreSites } from '@services/sites'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreUtils } from '@services/utils/utils'; +import { CorePushNotifications } from '@features/pushnotifications/services/pushnotifications'; +import { makeSingleton } from '@singletons'; +import { ACTIVITY_LOG_TABLE, CoreCourseActivityLogDBRecord } from './database/log'; +import { CoreStatusWithWarningsWSResponse } from '@services/ws'; +import { CoreWSError } from '@classes/errors/wserror'; + +/** + * Helper to manage logging to Moodle. + */ +@Injectable({ providedIn: 'root' }) +export class CoreCourseLogHelperProvider { + + /** + * Delete the offline saved activity logs. + * + * @param component Component name. + * @param componentId Component ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when deleted, rejected if failure. + */ + protected async deleteLogs(component: string, componentId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const conditions: Partial = { + component, + componentid: componentId, + }; + + await site.getDb().deleteRecords(ACTIVITY_LOG_TABLE, conditions); + } + + /** + * Delete a WS based log. + * + * @param component Component name. + * @param componentId Component ID. + * @param ws WS name. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when deleted, rejected if failure. + */ + protected async deleteWSLogsByComponent(component: string, componentId: number, ws: string, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const conditions: Partial = { + component, + componentid: componentId, + ws, + }; + + await site.getDb().deleteRecords(ACTIVITY_LOG_TABLE, conditions); + } + + /** + * Delete the offline saved activity logs using call data. + * + * @param ws WS name. + * @param data Data to send to the WS. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when deleted, rejected if failure. + */ + protected async deleteWSLogs(ws: string, data: Record, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const conditions: Partial = { + ws, + data: CoreUtils.instance.sortAndStringify(data), + }; + + await site.getDb().deleteRecords(ACTIVITY_LOG_TABLE, conditions); + } + + /** + * Get all the offline saved activity logs. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the list of offline logs. + */ + protected async getAllLogs(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return site.getDb().getAllRecords(ACTIVITY_LOG_TABLE); + } + + /** + * Get the offline saved activity logs. + * + * @param component Component name. + * @param componentId Component ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the list of offline logs. + */ + protected async getLogs(component: string, componentId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const conditions: Partial = { + component, + componentid: componentId, + }; + + return site.getDb().getRecords(ACTIVITY_LOG_TABLE, conditions); + } + + /** + * Perform log online. Data will be saved offline for syncing. + * + * @param ws WS name. + * @param data Data to send to the WS. + * @param component Component name. + * @param componentId Component ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async log(ws: string, data: Record, component: string, componentId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + if (!CoreApp.instance.isOnline()) { + // App is offline, store the action. + return this.storeOffline(ws, data, component, componentId, site.getId()); + } + + try { + await this.logOnline(ws, data, site.getId()); + } catch (error) { + if (CoreUtils.instance.isWebServiceError(error)) { + // The WebService has thrown an error, this means that responses cannot be submitted. + throw error; + } + + // Couldn't connect to server, store in offline. + return this.storeOffline(ws, data, component, componentId, site.getId()); + } + } + + /** + * Perform the log online. + * + * @param ws WS name. + * @param data Data to send to the WS. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when log is successfully submitted. Rejected with object containing + * the error message (if any) and a boolean indicating if the error was returned by WS. + */ + protected async logOnline( + ws: string, + data: Record, + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + + // Clone to have an unmodified data object. + const wsData = Object.assign({}, data); + + const response = await site.write(ws, wsData); + + if (!response.status) { + // Return the warning. If no warnings (shouldn't happen), create a fake one. + const warning = response.warnings?.[0] || { + warningcode: 'errorlog', + message: 'Error logging data.', + }; + + throw new CoreWSError(warning); + } + + // Remove all the logs performed. + // TODO: Remove this lines when time is accepted in logs. + await this.deleteWSLogs(ws, data, siteId); + } + + /** + * Perform log online. Data will be saved offline for syncing. + * It also triggers a Firebase view_item event. + * + * @param ws WS name. + * @param data Data to send to the WS. + * @param component Component name. + * @param componentId Component ID. + * @param name Name of the viewed item. + * @param category Category of the viewed item. + * @param eventData Data to pass to the Firebase event. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + logSingle( + ws: string, + data: Record, + component: string, + componentId: number, + name?: string, + category?: string, + eventData?: Record, + siteId?: string, + ): Promise { + CorePushNotifications.instance.logViewEvent(componentId, name, category, ws, eventData, siteId); + + return this.log(ws, data, component, componentId, siteId); + } + + /** + * Perform log online. Data will be saved offline for syncing. + * It also triggers a Firebase view_item_list event. + * + * @param ws WS name. + * @param data Data to send to the WS. + * @param component Component name. + * @param componentId Component ID. + * @param category Category of the viewed item. + * @param eventData Data to pass to the Firebase event. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + logList( + ws: string, + data: Record, + component: string, + componentId: number, + category: string, + eventData?: Record, + siteId?: string, + ): Promise { + CorePushNotifications.instance.logViewListEvent(category, ws, eventData, siteId); + + return this.log(ws, data, component, componentId, siteId); + } + + /** + * Save activity log for offline sync. + * + * @param ws WS name. + * @param data Data to send to the WS. + * @param component Component name. + * @param componentId Component ID. + * @param siteId Site ID. If not defined, current site. + * @return Resolved when done. + */ + protected async storeOffline( + ws: string, + data: Record, + component: string, + componentId: number, + siteId?: string, + ): Promise { + + const site = await CoreSites.instance.getSite(siteId); + + const log: CoreCourseActivityLogDBRecord = { + component, + componentid: componentId, + ws, + data: CoreUtils.instance.sortAndStringify(data), + time: CoreTimeUtils.instance.timestamp(), + }; + + await site.getDb().insertRecord(ACTIVITY_LOG_TABLE, log); + } + + /** + * Sync all the offline saved activity logs. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async syncSite(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + siteId = site.getId(); + + const logs = await this.getAllLogs(siteId); + + const unique: CoreCourseActivityLogDBRecord[] = []; + + // TODO: When time is accepted on log, do not discard same logs. + logs.forEach((log) => { + // Just perform unique syncs. + const found = unique.find((doneLog) => log.component == doneLog.component && log.componentid == doneLog.componentid && + log.ws == doneLog.ws && log.data == doneLog.data); + + if (!found) { + unique.push(log); + } + }); + + return this.syncLogs(unique, siteId); + } + + /** + * Sync the offline saved activity logs. + * + * @param component Component name. + * @param componentId Component ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async syncActivity(component: string, componentId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + siteId = site.getId(); + + const logs = await this.getLogs(component, componentId, siteId); + + const unique: CoreCourseActivityLogDBRecord[] = []; + + // TODO: When time is accepted on log, do not discard same logs. + logs.forEach((log) => { + // Just perform unique syncs. + const found = unique.find((doneLog) => log.ws == doneLog.ws && log.data == doneLog.data); + + if (!found) { + unique.push(log); + } + }); + + return this.syncLogs(unique, siteId); + } + + /** + * Sync and delete given logs. + * + * @param logs Array of log objects. + * @param siteId Site Id. + * @return Promise resolved when done. + */ + protected async syncLogs(logs: CoreCourseActivityLogDBRecord[], siteId: string): Promise { + await Promise.all(logs.map(async (log) => { + const data = CoreTextUtils.instance.parseJSON>(log.data || '{}', {}); + + try { + await this.logOnline(log.ws, data, siteId); + } catch (error) { + if (CoreUtils.instance.isWebServiceError(error)) { + // The WebService has thrown an error, this means that responses cannot be submitted. + await CoreUtils.instance.ignoreErrors(this.deleteWSLogs(log.ws, data, siteId)); + } + + throw error; + } + + await this.deleteWSLogsByComponent(log.component, log.componentid, log.ws, siteId); + })); + } + +} + +export class CoreCourseLogHelper extends makeSingleton(CoreCourseLogHelperProvider) {}