MOBILE-3648 lesson: Implement sync service and prefetch handler

main
Dani Palou 2021-02-01 12:28:52 +01:00
parent 2ff5883026
commit f1fbb75889
8 changed files with 1330 additions and 2 deletions

View File

@ -0,0 +1,39 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { IonicModule } from '@ionic/angular';
import { TranslateModule } from '@ngx-translate/core';
import { CoreSharedModule } from '@/core/shared.module';
import { AddonModLessonPasswordModalComponent } from './password-modal/password-modal';
@NgModule({
declarations: [
AddonModLessonPasswordModalComponent,
],
imports: [
CommonModule,
IonicModule,
TranslateModule.forChild(),
CoreSharedModule,
],
providers: [
],
exports: [
AddonModLessonPasswordModalComponent,
],
})
export class AddonModLessonComponentsModule {}

View File

@ -0,0 +1,28 @@
<ion-header>
<ion-toolbar>
<ion-title>{{ 'core.login.password' | translate }}</ion-title>
<ion-buttons slot="end">
<ion-button (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
<core-icon slot="icon-only" name="fas-times"></core-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding addon-mod_lesson-password-modal">
<form (ngSubmit)="submitPassword($event, passwordinput)" #passwordForm>
<ion-item>
<ion-label>{{ 'addon.mod_lesson.enterpassword' | translate }}</ion-label>
<core-show-password name="password">
<ion-input name="password" type="password" placeholder="{{ 'core.login.password' | translate }}"
[core-auto-focus] #passwordinput [clearOnEdit]="false"></ion-input>
</core-show-password>
</ion-item>
<ion-button expand="block" type="submit">
{{ 'addon.mod_lesson.continue' | translate }}
<core-icon slot="end" name="fas-arrow-right"></core-icon>
</ion-button>
<!-- Remove this once Ionic fixes this bug: https://github.com/ionic-team/ionic-framework/issues/19368 -->
<input type="submit" class="core-submit-hidden-enter" />
</form>
</ion-content>

View File

@ -0,0 +1,57 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, ViewChild, ElementRef } from '@angular/core';
import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { ModalController } from '@singletons';
/**
* Modal that asks the password for a lesson.
*/
@Component({
selector: 'page-addon-mod-lesson-password-modal',
templateUrl: 'password-modal.html',
})
export class AddonModLessonPasswordModalComponent {
@ViewChild('passwordForm') formElement?: ElementRef;
/**
* Send the password back.
*
* @param e Event.
* @param password The input element.
*/
submitPassword(e: Event, password: HTMLInputElement): void {
e.preventDefault();
e.stopPropagation();
CoreDomUtils.instance.triggerFormSubmittedEvent(this.formElement, false, CoreSites.instance.getCurrentSiteId());
ModalController.instance.dismiss(password.value);
}
/**
* Close modal.
*/
closeModal(): void {
CoreDomUtils.instance.triggerFormCancelledEvent(this.formElement, CoreSites.instance.getCurrentSiteId());
ModalController.instance.dismiss();
}
}

View File

@ -12,13 +12,19 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule } from '@angular/core';
import { APP_INITIALIZER, NgModule } from '@angular/core';
import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate';
import { CoreCronDelegate } from '@services/cron';
import { CORE_SITE_SCHEMAS } from '@services/sites';
import { AddonModLessonComponentsModule } from './components/components.module';
import { SITE_SCHEMA, OFFLINE_SITE_SCHEMA } from './services/database/lesson';
import { AddonModLessonPrefetchHandler } from './services/handlers/prefetch';
import { AddonModLessonSyncCronHandler } from './services/handlers/sync-cron';
@NgModule({
imports: [
AddonModLessonComponentsModule,
],
providers: [
{
@ -26,6 +32,15 @@ import { SITE_SCHEMA, OFFLINE_SITE_SCHEMA } from './services/database/lesson';
useValue: [SITE_SCHEMA, OFFLINE_SITE_SCHEMA],
multi: true,
},
{
provide: APP_INITIALIZER,
multi: true,
deps: [],
useFactory: () => () => {
CoreCourseModulePrefetchDelegate.instance.registerHandler(AddonModLessonPrefetchHandler.instance);
CoreCronDelegate.instance.register(AddonModLessonSyncCronHandler.instance);
},
},
],
})
export class AddonModLessonModule {}

View File

@ -15,7 +15,7 @@
import { CoreSiteSchema } from '@services/sites';
/**
* Database variables for AddonModLessonOfflineProvider.
* Database variables for AddonModLessonProvider.
*/
export const PASSWORD_TABLE_NAME = 'addon_mod_lesson_password';
export const SITE_SCHEMA: CoreSiteSchema = {
@ -145,6 +145,39 @@ export const OFFLINE_SITE_SCHEMA: CoreSiteSchema = {
],
};
/**
* Database variables for AddonModLessonSyncProvider.
*/
export const RETAKES_FINISHED_SYNC_TABLE_NAME = 'addon_mod_lesson_retakes_finished_sync';
export const SYNC_SITE_SCHEMA: CoreSiteSchema = {
name: 'AddonModLessonSyncProvider',
version: 1,
tables: [
{
name: RETAKES_FINISHED_SYNC_TABLE_NAME,
columns: [
{
name: 'lessonid',
type: 'INTEGER',
primaryKey: true,
},
{
name: 'retake',
type: 'INTEGER',
},
{
name: 'pageid',
type: 'INTEGER',
},
{
name: 'timefinished',
type: 'INTEGER',
},
],
},
],
};
/**
* Lesson retake data.
*/
@ -183,3 +216,13 @@ export type AddonModLessonPageAttemptDBRecord = {
answerid: number | null;
useranswer: string | null;
};
/**
* Data about a retake finished in sync.
*/
export type AddonModLessonRetakeFinishedInSyncDBRecord = {
lessonid: number;
retake: number;
pageid: number;
timefinished: number;
};

View File

@ -0,0 +1,576 @@
// (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 { CoreCanceledError } from '@classes/errors/cancelederror';
import { CoreError } from '@classes/errors/error';
import { CoreCourseActivityPrefetchHandlerBase } from '@features/course/classes/activity-prefetch-handler';
import { CoreCourse, CoreCourseCommonModWSOptions, CoreCourseAnyModuleData } from '@features/course/services/course';
import { CoreFilepool } from '@services/filepool';
import { CoreGroups } from '@services/groups';
import { CoreFileSizeSum, CorePluginFileDelegate } from '@services/plugin-file-delegate';
import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
import { CoreUtils } from '@services/utils/utils';
import { CoreWSExternalFile } from '@services/ws';
import { makeSingleton, ModalController, Translate } from '@singletons';
import { AddonModLessonPasswordModalComponent } from '../../components/password-modal/password-modal';
import {
AddonModLesson,
AddonModLessonGetAccessInformationWSResponse,
AddonModLessonLessonWSData,
AddonModLessonPasswordOptions,
AddonModLessonProvider,
} from '../lesson';
import { AddonModLessonSync, AddonModLessonSyncResult } from '../lesson-sync';
/**
* Handler to prefetch lessons.
*/
@Injectable({ providedIn: 'root' })
export class AddonModLessonPrefetchHandlerService extends CoreCourseActivityPrefetchHandlerBase {
name = 'AddonModLesson';
modName = 'lesson';
component = AddonModLessonProvider.COMPONENT;
// Don't check timers to decrease positives. If a user performs some action it will be reflected in other items.
updatesNames = /^configuration$|^.*files$|^grades$|^gradeitems$|^pages$|^answers$|^questionattempts$|^pagesviewed$/;
/**
* Ask password.
*
* @return Promise resolved with the password.
*/
protected async askUserPassword(): Promise<string> {
// Create and show the modal.
const modal = await ModalController.instance.create({
component: AddonModLessonPasswordModalComponent,
});
await modal.present();
const password = <string | undefined> await modal.onWillDismiss();
if (typeof password != 'string') {
throw new CoreCanceledError();
}
return password;
}
/**
* Get the download size of a module.
*
* @param module Module.
* @param courseId Course ID the module belongs to.
* @param single True if we're downloading a single module, false if we're downloading a whole section.
* @return Promise resolved with the size.
*/
async getDownloadSize(module: CoreCourseAnyModuleData, courseId: number, single?: boolean): Promise<CoreFileSizeSum> {
const siteId = CoreSites.instance.getCurrentSiteId();
let lesson = await AddonModLesson.instance.getLesson(courseId, module.id, { siteId });
// Get the lesson password if it's needed.
const passwordData = await this.getLessonPassword(lesson.id, {
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
askPassword: single,
siteId,
});
lesson = passwordData.lesson || lesson;
// Get intro files and media files.
let files = lesson.mediafiles || [];
files = files.concat(this.getIntroFilesFromInstance(module, lesson));
const result = await CorePluginFileDelegate.instance.getFilesDownloadSize(files);
// Get the pages to calculate the size.
const pages = await AddonModLesson.instance.getPages(lesson.id, {
cmId: module.id,
password: passwordData.password,
siteId,
});
pages.forEach((page) => {
result.size += page.filessizetotal;
});
return result;
}
/**
* Get the lesson password if needed. If not stored, it can ask the user to enter it.
*
* @param lessonId Lesson ID.
* @param options Other options.
* @return Promise resolved when done.
*/
async getLessonPassword(
lessonId: number,
options: AddonModLessonGetPasswordOptions = {},
): Promise<AddonModLessonGetPasswordResult> {
options.siteId = options.siteId || CoreSites.instance.getCurrentSiteId();
// Get access information to check if password is needed.
const accessInfo = await AddonModLesson.instance.getAccessInformation(lessonId, options);
if (!accessInfo.preventaccessreasons.length) {
// Password not needed.
return { accessInfo };
}
const passwordNeeded = accessInfo.preventaccessreasons.length == 1 &&
AddonModLesson.instance.isPasswordProtected(accessInfo);
if (!passwordNeeded) {
// Lesson cannot be played, reject.
throw new CoreError(accessInfo.preventaccessreasons[0].message);
}
// The lesson requires a password. Check if there is one in DB.
let password = await CoreUtils.instance.ignoreErrors(AddonModLesson.instance.getStoredPassword(lessonId));
if (password) {
try {
return this.validatePassword(lessonId, accessInfo, password, options);
} catch {
// Error validating it.
}
}
// Ask for the password if allowed.
if (!options.askPassword) {
// Cannot ask for password, reject.
throw new CoreError(accessInfo.preventaccessreasons[0].message);
}
password = await this.askUserPassword();
return this.validatePassword(lessonId, accessInfo, password, options);
}
/**
* Invalidate the prefetched content.
*
* @param moduleId The module ID.
* @param courseId The course ID the module belongs to.
* @return Promise resolved when the data is invalidated.
*/
async invalidateContent(moduleId: number, courseId: number): Promise<void> {
// Only invalidate the data that doesn't ignore cache when prefetching.
await Promise.all([
AddonModLesson.instance.invalidateLessonData(courseId),
CoreCourse.instance.invalidateModule(moduleId),
CoreGroups.instance.invalidateActivityAllowedGroups(moduleId),
]);
}
/**
* Invalidate WS calls needed to determine module status.
*
* @param module Module.
* @param courseId Course ID the module belongs to.
* @return Promise resolved when invalidated.
*/
async invalidateModule(module: CoreCourseAnyModuleData, courseId: number): Promise<void> {
// Invalidate data to determine if module is downloadable.
const siteId = CoreSites.instance.getCurrentSiteId();
const lesson = await AddonModLesson.instance.getLesson(courseId, module.id, {
readingStrategy: CoreSitesReadingStrategy.PreferCache,
siteId,
});
await Promise.all([
AddonModLesson.instance.invalidateLessonData(courseId, siteId),
AddonModLesson.instance.invalidateAccessInformation(lesson.id, siteId),
]);
}
/**
* Check if a module can be downloaded. If the function is not defined, we assume that all modules are downloadable.
*
* @param module Module.
* @param courseId Course ID the module belongs to.
* @return Whether the module can be downloaded. The promise should never be rejected.
*/
async isDownloadable(module: CoreCourseAnyModuleData, courseId: number): Promise<boolean> {
const siteId = CoreSites.instance.getCurrentSiteId();
const lesson = await AddonModLesson.instance.getLesson(courseId, module.id, { siteId });
const accessInfo = await AddonModLesson.instance.getAccessInformation(lesson.id, { cmId: module.id, siteId });
// If it's a student and lesson isn't offline, it isn't downloadable.
if (!accessInfo.canviewreports && !AddonModLesson.instance.isLessonOffline(lesson)) {
return false;
}
// It's downloadable if there are no prevent access reasons or there is just 1 and it's password.
return !accessInfo.preventaccessreasons.length ||
(accessInfo.preventaccessreasons.length == 1 && AddonModLesson.instance.isPasswordProtected(accessInfo));
}
/**
* Whether or not the handler is enabled on a site level.
*
* @return Promise resolved with a boolean indicating if the handler is enabled.
*/
isEnabled(): Promise<boolean> {
return AddonModLesson.instance.isPluginEnabled();
}
/**
* Prefetch a module.
*
* @param module Module.
* @param courseId Course ID the module belongs to.
* @param single True if we're downloading a single module, false if we're downloading a whole section.
* @param dirPath Path of the directory where to store all the content files.
* @return Promise resolved when done.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
prefetch(module: CoreCourseAnyModuleData, courseId?: number, single?: boolean, dirPath?: string): Promise<void> {
return this.prefetchPackage(module, courseId, this.prefetchLesson.bind(this, module, courseId, single));
}
/**
* Prefetch a lesson.
*
* @param module Module.
* @param courseId Course ID the module belongs to.
* @param single True if we're downloading a single module, false if we're downloading a whole section.
* @return Promise resolved when done.
*/
protected async prefetchLesson(module: CoreCourseAnyModuleData, courseId?: number, single?: boolean): Promise<void> {
const siteId = CoreSites.instance.getCurrentSiteId();
courseId = courseId || module.course || 1;
const commonOptions = {
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
siteId,
};
const modOptions = {
cmId: module.id,
...commonOptions, // Include all common options.
};
let lesson = await AddonModLesson.instance.getLesson(courseId, module.id, commonOptions);
// Get the lesson password if it's needed.
const passwordData = await this.getLessonPassword(lesson.id, {
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
askPassword: single,
siteId,
});
lesson = passwordData.lesson || lesson;
let accessInfo = passwordData.accessInfo;
const password = passwordData.password;
if (AddonModLesson.instance.isLessonOffline(lesson) && !AddonModLesson.instance.leftDuringTimed(accessInfo)) {
// The user didn't left during a timed session. Call launch retake to make sure there is a started retake.
accessInfo = await this.launchRetake(lesson.id, password, modOptions, siteId);
}
const promises: Promise<void>[] = [];
// Download intro files and media files.
const files = (lesson.mediafiles || []).concat(this.getIntroFilesFromInstance(module, lesson));
promises.push(CoreFilepool.instance.addFilesToQueue(siteId, files, this.component, module.id));
if (AddonModLesson.instance.isLessonOffline(lesson)) {
promises.push(this.prefetchPlayData(lesson, password, accessInfo.attemptscount, modOptions));
}
if (accessInfo.canviewreports) {
promises.push(this.prefetchGroupInfo(module.id, lesson.id, modOptions));
promises.push(this.prefetchReportsData(module.id, lesson.id, modOptions));
}
await Promise.all(promises);
}
/**
* Launch a retake and return the updated access information.
*
* @param lessonId Lesson ID.
* @param password Password (if needed).
* @param modOptions Options.
* @param siteId Site ID.
*/
protected async launchRetake(
lessonId: number,
password: string | undefined,
modOptions: CoreCourseCommonModWSOptions,
siteId: string,
): Promise<AddonModLessonGetAccessInformationWSResponse> {
// The user didn't left during a timed session. Call launch retake to make sure there is a started retake.
await AddonModLesson.instance.launchRetake(lessonId, password, undefined, false, siteId);
const results = await Promise.all([
CoreUtils.instance.ignoreErrors(CoreFilepool.instance.updatePackageDownloadTime(siteId, this.component, module.id)),
AddonModLesson.instance.getAccessInformation(lessonId, modOptions),
]);
return results[1];
}
/**
* Prefetch data to play the lesson in offline.
*
* @param lesson Lesson.
* @param password Password (if needed).
* @param retake Retake to prefetch.
* @param options Options.
* @return Promise resolved when done.
*/
protected async prefetchPlayData(
lesson: AddonModLessonLessonWSData,
password: string | undefined,
retake: number,
modOptions: CoreCourseCommonModWSOptions,
): Promise<void> {
const passwordOptions = {
password,
...modOptions, // Include all mod options.
};
await Promise.all([
this.prefetchPagesData(lesson, passwordOptions),
// Prefetch user timers to be able to calculate timemodified in offline.
CoreUtils.instance.ignoreErrors(AddonModLesson.instance.getTimers(lesson.id, modOptions)),
// Prefetch viewed pages in last retake to calculate progress.
AddonModLesson.instance.getContentPagesViewedOnline(lesson.id, retake, modOptions),
// Prefetch question attempts in last retake for offline calculations.
AddonModLesson.instance.getQuestionsAttemptsOnline(lesson.id, retake, modOptions),
]);
}
/**
* Prefetch data related to pages.
*
* @param lesson Lesson.
* @param options Options.
* @return Promise resolved when done.
*/
protected async prefetchPagesData(
lesson: AddonModLessonLessonWSData,
options: AddonModLessonPasswordOptions,
): Promise<void> {
const pages = await AddonModLesson.instance.getPages(lesson.id, options);
let hasRandomBranch = false;
// Get the data for each page.
const promises = pages.map(async (data) => {
// Check if any page has a RANDOMBRANCH jump.
if (!hasRandomBranch) {
for (let i = 0; i < data.jumps.length; i++) {
if (data.jumps[i] == AddonModLessonProvider.LESSON_RANDOMBRANCH) {
hasRandomBranch = true;
break;
}
}
}
// Get the page data. We don't pass accessInfo because we don't need to calculate the offline data.
const pageData = await AddonModLesson.instance.getPageData(lesson, data.page.id, {
includeContents: true,
includeOfflineData: false,
...options, // Include all options.
});
// Download the page files.
let pageFiles = pageData.contentfiles || [];
pageData.answers.forEach((answer) => {
pageFiles = pageFiles.concat(answer.answerfiles);
pageFiles = pageFiles.concat(answer.responsefiles);
});
await CoreFilepool.instance.addFilesToQueue(options.siteId!, pageFiles, this.component, module.id);
});
// Prefetch the list of possible jumps for offline navigation. Do it here because we know hasRandomBranch.
promises.push(this.prefetchPossibleJumps(lesson.id, hasRandomBranch, options));
await Promise.all(promises);
}
/**
* Prefetch possible jumps.
*
* @param lessonId Lesson ID.
* @param hasRandomBranch Whether any page has a random branch jump.
* @param modOptions Options.
* @return Promise resolved when done.
*/
protected async prefetchPossibleJumps(
lessonId: number,
hasRandomBranch: boolean,
modOptions: CoreCourseCommonModWSOptions,
): Promise<void> {
try {
await AddonModLesson.instance.getPagesPossibleJumps(lessonId, modOptions);
} catch (error) {
if (hasRandomBranch) {
// The WebSevice probably failed because RANDOMBRANCH aren't supported if the user hasn't seen any page.
throw new CoreError(Translate.instance.instant('addon.mod_lesson.errorprefetchrandombranch'));
}
throw error;
}
}
/**
* Prefetch group info.
*
* @param moduleId Module ID.
* @param lessonId Lesson ID.
* @param modOptions Options.
* @return Promise resolved when done.
*/
protected async prefetchGroupInfo(
moduleId: number,
lessonId: number,
modOptions: CoreCourseCommonModWSOptions,
): Promise<void> {
const groupInfo = await CoreGroups.instance.getActivityGroupInfo(moduleId, false, undefined, modOptions.siteId, true);
await Promise.all(groupInfo.groups?.map(async (group) => {
await AddonModLesson.instance.getRetakesOverview(lessonId, {
groupId: group.id,
...modOptions, // Include all options.
});
}) || []);
}
/**
* Prefetch reports data.
*
* @param moduleId Module ID.
* @param lessonId Lesson ID.
* @param modOptions Options.
* @return Promise resolved when done.
*/
protected async prefetchReportsData(
moduleId: number,
lessonId: number,
modOptions: CoreCourseCommonModWSOptions,
): Promise<void> {
// Always get all participants, even if there are no groups.
const data = await AddonModLesson.instance.getRetakesOverview(lessonId, modOptions);
if (!data || !data.students) {
return;
}
// Prefetch the last retake for each user.
await Promise.all(data.students.map(async (student) => {
const lastRetake = student.attempts?.[student.attempts.length - 1];
if (!lastRetake) {
return;
}
const attempt = await AddonModLesson.instance.getUserRetake(lessonId, lastRetake.try, {
userId: student.id,
...modOptions, // Include all options.
});
if (!attempt?.answerpages) {
return;
}
// Download embedded files in essays.
const files: CoreWSExternalFile[] = [];
attempt.answerpages.forEach((answerPage) => {
if (!answerPage.page || answerPage.page.qtype != AddonModLessonProvider.LESSON_PAGE_ESSAY) {
return;
}
answerPage.answerdata?.answers?.forEach((answer) => {
files.push(...CoreFilepool.instance.extractDownloadableFilesFromHtmlAsFakeFileObjects(answer[0]));
});
});
await CoreFilepool.instance.addFilesToQueue(modOptions.siteId!, files, this.component, moduleId);
}));
}
/**
* Validate the password.
*
* @param lessonId Lesson ID.
* @param info Lesson access info.
* @param pwd Password to check.
* @param options Other options.
* @return Promise resolved when done.
*/
protected async validatePassword(
lessonId: number,
accessInfo: AddonModLessonGetAccessInformationWSResponse,
password: string,
options: CoreCourseCommonModWSOptions = {},
): Promise<AddonModLessonGetPasswordResult> {
options.siteId = options.siteId || CoreSites.instance.getCurrentSiteId();
const lesson = await AddonModLesson.instance.getLessonWithPassword(lessonId, {
password,
...options, // Include all options.
});
// Password is ok, store it and return the data.
await AddonModLesson.instance.storePassword(lesson.id, password, options.siteId);
return {
password,
lesson,
accessInfo,
};
}
/**
* 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.
*/
sync(module: CoreCourseAnyModuleData, courseId: number, siteId?: string): Promise<AddonModLessonSyncResult> {
return AddonModLessonSync.instance.syncLesson(module.instance!, false, false, siteId);
}
}
export class AddonModLessonPrefetchHandler extends makeSingleton(AddonModLessonPrefetchHandlerService) {}
/**
* Options to pass to get lesson password.
*/
export type AddonModLessonGetPasswordOptions = CoreCourseCommonModWSOptions & {
askPassword?: boolean; // True if we should ask for password if needed, false otherwise.
};
/**
* Result of getLessonPassword.
*/
export type AddonModLessonGetPasswordResult = {
password?: string;
lesson?: AddonModLessonLessonWSData;
accessInfo: AddonModLessonGetAccessInformationWSResponse;
};

View File

@ -0,0 +1,52 @@
// (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 { AddonModLessonSync } from '../lesson-sync';
/**
* Synchronization cron handler.
*/
@Injectable({ providedIn: 'root' })
export class AddonModLessonSyncCronHandlerService implements CoreCronHandler {
name = 'AddonModLessonSyncCronHandler';
/**
* Execute the process.
* Receives the ID of the site affected, undefined for all sites.
*
* @param siteId ID of the site affected, undefined for all sites.
* @param force Wether the execution is forced (manual sync).
* @return Promise resolved when done, rejected if failure.
*/
execute(siteId?: string, force?: boolean): Promise<void> {
return AddonModLessonSync.instance.syncAllLessons(siteId, force);
}
/**
* Get the time between consecutive executions.
*
* @return Time between consecutive executions (in ms).
*/
getInterval(): number {
return AddonModLessonSync.instance.syncInterval;
}
}
export class AddonModLessonSyncCronHandler extends makeSingleton(AddonModLessonSyncCronHandlerService) {}

View File

@ -0,0 +1,518 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { CoreError } from '@classes/errors/error';
import { CoreNetworkError } from '@classes/errors/network-error';
import { CoreCourseActivitySyncBaseProvider } from '@features/course/classes/activity-sync';
import { CoreCourse } from '@features/course/services/course';
import { CoreCourseLogHelper } from '@features/course/services/log-helper';
import { CoreApp } from '@services/app';
import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
import { CoreSync } from '@services/sync';
import { CoreTextUtils } from '@services/utils/text';
import { CoreTimeUtils } from '@services/utils/time';
import { CoreUrlUtils } from '@services/utils/url';
import { CoreUtils } from '@services/utils/utils';
import { makeSingleton, Translate } from '@singletons';
import { CoreEvents, CoreEventSiteData } from '@singletons/events';
import { AddonModLessonRetakeFinishedInSyncDBRecord, RETAKES_FINISHED_SYNC_TABLE_NAME } from './database/lesson';
import { AddonModLessonGetPasswordResult, AddonModLessonPrefetchHandler } from './handlers/prefetch';
import { AddonModLesson, AddonModLessonLessonWSData, AddonModLessonProvider } from './lesson';
import { AddonModLessonOffline, AddonModLessonPageAttemptRecord } from './lesson-offline';
/**
* Service to sync lesson.
*/
@Injectable({ providedIn: 'root' })
export class AddonModLessonSyncProvider extends CoreCourseActivitySyncBaseProvider<AddonModLessonSyncResult> {
static readonly AUTO_SYNCED = 'addon_mod_lesson_autom_synced';
protected componentTranslate?: string;
constructor() {
super('AddonModLessonSyncProvider');
}
/**
* Unmark a retake as finished in a synchronization.
*
* @param lessonId Lesson ID.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done.
*/
async deleteRetakeFinishedInSync(lessonId: number, siteId?: string): Promise<void> {
const site = await CoreSites.instance.getSite(siteId);
// Ignore errors, maybe there is none.
await CoreUtils.instance.ignoreErrors(site.getDb().deleteRecords(RETAKES_FINISHED_SYNC_TABLE_NAME, { lessonid: lessonId }));
}
/**
* Get a retake finished in a synchronization for a certain lesson (if any).
*
* @param lessonId Lesson ID.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with the retake entry (undefined if no retake).
*/
async getRetakeFinishedInSync(
lessonId: number,
siteId?: string,
): Promise<AddonModLessonRetakeFinishedInSyncDBRecord | undefined> {
const site = await CoreSites.instance.getSite(siteId);
return CoreUtils.instance.ignoreErrors(site.getDb().getRecord(RETAKES_FINISHED_SYNC_TABLE_NAME, { lessonid: lessonId }));
}
/**
* Check if a lesson has data to synchronize.
*
* @param lessonId Lesson ID.
* @param retake Retake number.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with boolean: whether it has data to sync.
*/
async hasDataToSync(lessonId: number, retake: number, siteId?: string): Promise<boolean> {
const [hasAttempts, hasFinished] = await Promise.all([
CoreUtils.instance.ignoreErrors(AddonModLessonOffline.instance.hasRetakeAttempts(lessonId, retake, siteId)),
CoreUtils.instance.ignoreErrors(AddonModLessonOffline.instance.hasFinishedRetake(lessonId, siteId)),
]);
return !!(hasAttempts || hasFinished);
}
/**
* Mark a retake as finished in a synchronization.
*
* @param lessonId Lesson ID.
* @param retake The retake number.
* @param pageId The page ID to start reviewing from.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done.
*/
async setRetakeFinishedInSync(lessonId: number, retake: number, pageId: number, siteId?: string): Promise<void> {
const site = await CoreSites.instance.getSite(siteId);
await site.getDb().insertRecord(RETAKES_FINISHED_SYNC_TABLE_NAME, <AddonModLessonRetakeFinishedInSyncDBRecord> {
lessonid: lessonId,
retake: Number(retake),
pageid: Number(pageId),
timefinished: CoreTimeUtils.instance.timestamp(),
});
}
/**
* Try to synchronize all the lessons 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.
*/
syncAllLessons(siteId?: string, force?: boolean): Promise<void> {
return this.syncOnSites('all lessons', this.syncAllLessonsFunc.bind(this, !!force), siteId);
}
/**
* Sync all lessons on a site.
*
* @param force Wether to force sync not depending on last execution.
* @param siteId Site ID to sync.
* @param Promise resolved if sync is successful, rejected if sync fails.
*/
protected async syncAllLessonsFunc(force: boolean, siteId: string): Promise<void> {
// Get all the lessons that have something to be synchronized.
const lessons = await AddonModLessonOffline.instance.getAllLessonsWithData(siteId);
// Sync all lessons that need it.
await Promise.all(lessons.map(async (lesson) => {
const result = force ?
await this.syncLesson(lesson.id, false, false, siteId) :
await this.syncLessonIfNeeded(lesson.id, false, siteId);
if (result?.updated) {
// Sync successful, send event.
CoreEvents.trigger<AddonModLessonAutoSyncData>(AddonModLessonSyncProvider.AUTO_SYNCED, {
lessonId: lesson.id,
warnings: result.warnings,
}, siteId);
}
}));
}
/**
* Sync a lesson only if a certain time has passed since the last time.
*
* @param lessonId Lesson ID.
* @param askPreflight Whether we should ask for password if needed.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when the lesson is synced or if it doesn't need to be synced.
*/
async syncLessonIfNeeded(
lessonId: number,
askPassword?: boolean,
siteId?: string,
): Promise<AddonModLessonSyncResult | undefined> {
const needed = await this.isSyncNeeded(lessonId, siteId);
if (needed) {
return this.syncLesson(lessonId, askPassword, false, siteId);
}
}
/**
* Try to synchronize a lesson.
*
* @param lessonId Lesson ID.
* @param askPassword True if we should ask for password if needed, false otherwise.
* @param ignoreBlock True to ignore the sync block setting.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved in success.
*/
async syncLesson(
lessonId: number,
askPassword?: boolean,
ignoreBlock?: boolean,
siteId?: string,
): Promise<AddonModLessonSyncResult> {
siteId = siteId || CoreSites.instance.getCurrentSiteId();
this.componentTranslate = this.componentTranslate || CoreCourse.instance.translateModuleName('lesson');
let syncPromise = this.getOngoingSync(lessonId, siteId);
if (syncPromise) {
// There's already a sync ongoing for this lesson, return the promise.
return syncPromise;
}
// Verify that lesson isn't blocked.
if (!ignoreBlock && CoreSync.instance.isBlocked(AddonModLessonProvider.COMPONENT, lessonId, siteId)) {
this.logger.debug('Cannot sync lesson ' + lessonId + ' because it is blocked.');
throw new CoreError(Translate.instance.instant('core.errorsyncblocked', { $a: this.componentTranslate }));
}
this.logger.debug('Try to sync lesson ' + lessonId + ' in site ' + siteId);
syncPromise = this.performSyncLesson(lessonId, askPassword, ignoreBlock, siteId);
return this.addOngoingSync(lessonId, syncPromise, siteId);
}
/**
* Try to synchronize a lesson.
*
* @param lessonId Lesson ID.
* @param askPassword True if we should ask for password if needed, false otherwise.
* @param ignoreBlock True to ignore the sync block setting.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved in success.
*/
protected async performSyncLesson(
lessonId: number,
askPassword?: boolean,
ignoreBlock?: boolean,
siteId?: string,
): Promise<AddonModLessonSyncResult> {
// Sync offline logs.
await CoreUtils.instance.ignoreErrors(
CoreCourseLogHelper.instance.syncActivity(AddonModLessonProvider.COMPONENT, lessonId, siteId),
);
const result: AddonModLessonSyncResult = {
warnings: [],
updated: false,
};
// Try to synchronize the page attempts first.
const passwordData = await this.syncAttempts(lessonId, result, askPassword, siteId);
// Now sync the retake.
await this.syncRetake(lessonId, result, passwordData, askPassword, ignoreBlock, siteId);
if (result.updated && result.courseId) {
try {
// Data has been sent to server, update data.
const module = await CoreCourse.instance.getModuleBasicInfoByInstance(lessonId, 'lesson', siteId);
await this.prefetchAfterUpdate(AddonModLessonPrefetchHandler.instance, module, result.courseId, undefined, siteId);
} catch {
// Ignore errors.
}
}
// Sync finished, set sync time.
await CoreUtils.instance.ignoreErrors(this.setSyncTime(String(lessonId), siteId));
// All done, return the result.
return result;
}
/**
* Sync all page attempts.
*
* @param lessonId Lesson ID.
* @param result Sync result where to store the result.
* @param askPassword True if we should ask for password if needed, false otherwise.
* @param siteId Site ID. If not defined, current site.
*/
protected async syncAttempts(
lessonId: number,
result: AddonModLessonSyncResult,
askPassword?: boolean,
siteId?: string,
): Promise<AddonModLessonGetPasswordResult | undefined> {
let attempts = await AddonModLessonOffline.instance.getLessonAttempts(lessonId, siteId);
if (!attempts.length) {
return;
} else if (!CoreApp.instance.isOnline()) {
// Cannot sync in offline.
throw new CoreNetworkError();
}
result.courseId = attempts[0].courseid;
const attemptsLength = attempts.length;
// Get the info, access info and the lesson password if needed.
const lesson = await AddonModLesson.instance.getLessonById(result.courseId, lessonId, { siteId });
const passwordData = await AddonModLessonPrefetchHandler.instance.getLessonPassword(lessonId, {
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
askPassword,
siteId,
});
const promises: Promise<void>[] = [];
passwordData.lesson = passwordData.lesson || lesson;
// Filter the attempts, get only the ones that belong to the current retake.
attempts = attempts.filter((attempt) => {
if (attempt.retake == passwordData.accessInfo.attemptscount) {
return true;
}
// Attempt doesn't belong to current retake, delete.
promises.push(CoreUtils.instance.ignoreErrors(AddonModLessonOffline.instance.deleteAttempt(
lesson.id,
attempt.retake,
attempt.pageid,
attempt.timemodified,
siteId,
)));
return false;
});
if (attempts.length != attemptsLength) {
// Some attempts won't be sent, add a warning.
result.warnings.push(Translate.instance.instant('core.warningofflinedatadeleted', {
component: this.componentTranslate,
name: lesson.name,
error: Translate.instance.instant('addon.mod_lesson.warningretakefinished'),
}));
}
await Promise.all(promises);
if (!attempts.length) {
return passwordData;
}
// Send the attempts in the same order they were answered.
attempts.sort((a, b) => a.timemodified - b.timemodified);
const promisesData = attempts.map((attempt) => ({
function: this.sendAttempt.bind(this, lesson, passwordData.password, attempt, result, siteId),
blocking: true,
}));
await CoreUtils.instance.executeOrderedPromises(promisesData);
return passwordData;
}
/**
* Send an attempt to the site and delete it afterwards.
*
* @param lesson Lesson.
* @param password Password (if any).
* @param attempt Attempt to send.
* @param result Result where to store the data.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done.
*/
protected async sendAttempt(
lesson: AddonModLessonLessonWSData,
password: string,
attempt: AddonModLessonPageAttemptRecord,
result: AddonModLessonSyncResult,
siteId?: string,
): Promise<void> {
const retake = attempt.retake;
const pageId = attempt.pageid;
const timemodified = attempt.timemodified;
try {
// Send the page data.
await AddonModLesson.instance.processPageOnline(lesson.id, attempt.pageid, attempt.data || {}, {
password,
siteId,
});
result.updated = true;
await AddonModLessonOffline.instance.deleteAttempt(lesson.id, retake, pageId, timemodified, siteId);
} catch (error) {
if (!error || !CoreUtils.instance.isWebServiceError(error)) {
// Couldn't connect to server.
throw error;
}
// The WebService has thrown an error, this means that the attempt cannot be submitted. Delete it.
result.updated = true;
await AddonModLessonOffline.instance.deleteAttempt(lesson.id, retake, pageId, timemodified, siteId);
// Attempt deleted, add a warning.
result.warnings.push(Translate.instance.instant('core.warningofflinedatadeleted', {
component: this.componentTranslate,
name: lesson.name,
error: CoreTextUtils.instance.getErrorMessageFromError(error),
}));
}
}
/**
* Sync retake.
*
* @param lessonId Lesson ID.
* @param result Sync result where to store the result.
* @param passwordData Password data. If not provided it will be calculated.
* @param askPassword True if we should ask for password if needed, false otherwise.
* @param ignoreBlock True to ignore the sync block setting.
* @param siteId Site ID. If not defined, current site.
*/
protected async syncRetake(
lessonId: number,
result: AddonModLessonSyncResult,
passwordData?: AddonModLessonGetPasswordResult,
askPassword?: boolean,
ignoreBlock?: boolean,
siteId?: string,
): Promise<void> {
// Attempts sent or there was none. If there is a finished retake, send it.
const retake = await CoreUtils.instance.ignoreErrors(AddonModLessonOffline.instance.getRetake(lessonId, siteId));
if (!retake) {
// No retake to sync.
return;
}
if (!retake.finished) {
// The retake isn't marked as finished, nothing to send. Delete the retake.
await AddonModLessonOffline.instance.deleteRetake(lessonId, siteId);
return;
} else if (!CoreApp.instance.isOnline()) {
// Cannot sync in offline.
throw new CoreNetworkError();
}
result.courseId = retake.courseid || result.courseId;
if (!passwordData?.lesson) {
// Retrieve the needed data.
const lesson = await AddonModLesson.instance.getLessonById(result.courseId!, lessonId, { siteId });
passwordData = await AddonModLessonPrefetchHandler.instance.getLessonPassword(lessonId, {
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
askPassword,
siteId,
});
passwordData.lesson = passwordData.lesson || lesson;
}
if (retake.retake != passwordData.accessInfo.attemptscount) {
// The retake changed, add a warning if it isn't there already.
if (!result.warnings.length) {
result.warnings.push(Translate.instance.instant('core.warningofflinedatadeleted', {
component: this.componentTranslate,
name: passwordData.lesson.name,
error: Translate.instance.instant('addon.mod_lesson.warningretakefinished'),
}));
}
await AddonModLessonOffline.instance.deleteRetake(lessonId, siteId);
}
try {
// All good, finish the retake.
const response = await AddonModLesson.instance.finishRetakeOnline(lessonId, {
password: passwordData.password,
siteId,
});
result.updated = true;
// Mark the retake as finished in a sync if it can be reviewed.
if (!ignoreBlock && response.data?.reviewlesson) {
const params = CoreUrlUtils.instance.extractUrlParams(<string> response.data.reviewlesson.value);
if (params.pageid) {
// The retake can be reviewed, mark it as finished. Don't block the user for this.
this.setRetakeFinishedInSync(lessonId, retake.retake, Number(params.pageid), siteId);
}
}
await AddonModLessonOffline.instance.deleteRetake(lessonId, siteId);
} catch (error) {
if (!error || !CoreUtils.instance.isWebServiceError(error)) {
// Couldn't connect to server.
throw error;
}
// The WebService has thrown an error, this means that responses cannot be submitted. Delete them.
result.updated = true;
await AddonModLessonOffline.instance.deleteRetake(lessonId, siteId);
// Retake deleted, add a warning.
result.warnings.push(Translate.instance.instant('core.warningofflinedatadeleted', {
component: this.componentTranslate,
name: passwordData.lesson.name,
error: CoreTextUtils.instance.getErrorMessageFromError(error),
}));
}
}
}
export class AddonModLessonSync extends makeSingleton(AddonModLessonSyncProvider) {}
/**
* Data returned by a lesson sync.
*/
export type AddonModLessonSyncResult = {
warnings: string[]; // List of warnings.
updated: boolean; // Whether some data was sent to the server or offline data was updated.
courseId?: number; // Course the lesson belongs to (if known).
};
/**
* Data passed to AUTO_SYNCED event.
*/
export type AddonModLessonAutoSyncData = CoreEventSiteData & {
lessonId: number;
warnings: string[];
};