diff --git a/src/addon/mod/assign/classes/feedback-plugin-component.ts b/src/addon/mod/assign/classes/feedback-plugin-component.ts index 847e7fe5a..dfb9250cd 100644 --- a/src/addon/mod/assign/classes/feedback-plugin-component.ts +++ b/src/addon/mod/assign/classes/feedback-plugin-component.ts @@ -18,7 +18,7 @@ import { ModalController } from 'ionic-angular'; /** * Base class for component to render a feedback plugin. */ -export class AddonModAssignFeedbackPluginComponent { +export class AddonModAssignFeedbackPluginComponentBase { @Input() assign: any; // The assignment. @Input() submission: any; // The submission. @Input() plugin: any; // The plugin object. diff --git a/src/addon/mod/assign/components/index/index.html b/src/addon/mod/assign/components/index/index.html index 900d3bb60..957d7ff15 100644 --- a/src/addon/mod/assign/components/index/index.html +++ b/src/addon/mod/assign/components/index/index.html @@ -76,7 +76,7 @@
- + {{ 'addon.mod_assign.ungroupedusers' | translate }}
diff --git a/src/addon/mod/assign/feedback/comments/component/comments.ts b/src/addon/mod/assign/feedback/comments/component/comments.ts index 8c4aec3ae..0460e0c81 100644 --- a/src/addon/mod/assign/feedback/comments/component/comments.ts +++ b/src/addon/mod/assign/feedback/comments/component/comments.ts @@ -20,7 +20,7 @@ import { CoreTextUtilsProvider } from '@providers/utils/text'; import { AddonModAssignProvider } from '../../../providers/assign'; import { AddonModAssignOfflineProvider } from '../../../providers/assign-offline'; import { AddonModAssignFeedbackDelegate } from '../../../providers/feedback-delegate'; -import { AddonModAssignFeedbackPluginComponent } from '../../../classes/feedback-plugin-component'; +import { AddonModAssignFeedbackPluginComponentBase } from '../../../classes/feedback-plugin-component'; import { AddonModAssignFeedbackCommentsHandler } from '../providers/handler'; /** @@ -30,7 +30,7 @@ import { AddonModAssignFeedbackCommentsHandler } from '../providers/handler'; selector: 'addon-mod-assign-feedback-comments', templateUrl: 'comments.html' }) -export class AddonModAssignFeedbackCommentsComponent extends AddonModAssignFeedbackPluginComponent implements OnInit { +export class AddonModAssignFeedbackCommentsComponent extends AddonModAssignFeedbackPluginComponentBase implements OnInit { control: FormControl; component = AddonModAssignProvider.COMPONENT; diff --git a/src/addon/mod/assign/feedback/editpdf/component/editpdf.ts b/src/addon/mod/assign/feedback/editpdf/component/editpdf.ts index 4ccf24e5b..32b6f81cb 100644 --- a/src/addon/mod/assign/feedback/editpdf/component/editpdf.ts +++ b/src/addon/mod/assign/feedback/editpdf/component/editpdf.ts @@ -15,7 +15,7 @@ import { Component, OnInit } from '@angular/core'; import { ModalController } from 'ionic-angular'; import { AddonModAssignProvider } from '../../../providers/assign'; -import { AddonModAssignFeedbackPluginComponent } from '../../../classes/feedback-plugin-component'; +import { AddonModAssignFeedbackPluginComponentBase } from '../../../classes/feedback-plugin-component'; /** * Component to render a edit pdf feedback plugin. @@ -24,7 +24,7 @@ import { AddonModAssignFeedbackPluginComponent } from '../../../classes/feedback selector: 'addon-mod-assign-feedback-edit-pdf', templateUrl: 'editpdf.html' }) -export class AddonModAssignFeedbackEditPdfComponent extends AddonModAssignFeedbackPluginComponent implements OnInit { +export class AddonModAssignFeedbackEditPdfComponent extends AddonModAssignFeedbackPluginComponentBase implements OnInit { component = AddonModAssignProvider.COMPONENT; files: any[]; diff --git a/src/addon/mod/assign/feedback/file/component/file.ts b/src/addon/mod/assign/feedback/file/component/file.ts index 6074bc2f2..d9456ff28 100644 --- a/src/addon/mod/assign/feedback/file/component/file.ts +++ b/src/addon/mod/assign/feedback/file/component/file.ts @@ -15,7 +15,7 @@ import { Component, OnInit } from '@angular/core'; import { ModalController } from 'ionic-angular'; import { AddonModAssignProvider } from '../../../providers/assign'; -import { AddonModAssignFeedbackPluginComponent } from '../../../classes/feedback-plugin-component'; +import { AddonModAssignFeedbackPluginComponentBase } from '../../../classes/feedback-plugin-component'; /** * Component to render a file feedback plugin. @@ -24,7 +24,7 @@ import { AddonModAssignFeedbackPluginComponent } from '../../../classes/feedback selector: 'addon-mod-assign-feedback-file', templateUrl: 'file.html' }) -export class AddonModAssignFeedbackFileComponent extends AddonModAssignFeedbackPluginComponent implements OnInit { +export class AddonModAssignFeedbackFileComponent extends AddonModAssignFeedbackPluginComponentBase implements OnInit { component = AddonModAssignProvider.COMPONENT; files: any[]; diff --git a/src/addon/mod/choice/components/index/index.html b/src/addon/mod/choice/components/index/index.html index 1e95f3cad..b89c63214 100644 --- a/src/addon/mod/choice/components/index/index.html +++ b/src/addon/mod/choice/components/index/index.html @@ -34,7 +34,7 @@
- + {{ publishInfo | translate }}
diff --git a/src/addon/mod/lesson/components/components.module.ts b/src/addon/mod/lesson/components/components.module.ts new file mode 100644 index 000000000..0acaaf970 --- /dev/null +++ b/src/addon/mod/lesson/components/components.module.ts @@ -0,0 +1,45 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { CoreCourseComponentsModule } from '@core/course/components/components.module'; +import { AddonModLessonIndexComponent } from './index/index'; + +@NgModule({ + declarations: [ + AddonModLessonIndexComponent + ], + imports: [ + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule, + CoreCourseComponentsModule + ], + providers: [ + ], + exports: [ + AddonModLessonIndexComponent + ], + entryComponents: [ + AddonModLessonIndexComponent + ] +}) +export class AddonModLessonComponentsModule {} diff --git a/src/addon/mod/lesson/components/index/index.html b/src/addon/mod/lesson/components/index/index.html new file mode 100644 index 000000000..2bb0a5f6f --- /dev/null +++ b/src/addon/mod/lesson/components/index/index.html @@ -0,0 +1,247 @@ + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ + +
+ + {{ 'core.hasdatatosync' | translate: {$a: moduleName} }} +
+ + + +
+ + + {{ 'addon.mod_lesson.enterpassword' | translate }} + + + + + + +
+
+ + + + + +

{{ 'addon.mod_lesson.retakefinishedinsync' | translate }}

+ {{ 'addon.mod_lesson.review' | translate }} +
+ + + +

+ + + + {{ 'core.no' | translate }} + + + {{ 'core.yes' | translate }} + + + +
+ + + +

+ + {{ 'addon.mod_lesson.continue' | translate }} + + +
+ + + +

+
+ + + + + {{ 'core.start' | translate }} + + + + {{ 'addon.mod_lesson.preview' | translate }} + + + +
+
+
+
+ + + + + + + + {{ 'core.groupsseparate' | translate }} + {{ 'core.groupsvisible' | translate }} + + {{groupOpt.name}} + + + + + + + + + + + + + {{ 'addon.mod_lesson.lessonstats' | translate }} + + + + + + + +

{{ 'addon.mod_lesson.averagescore' | translate }}

+

{{ 'core.percentagenumber' | translate:{$a: overview.avescore} }}

+

{{ 'addon.mod_lesson.notcompleted' | translate }}

+
+ + +

{{ 'addon.mod_lesson.highscore' | translate }}

+

{{ 'core.percentagenumber' | translate:{$a: overview.highscore} }}

+

{{ 'addon.mod_lesson.notcompleted' | translate }}

+
+ + +

{{ 'addon.mod_lesson.lowscore' | translate }}

+

{{ 'core.percentagenumber' | translate:{$a: overview.lowscore} }}

+

{{ 'addon.mod_lesson.notcompleted' | translate }}

+
+
+
+ + + + +

{{ 'addon.mod_lesson.averagetime' | translate }}

+

{{ overview.avetimeReadable }}

+

{{ 'addon.mod_lesson.notcompleted' | translate }}

+
+ + +

{{ 'addon.mod_lesson.hightime' | translate }}

+

{{ overview.hightimeReadable }}

+

{{ 'addon.mod_lesson.notcompleted' | translate }}

+
+ + +

{{ 'addon.mod_lesson.lowtime' | translate }}

+

{{ overview.lowtimeReadable }}

+

{{ 'addon.mod_lesson.notcompleted' | translate }}

+
+
+
+
+ + + + + + +

{{ 'addon.mod_lesson.averagescore' | translate }}

+

{{ 'core.percentagenumber' | translate:{$a: overview.avescore} }}

+

{{ 'addon.mod_lesson.notcompleted' | translate }}

+
+ + +

{{ 'addon.mod_lesson.averagetime' | translate }}

+

{{ overview.avetimeReadable }}

+

{{ 'addon.mod_lesson.notcompleted' | translate }}

+
+
+
+ + + + +

{{ 'addon.mod_lesson.highscore' | translate }}

+

{{ 'core.percentagenumber' | translate:{$a: overview.highscore} }}

+

{{ 'addon.mod_lesson.notcompleted' | translate }}

+
+ + +

{{ 'addon.mod_lesson.hightime' | translate }}

+

{{ overview.hightimeReadable }}

+

{{ 'addon.mod_lesson.notcompleted' | translate }}

+
+
+
+ + + + +

{{ 'addon.mod_lesson.lowscore' | translate }}

+

{{ 'core.percentagenumber' | translate:{$a: overview.lowscore} }}

+

{{ 'addon.mod_lesson.notcompleted' | translate }}

+
+ + +

{{ 'addon.mod_lesson.lowtime' | translate }}

+

{{ overview.lowtimeReadable }}

+

{{ 'addon.mod_lesson.notcompleted' | translate }}

+
+
+
+
+
+ + + + + {{ 'addon.mod_lesson.overview' | translate }} + + + + + + +

{{ student.fullname }}

+ +
+
+
+
+
+
+
diff --git a/src/addon/mod/lesson/components/index/index.scss b/src/addon/mod/lesson/components/index/index.scss new file mode 100644 index 000000000..bee1246fe --- /dev/null +++ b/src/addon/mod/lesson/components/index/index.scss @@ -0,0 +1,2 @@ +addon-mod-lesson-index { +} diff --git a/src/addon/mod/lesson/components/index/index.ts b/src/addon/mod/lesson/components/index/index.ts new file mode 100644 index 000000000..b3b21b9da --- /dev/null +++ b/src/addon/mod/lesson/components/index/index.ts @@ -0,0 +1,552 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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, Optional, Injector, Input } from '@angular/core'; +import { Content, NavController } from 'ionic-angular'; +import { CoreGroupsProvider, CoreGroupInfo } from '@providers/groups'; +import { CoreTimeUtilsProvider } from '@providers/utils/time'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreCourseModuleMainActivityComponent } from '@core/course/classes/main-activity-component'; +import { CoreUserProvider } from '@core/user/providers/user'; +import { AddonModLessonProvider } from '../../providers/lesson'; +import { AddonModLessonOfflineProvider } from '../../providers/lesson-offline'; +import { AddonModLessonSyncProvider } from '../../providers/lesson-sync'; +import { AddonModLessonPrefetchHandler } from '../../providers/prefetch-handler'; +import { CoreConstants } from '@core/constants'; + +/** + * Component that displays a lesson entry page. + */ +@Component({ + selector: 'addon-mod-lesson-index', + templateUrl: 'index.html', +}) +export class AddonModLessonIndexComponent extends CoreCourseModuleMainActivityComponent { + @Input() group: number; // The group to display. + @Input() action: string; // The "action" to display first. + + component = AddonModLessonProvider.COMPONENT; + moduleName = 'lesson'; + + lesson: any; // The lesson. + selectedTab: number; // The initial selected tab. + askPassword: boolean; // Whether to ask the password. + canManage: boolean; // Whether the user can manage the lesson. + canViewReports: boolean; // Whether the user can view the lesson reports. + showSpinner: boolean; // Whether to display a spinner. + hasOffline: boolean; // Whether there's offline data. + retakeToReview: any; // A retake to review. + preventMessages: string[]; // List of messages that prevent the lesson from being seen. + leftDuringTimed: boolean; // Whether the user has started and left a retake. + groupInfo: CoreGroupInfo; // The group info. + reportLoaded: boolean; // Whether the report data has been loaded. + selectedGroupName: string; // The name of the selected group. + overview: any; // Reports overview data. + + protected syncEventName = AddonModLessonSyncProvider.AUTO_SYNCED; + protected accessInfo: any; // Lesson access info. + protected password: string; // The password for the lesson. + protected hasPlayed: boolean; // Whether the user has gone to the lesson player (attempted). + + constructor(injector: Injector, protected lessonProvider: AddonModLessonProvider, @Optional() content: Content, + protected groupsProvider: CoreGroupsProvider, protected lessonOffline: AddonModLessonOfflineProvider, + protected lessonSync: AddonModLessonSyncProvider, protected utils: CoreUtilsProvider, + protected prefetchHandler: AddonModLessonPrefetchHandler, protected navCtrl: NavController, + protected timeUtils: CoreTimeUtilsProvider, protected userProvider: CoreUserProvider) { + super(injector, content); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + super.ngOnInit(); + + this.selectedTab = this.action == 'report' ? 1 : 0; + + this.loadContent(false, true).then(() => { + if (!this.lesson || (this.preventMessages && this.preventMessages.length)) { + return; + } + + this.logView(); + }); + } + + /** + * Change the group displayed. + * + * @param {number} groupId Group ID to display. + */ + changeGroup(groupId: number): void { + this.reportLoaded = false; + + this.setGroup(groupId).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error getting report.'); + }).finally(() => { + this.reportLoaded = true; + }); + } + + /** + * Get the lesson data. + * + * @param {boolean} [refresh=false] If it's refreshing content. + * @param {boolean} [sync=false] If the refresh is needs syncing. + * @param {boolean} [showErrors=false] If show errors to the user of hide them. + * @return {Promise} Promise resolved when done. + */ + protected fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise { + + let lessonReady = true; + this.askPassword = false; + + return this.lessonProvider.getLesson(this.courseId, this.module.id).then((lessonData) => { + this.lesson = lessonData; + + this.dataRetrieved.emit(this.lesson); + this.description = this.lesson.intro; // Show description only if intro is present. + + if (sync) { + // Try to synchronize the lesson. + return this.syncActivity(showErrors); + } + }).then(() => { + return this.lessonProvider.getAccessInformation(this.lesson.id); + }).then((info) => { + const promises = []; + + this.accessInfo = info; + this.canManage = info.canmanage; + this.canViewReports = info.canviewreports; + + if (this.lessonProvider.isLessonOffline(this.lesson)) { + // Handle status. + this.setStatusListener(); + + // Check if there is offline data. + promises.push(this.lessonSync.hasDataToSync(this.lesson.id, info.attemptscount).then((hasOffline) => { + this.hasOffline = hasOffline; + })); + + // Check if there is a retake finished in a synchronization. + promises.push(this.lessonSync.getRetakeFinishedInSync(this.lesson.id).then((retake) => { + if (retake && retake.retake == info.attemptscount - 1) { + // The retake finished is still the last retake. Allow reviewing it. + this.retakeToReview = retake; + } else { + this.retakeToReview = undefined; + if (retake) { + this.lessonSync.deleteRetakeFinishedInSync(this.lesson.id); + } + } + })); + + // Update the list of content pages viewed and question attempts. + promises.push(this.lessonProvider.getContentPagesViewedOnline(this.lesson.id, info.attemptscount)); + promises.push(this.lessonProvider.getQuestionsAttemptsOnline(this.lesson.id, info.attemptscount)); + } + + if (info.preventaccessreasons && info.preventaccessreasons.length) { + const askPassword = info.preventaccessreasons.length == 1 && this.lessonProvider.isPasswordProtected(info); + + if (askPassword) { + // The lesson requires a password. Check if there is one in memory or DB. + const promise = this.password ? Promise.resolve(this.password) : + this.lessonProvider.getStoredPassword(this.lesson.id); + + promises.push(promise.then((password) => { + return this.validatePassword(password); + }).catch(() => { + // No password or the validation failed. Show password form. + this.askPassword = true; + this.preventMessages = info.preventaccessreasons; + lessonReady = false; + })); + } else { + // Lesson cannot be started. + this.preventMessages = info.preventaccessreasons; + lessonReady = false; + } + } + + if (this.selectedTab == 1 && this.canViewReports) { + // Only fetch the report data if the tab is selected. + promises.push(this.fetchReportData()); + } + + return Promise.all(promises).then(() => { + if (lessonReady) { + // Lesson can be started, don't ask the password and don't show prevent messages. + this.lessonReady(refresh); + } + }); + }); + } + + /** + * Fetch the reports data. + * + * @return {Promise} Promise resolved when done. + */ + protected fetchReportData(): Promise { + return this.groupsProvider.getActivityGroupInfo(this.module.id).then((groupInfo) => { + this.groupInfo = groupInfo; + + return this.setGroup(this.group || 0); + }).finally(() => { + this.reportLoaded = true; + }); + } + + /** + * Checks if sync has succeed from result sync data. + * + * @param {any} result Data returned on the sync function. + * @return {boolean} If suceed or not. + */ + protected hasSyncSucceed(result: any): boolean { + return result.updated; + } + + /** + * User entered the page that contains the component. + */ + ionViewDidEnter(): void { + super.ionViewDidEnter(); + + // Update data when we come back from the player since the status could have changed. + if (this.hasPlayed) { + this.hasPlayed = false; + + this.showLoadingAndRefresh(true, false); + } + } + + /** + * User left the page that contains the component. + */ + ionViewDidLeave(): void { + super.ionViewDidLeave(); + + if (this.navCtrl.getActive().component.name == 'AddonModLessonPlayerPage') { + this.hasPlayed = true; + } + } + + /** + * Perform the invalidate content function. + * + * @return {Promise} Resolved when done. + */ + protected invalidateContent(): Promise { + const promises = []; + + promises.push(this.lessonProvider.invalidateLessonData(this.courseId)); + + if (this.lesson) { + promises.push(this.lessonProvider.invalidateAccessInformation(this.lesson.id)); + promises.push(this.lessonProvider.invalidatePages(this.lesson.id)); + promises.push(this.lessonProvider.invalidateLessonWithPassword(this.lesson.id)); + promises.push(this.lessonProvider.invalidateTimers(this.lesson.id)); + promises.push(this.lessonProvider.invalidateContentPagesViewed(this.lesson.id)); + promises.push(this.lessonProvider.invalidateQuestionsAttempts(this.lesson.id)); + promises.push(this.lessonProvider.invalidateRetakesOverview(this.lesson.id)); + promises.push(this.groupsProvider.invalidateActivityGroupInfo(this.module.id)); + } + + return Promise.all(promises); + } + + /** + * Compares sync event data with current data to check if refresh content is needed. + * + * @param {any} syncEventData Data receiven on sync observer. + * @return {boolean} True if refresh is needed, false otherwise. + */ + protected isRefreshSyncNeeded(syncEventData: any): boolean { + return this.lesson && syncEventData.lessonId == this.lesson.id; + } + + /** + * Function called when the lesson is ready to be seen (no pending prevent access reasons). + * + * @param {boolean} [refresh=false] If it's refreshing content. + */ + protected lessonReady(refresh?: boolean): void { + this.askPassword = false; + this.preventMessages = []; + this.leftDuringTimed = this.hasOffline || this.lessonProvider.leftDuringTimed(this.accessInfo); + + if (this.password) { + // Store the password in DB. + this.lessonProvider.storePassword(this.lesson.id, this.password); + } + + // All data obtained, now fill the context menu. + this.fillContextMenu(refresh); + } + + /** + * Log viewing the lesson. + */ + protected logView(): void { + this.lessonProvider.logViewLesson(this.lesson.id, this.password).then(() => { + this.courseProvider.checkModuleCompletion(this.courseId, this.module.completionstatus); + }).catch((error) => { + // Ignore errors. + }); + } + + /** + * Open the lesson player. + * + * @param {boolean} continueLast Whether to continue the last retake. + * @return {Promise} Promise resolved when done. + */ + protected playLesson(continueLast: boolean): Promise { + // Calculate the pageId to load. If there is timelimit, lesson is always restarted from the start. + let promise; + + if (this.hasOffline) { + if (continueLast) { + promise = this.lessonProvider.getLastPageSeen(this.lesson.id, this.accessInfo.attemptscount); + } else { + promise = Promise.resolve(this.accessInfo.firstpageid); + } + } else if (this.leftDuringTimed && !this.lesson.timelimit) { + promise = Promise.resolve(continueLast ? this.accessInfo.lastpageseen : this.accessInfo.firstpageid); + } else { + promise = Promise.resolve(); + } + + return promise.then((pageId) => { + this.navCtrl.push('AddonModLessonPlayerPage', { + courseId: this.courseId, + lessonId: this.lesson.id, + pageId: pageId, + password: this.password + }); + }); + } + + /** + * Reports tab selected. + */ + reportsSelected(): void { + if (!this.groupInfo) { + this.fetchReportData().catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error getting report.'); + }); + } + } + + /** + * Review the lesson. + */ + review(): void { + if (!this.retakeToReview) { + // No retake to review, stop. + return; + } + + this.navCtrl.push('AddonModLessonPlayerPage', { + courseId: this.courseId, + lessonId: this.lesson.id, + pageId: this.retakeToReview.pageId, + password: this.password, + review: true, + retake: this.retakeToReview.retake + }); + } + + /** + * Set a group to view the reports. + * + * @param {number} groupId Group ID. + * @return {Promise} Promise resolved when done. + */ + protected setGroup(groupId: number): Promise { + this.group = groupId; + this.selectedGroupName = ''; + + // Search the name of the group if it isn't all participants. + if (groupId && this.groupInfo && this.groupInfo.groups) { + for (let i = 0; i < this.groupInfo.groups.length; i++) { + const group = this.groupInfo.groups[i]; + if (groupId == group.id) { + this.selectedGroupName = group.name; + break; + } + } + } + + // Get the overview of retakes for the group. + return this.lessonProvider.getRetakesOverview(this.lesson.id, groupId).then((data) => { + const promises = []; + + // Format times and grades. + if (data && data.avetime != null && data.numofattempts) { + data.avetime = Math.floor(data.avetime / data.numofattempts); + data.avetimeReadable = this.timeUtils.formatTime(data.avetime); + } + + if (data && data.hightime != null) { + data.hightimeReadable = this.timeUtils.formatTime(data.hightime); + } + + if (data && data.lowtime != null) { + data.lowtimeReadable = this.timeUtils.formatTime(data.lowtime); + } + + if (data && data.lessonscored) { + if (data.numofattempts) { + data.avescore = this.textUtils.roundToDecimals(data.avescore, 2); + } + if (data.highscore != null) { + data.highscore = this.textUtils.roundToDecimals(data.highscore, 2); + } + if (data.lowscore != null) { + data.lowscore = this.textUtils.roundToDecimals(data.lowscore, 2); + } + } + + if (data && data.students) { + // Get the user data for each student returned. + data.students.forEach((student) => { + student.bestgrade = this.textUtils.roundToDecimals(student.bestgrade, 2); + + promises.push(this.userProvider.getProfile(student.id, this.courseId, true).then((user) => { + student.profileimageurl = user.profileimageurl; + }).catch(() => { + // Error getting profile, resolve promise without adding any extra data. + })); + }); + } + + return this.utils.allPromises(promises).catch(() => { + // Shouldn't happen. + }).then(() => { + this.overview = data; + }); + }); + } + + /** + * Displays some data based on the current status. + * + * @param {string} status The current status. + * @param {string} [previousStatus] The previous status. If not defined, there is no previous status. + */ + protected showStatus(status: string, previousStatus?: string): void { + this.showSpinner = status == CoreConstants.DOWNLOADING; + } + + /** + * Start the lesson. + * + * @param {boolean} [continueLast] Whether to continue the last attempt. + */ + start(continueLast?: boolean): void { + if (this.showSpinner) { + // Lesson is being downloaded, abort. + return; + } + + if (this.lessonProvider.isLessonOffline(this.lesson)) { + // Lesson supports offline, check if it needs to be downloaded. + if (this.currentStatus != CoreConstants.DOWNLOADED) { + // Prefetch the lesson. + this.showSpinner = true; + + this.prefetchHandler.prefetch(this.module, this.courseId, true).then(() => { + // Success downloading, open lesson. + this.playLesson(continueLast); + }).catch((error) => { + if (this.hasOffline) { + // Error downloading but there is something offline, allow continuing it. + this.playLesson(continueLast); + } else { + this.domUtils.showErrorModalDefault(error, 'core.errordownloading', true); + } + }).finally(() => { + this.showSpinner = false; + }); + } else { + // Already downloaded, open it. + this.playLesson(continueLast); + } + } else { + this.playLesson(continueLast); + } + } + + /** + * Submit password for password protected lessons. + * + * @param {HTMLInputElement} passwordEl The password input. + */ + submitPassword(passwordEl: HTMLInputElement): void { + const password = passwordEl && passwordEl.value; + if (!password) { + this.domUtils.showErrorModal('addon.mod_lesson.emptypassword', true); + + return; + } + + this.loaded = false; + this.refreshIcon = 'spinner'; + this.syncIcon = 'spinner'; + + this.validatePassword(password).then(() => { + // Password validated. + this.lessonReady(false); + + // Log view now that we have the password. + this.logView(); + }).catch((error) => { + this.domUtils.showErrorModal(error); + }).finally(() => { + this.loaded = true; + this.refreshIcon = 'refresh'; + this.syncIcon = 'sync'; + }); + } + + /** + * Performs the sync of the activity. + * + * @return {Promise} Promise resolved when done. + */ + protected sync(): Promise { + return this.lessonSync.syncLesson(this.lesson.id, true); + } + + /** + * Validate a password and retrieve extra data. + * + * @param {string} password The password to validate. + * @return {Promise} Promise resolved when done. + */ + protected validatePassword(password: string): Promise { + return this.lessonProvider.getLessonWithPassword(this.lesson.id, password).then((lessonData) => { + this.lesson = lessonData; + this.password = password; + }).catch((error) => { + this.password = ''; + + return Promise.reject(error); + }); + } +} diff --git a/src/addon/mod/lesson/lang/en.json b/src/addon/mod/lesson/lang/en.json new file mode 100644 index 000000000..920c695d6 --- /dev/null +++ b/src/addon/mod/lesson/lang/en.json @@ -0,0 +1,85 @@ +{ + "answer": "Answer", + "attempt": "Attempt: {{$a}}", + "attemptheader": "Attempt", + "attemptsremaining": "You have {{$a}} attempt(s) remaining", + "averagescore": "Average score", + "averagetime": "Average time", + "branchtable": "Content", + "cannotfindattempt": "Error: could not find attempt", + "cannotfinduser": "Error: could not find users", + "clusterjump": "Unseen question within a cluster", + "completed": "Completed", + "congratulations": "Congratulations - end of lesson reached", + "continue": "Continue", + "continuetonextpage": "Continue to next page.", + "defaultessayresponse": "Your essay will be graded by your teacher.", + "detailedstats": "Detailed statistics", + "didnotanswerquestion": "Did not answer this question.", + "displayofgrade": "Display of grade (for students only)", + "displayscorewithessays": "

You earned {{$a.score}} out of {{$a.tempmaxgrade}} for the automatically graded questions.

Your {{$a.essayquestions}} essay question(s) will be graded and added into your final score at a later date.

Your current grade without the essay question(s) is {{$a.score}} out of {{$a.grade}}.

", + "displayscorewithoutessays": "Your score is {{$a.score}} (out of {{$a.grade}}).", + "emptypassword": "Password cannot be empty", + "enterpassword": "Please enter the password:", + "eolstudentoutoftimenoanswers": "You did not answer any questions. You have received a 0 for this lesson.", + "errorprefetchrandombranch": "This lesson contains a jump to a random content page. It can't be attempted in the app until it has been started in a web browser.", + "errorreviewretakenotlast": "This attempt can no longer be reviewed because another attempt has been finished.", + "finish": "Finish", + "finishretakeoffline": "This attempt was finished offline.", + "firstwrong": "You have answered incorrectly. Would you like to attempt the question again? (If you now answer the question correctly, it will not count towards your final score.)", + "gotoendoflesson": "Go to the end of the lesson", + "grade": "Grade", + "highscore": "High score", + "hightime": "High time", + "leftduringtimed": "You have left during a timed lesson.
Please click on Continue to restart the lesson.", + "leftduringtimednoretake": "You have left during a timed lesson and you are
not allowed to retake or continue the lesson.", + "lessonmenu": "Lesson menu", + "lessonstats": "Lesson statistics", + "linkedmedia": "Linked media", + "loginfail": "Login failed, please try again...", + "lowscore": "Low score", + "lowtime": "Low time", + "maximumnumberofattemptsreached": "Maximum number of attempts reached - Moving to next page", + "modattemptsnoteacher": "Student review only works for students.", + "noanswer": "One or more questions have no answer given. Please go back and submit an answer.", + "nolessonattempts": "No attempts have been made on this lesson.", + "nolessonattemptsgroup": "No attempts have been made by {{$a}} group members on this lesson.", + "notcompleted": "Not completed", + "numberofcorrectanswers": "Number of correct answers: {{$a}}", + "numberofpagesviewed": "Number of questions answered: {{$a}}", + "numberofpagesviewednotice": "Number of questions answered: {{$a.nquestions}} (You should answer at least {{$a.minquestions}})", + "ongoingcustom": "You have earned {{$a.score}} point(s) out of {{$a.currenthigh}} point(s) thus far.", + "ongoingnormal": "You have answered {{$a.correct}} correctly out of {{$a.viewed}} attempts.", + "or": "OR", + "overview": "Overview", + "preview": "Preview", + "progressbarteacherwarning2": "You will not see the progress bar because you can edit this lesson", + "progresscompleted": "You have completed {{$a}}% of the lesson", + "question": "Question", + "rawgrade": "Raw grade", + "reports": "Reports", + "response": "Response", + "retakefinishedinsync": "An offline attempt was synchronised. Do you want to review it?", + "retakelabelfull": "{{retake}}: {{grade}} {{timestart}} ({{duration}})", + "retakelabelshort": "{{retake}}: {{grade}} {{timestart}}", + "review": "Review", + "reviewlesson": "Review lesson", + "reviewquestionback": "Yes, I'd like to try again", + "reviewquestioncontinue": "No, I just want to go on to the next question", + "secondpluswrong": "Not quite. Would you like to try again?", + "submit": "Submit", + "teacherjumpwarning": "An {{$a.cluster}} jump or an {{$a.unseen}} jump is being used in this lesson. The next page jump will be used instead. Login as a student to test these jumps.", + "teacherongoingwarning": "Ongoing score is only displayed for student. Login as a student to test ongoing score", + "teachertimerwarning": "Timer only works for students. Test the timer by logging in as a student.", + "thatsthecorrectanswer": "That's the correct answer", + "thatsthewronganswer": "That's the wrong answer", + "timeremaining": "Time remaining", + "timetaken": "Time taken", + "unseenpageinbranch": "Unseen question within a content page", + "warningretakefinished": "The attempt was finished on the site.", + "welldone": "Well done!", + "youhaveseen": "You have seen more than one page of this lesson already.
Do you want to start at the last page you saw?", + "youranswer": "Your answer", + "yourcurrentgradeisoutof": "Your current grade is {{$a.grade}} out of {{$a.total}}", + "youshouldview": "You should answer at least: {{$a}}" +} \ No newline at end of file diff --git a/src/addon/mod/lesson/lesson.module.ts b/src/addon/mod/lesson/lesson.module.ts new file mode 100644 index 000000000..7399c86a2 --- /dev/null +++ b/src/addon/mod/lesson/lesson.module.ts @@ -0,0 +1,65 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { CoreCronDelegate } from '@providers/cron'; +import { CoreCourseModuleDelegate } from '@core/course/providers/module-delegate'; +import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; +import { CoreContentLinksDelegate } from '@core/contentlinks/providers/delegate'; +import { AddonModLessonComponentsModule } from './components/components.module'; +import { AddonModLessonProvider } from './providers/lesson'; +import { AddonModLessonOfflineProvider } from './providers/lesson-offline'; +import { AddonModLessonSyncProvider } from './providers/lesson-sync'; +import { AddonModLessonHelperProvider } from './providers/helper'; +import { AddonModLessonModuleHandler } from './providers/module-handler'; +import { AddonModLessonPrefetchHandler } from './providers/prefetch-handler'; +import { AddonModLessonSyncCronHandler } from './providers/sync-cron-handler'; +import { AddonModLessonIndexLinkHandler } from './providers/index-link-handler'; +import { AddonModLessonGradeLinkHandler } from './providers/grade-link-handler'; +import { AddonModLessonReportLinkHandler } from './providers/report-link-handler'; + +@NgModule({ + declarations: [ + ], + imports: [ + AddonModLessonComponentsModule + ], + providers: [ + AddonModLessonProvider, + AddonModLessonOfflineProvider, + AddonModLessonSyncProvider, + AddonModLessonHelperProvider, + AddonModLessonModuleHandler, + AddonModLessonPrefetchHandler, + AddonModLessonSyncCronHandler, + AddonModLessonIndexLinkHandler, + AddonModLessonGradeLinkHandler, + AddonModLessonReportLinkHandler + ] +}) +export class AddonModLessonModule { + constructor(moduleDelegate: CoreCourseModuleDelegate, moduleHandler: AddonModLessonModuleHandler, + prefetchDelegate: CoreCourseModulePrefetchDelegate, prefetchHandler: AddonModLessonPrefetchHandler, + cronDelegate: CoreCronDelegate, syncHandler: AddonModLessonSyncCronHandler, linksDelegate: CoreContentLinksDelegate, + indexHandler: AddonModLessonIndexLinkHandler, gradeHandler: AddonModLessonGradeLinkHandler, + reportHandler: AddonModLessonReportLinkHandler) { + + moduleDelegate.registerHandler(moduleHandler); + prefetchDelegate.registerHandler(prefetchHandler); + cronDelegate.register(syncHandler); + linksDelegate.registerHandler(indexHandler); + linksDelegate.registerHandler(gradeHandler); + linksDelegate.registerHandler(reportHandler); + } +} diff --git a/src/addon/mod/lesson/pages/index/index.html b/src/addon/mod/lesson/pages/index/index.html new file mode 100644 index 000000000..80022b30e --- /dev/null +++ b/src/addon/mod/lesson/pages/index/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/addon/mod/lesson/pages/index/index.module.ts b/src/addon/mod/lesson/pages/index/index.module.ts new file mode 100644 index 000000000..890814b05 --- /dev/null +++ b/src/addon/mod/lesson/pages/index/index.module.ts @@ -0,0 +1,33 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { IonicPageModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { AddonModLessonComponentsModule } from '../../components/components.module'; +import { AddonModLessonIndexPage } from './index'; + +@NgModule({ + declarations: [ + AddonModLessonIndexPage, + ], + imports: [ + CoreDirectivesModule, + AddonModLessonComponentsModule, + IonicPageModule.forChild(AddonModLessonIndexPage), + TranslateModule.forChild() + ], +}) +export class AddonModLessonIndexPageModule {} diff --git a/src/addon/mod/lesson/pages/index/index.ts b/src/addon/mod/lesson/pages/index/index.ts new file mode 100644 index 000000000..838cbe8a3 --- /dev/null +++ b/src/addon/mod/lesson/pages/index/index.ts @@ -0,0 +1,66 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 } from '@angular/core'; +import { IonicPage, NavParams } from 'ionic-angular'; +import { AddonModLessonIndexComponent } from '../../components/index/index'; + +/** + * Page that displays the lesson entry page. + */ +@IonicPage({ segment: 'addon-mod-lesson-index' }) +@Component({ + selector: 'page-addon-mod-lesson-index', + templateUrl: 'index.html', +}) +export class AddonModLessonIndexPage { + @ViewChild(AddonModLessonIndexComponent) lessonComponent: AddonModLessonIndexComponent; + + title: string; + module: any; + courseId: number; + group: number; // The group to display. + action: string; // The "action" to display first. + + constructor(navParams: NavParams) { + this.module = navParams.get('module') || {}; + this.courseId = navParams.get('courseId'); + this.group = navParams.get('group'); + this.action = navParams.get('action'); + this.title = this.module.name; + } + + /** + * Update some data based on the lesson instance. + * + * @param {any} lesson Lesson instance. + */ + updateData(lesson: any): void { + this.title = lesson.name || this.title; + } + + /** + * User entered the page. + */ + ionViewDidEnter(): void { + this.lessonComponent.ionViewDidEnter(); + } + + /** + * User left the page. + */ + ionViewDidLeave(): void { + this.lessonComponent.ionViewDidLeave(); + } +} diff --git a/src/addon/mod/lesson/pages/menu-modal/menu-modal.html b/src/addon/mod/lesson/pages/menu-modal/menu-modal.html new file mode 100644 index 000000000..1bb5c72ed --- /dev/null +++ b/src/addon/mod/lesson/pages/menu-modal/menu-modal.html @@ -0,0 +1,36 @@ + + + {{ pageInstance.lesson.name }} + + + + + + + + diff --git a/src/addon/mod/lesson/pages/menu-modal/menu-modal.module.ts b/src/addon/mod/lesson/pages/menu-modal/menu-modal.module.ts new file mode 100644 index 000000000..3147dc86e --- /dev/null +++ b/src/addon/mod/lesson/pages/menu-modal/menu-modal.module.ts @@ -0,0 +1,33 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { IonicPageModule } from 'ionic-angular'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { AddonModLessonMenuModalPage } from './menu-modal'; +import { TranslateModule } from '@ngx-translate/core'; + +@NgModule({ + declarations: [ + AddonModLessonMenuModalPage + ], + imports: [ + CoreComponentsModule, + CoreDirectivesModule, + IonicPageModule.forChild(AddonModLessonMenuModalPage), + TranslateModule.forChild() + ] +}) +export class AddonModLessonMenuModalPageModule {} diff --git a/src/addon/mod/lesson/pages/menu-modal/menu-modal.scss b/src/addon/mod/lesson/pages/menu-modal/menu-modal.scss new file mode 100644 index 000000000..8961fc65e --- /dev/null +++ b/src/addon/mod/lesson/pages/menu-modal/menu-modal.scss @@ -0,0 +1,5 @@ +page-addon-mod-lesson-menu-modal { + .addon-mod_lesson-selected, .item.addon-mod_lesson-selected { + background: $blue-light; + } +} diff --git a/src/addon/mod/lesson/pages/menu-modal/menu-modal.ts b/src/addon/mod/lesson/pages/menu-modal/menu-modal.ts new file mode 100644 index 000000000..a9406ceb4 --- /dev/null +++ b/src/addon/mod/lesson/pages/menu-modal/menu-modal.ts @@ -0,0 +1,58 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 } from '@angular/core'; +import { IonicPage, ViewController, NavParams } from 'ionic-angular'; + +/** + * Modal that renders the lesson menu and media file. + */ +@IonicPage({ segment: 'addon-mod-lesson-menu-modal' }) +@Component({ + selector: 'page-addon-mod-lesson-menu-modal', + templateUrl: 'menu-modal.html', +}) +export class AddonModLessonMenuModalPage { + + /** + * The instance of the page that opened the modal. We use the instance instead of the needed attributes for these reasons: + * - We want the user to be able to see the media file while the menu is being loaded, so we need to be able to update + * the menu dynamically based on the data retrieved by the page that opened the modal. + * - The onDidDismiss function takes a while to be called, making the app seem slow. This way we can directly call + * the functions we need without having to wait for the modal to be dismissed. + * @type {any} + */ + pageInstance: any; + + constructor(params: NavParams, protected viewCtrl: ViewController) { + this.pageInstance = params.get('page'); + } + + /** + * Close modal. + */ + closeModal(): void { + this.viewCtrl.dismiss(); + } + + /** + * Load a certain page. + * + * @param {number} pageId The page ID to load. + */ + loadPage(pageId: number): void { + this.pageInstance.changePage && this.pageInstance.changePage(pageId); + this.closeModal(); + } +} diff --git a/src/addon/mod/lesson/pages/password-modal/password-modal.html b/src/addon/mod/lesson/pages/password-modal/password-modal.html new file mode 100644 index 000000000..dd55a244a --- /dev/null +++ b/src/addon/mod/lesson/pages/password-modal/password-modal.html @@ -0,0 +1,26 @@ + + + {{ 'core.login.password' | translate }} + + + + + + +
+ + + {{ 'addon.mod_lesson.enterpassword' | translate }} + + + + + + +
+
diff --git a/src/addon/mod/lesson/pages/password-modal/password-modal.module.ts b/src/addon/mod/lesson/pages/password-modal/password-modal.module.ts new file mode 100644 index 000000000..5aebe2d78 --- /dev/null +++ b/src/addon/mod/lesson/pages/password-modal/password-modal.module.ts @@ -0,0 +1,31 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { IonicPageModule } from 'ionic-angular'; +import { AddonModLessonPasswordModalPage } from './password-modal'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreComponentsModule } from '@components/components.module'; + +@NgModule({ + declarations: [ + AddonModLessonPasswordModalPage + ], + imports: [ + CoreComponentsModule, + IonicPageModule.forChild(AddonModLessonPasswordModalPage), + TranslateModule.forChild() + ] +}) +export class AddonModLessonPasswordModalPageModule {} diff --git a/src/addon/mod/lesson/pages/password-modal/password-modal.ts b/src/addon/mod/lesson/pages/password-modal/password-modal.ts new file mode 100644 index 000000000..d9c116c8f --- /dev/null +++ b/src/addon/mod/lesson/pages/password-modal/password-modal.ts @@ -0,0 +1,43 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 } from '@angular/core'; +import { IonicPage, ViewController } from 'ionic-angular'; + +/** + * Modal that asks the password for a lesson. + */ +@IonicPage({ segment: 'addon-mod-lesson-password-modal' }) +@Component({ + selector: 'page-addon-mod-lesson-password-modal', + templateUrl: 'password-modal.html', +}) +export class AddonModLessonPasswordModalPage { + + constructor(protected viewCtrl: ViewController) { } + + /** + * Send the password back. + */ + submitPassword(password: HTMLInputElement): void { + this.viewCtrl.dismiss(password.value); + } + + /** + * Close modal. + */ + closeModal(): void { + this.viewCtrl.dismiss(); + } +} diff --git a/src/addon/mod/lesson/pages/player/player.html b/src/addon/mod/lesson/pages/player/player.html new file mode 100644 index 000000000..34def2500 --- /dev/null +++ b/src/addon/mod/lesson/pages/player/player.html @@ -0,0 +1,220 @@ + + + + + + + + + + + + +
+ + +
+ +
+ + + + + +

{{ 'addon.mod_lesson.attempt' | translate:{$a: retake} }}

+
+ + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +

{{ 'addon.mod_lesson.youranswer' | translate }}

+

+
+
+ + + + +
+ + + + + + +
+ + + + + + + + + + +
+ + + + + + + +

+
+ + + {{option.label}} + + +
+
+
+
+
+ + + + +
+
+ + + + + + + {{ button.content }} + + + + +

{{ 'addon.mod_lesson.progresscompleted' | translate:{$a: pageData.progress} }}

+ +
+
+ + {{ 'addon.mod_lesson.progressbarteacherwarning2' | translate }} +
+
+ + + +
+ + {{ 'addon.mod_lesson.finishretakeoffline' | translate }} +
+ +

{{ 'addon.mod_lesson.congratulations' | translate }}

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +

{{ 'addon.mod_lesson.progresscompleted' | translate:{$a: eolData.progresscompleted.value} }}

+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + +

+ +

+
+

{{ 'addon.mod_lesson.gotoendoflesson' | translate }}

+

{{ 'addon.mod_lesson.or' | translate }}

+

{{ 'addon.mod_lesson.continuetonextpage' | translate }}

+
+
+ + {{ 'addon.mod_lesson.finish' | translate }} + {{ button.label | translate }} + +
+
+
+
diff --git a/src/addon/mod/lesson/pages/player/player.module.ts b/src/addon/mod/lesson/pages/player/player.module.ts new file mode 100644 index 000000000..5569515c0 --- /dev/null +++ b/src/addon/mod/lesson/pages/player/player.module.ts @@ -0,0 +1,33 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { IonicPageModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { AddonModLessonPlayerPage } from './player'; + +@NgModule({ + declarations: [ + AddonModLessonPlayerPage, + ], + imports: [ + CoreComponentsModule, + CoreDirectivesModule, + IonicPageModule.forChild(AddonModLessonPlayerPage), + TranslateModule.forChild() + ], +}) +export class AddonModLessonPlayerPageModule {} diff --git a/src/addon/mod/lesson/pages/player/player.scss b/src/addon/mod/lesson/pages/player/player.scss new file mode 100644 index 000000000..8a887b623 --- /dev/null +++ b/src/addon/mod/lesson/pages/player/player.scss @@ -0,0 +1,21 @@ +page-addon-mod-lesson-player { + .addon-mod_lesson-slideshow { + max-width: 100%; + max-height: 100%; + margin: 0 auto; + } + + ion-input[padding-left] input[padding-left] { + // Applying padding-left to the ion-input applies it twice since it's replicated in the inner input. + padding-left: 0; + } + + .addon-mod_lesson-pagebuttons .button-block { + contain: content; + height: auto; + + .button-inner { + height: auto; + } + } +} diff --git a/src/addon/mod/lesson/pages/player/player.ts b/src/addon/mod/lesson/pages/player/player.ts new file mode 100644 index 000000000..4b823d33e --- /dev/null +++ b/src/addon/mod/lesson/pages/player/player.ts @@ -0,0 +1,635 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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, OnInit, OnDestroy, ViewChild, ChangeDetectorRef } from '@angular/core'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { IonicPage, NavParams, Content, PopoverController, ModalController, Modal, NavController } from 'ionic-angular'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreAppProvider } from '@providers/app'; +import { CoreEventsProvider } from '@providers/events'; +import { CoreLoggerProvider } from '@providers/logger'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreSyncProvider } from '@providers/sync'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreTimeUtilsProvider } from '@providers/utils/time'; +import { CoreUrlUtilsProvider } from '@providers/utils/url'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { AddonModLessonProvider } from '../../providers/lesson'; +import { AddonModLessonOfflineProvider } from '../../providers/lesson-offline'; +import { AddonModLessonSyncProvider } from '../../providers/lesson-sync'; +import { AddonModLessonHelperProvider } from '../../providers/helper'; + +/** + * Page that allows attempting and reviewing a lesson. + */ +@IonicPage({ segment: 'addon-mod-lesson-player' }) +@Component({ + selector: 'page-addon-mod-lesson-player', + templateUrl: 'player.html', +}) +export class AddonModLessonPlayerPage implements OnInit, OnDestroy { + @ViewChild(Content) content: Content; + + component = AddonModLessonProvider.COMPONENT; + LESSON_EOL = AddonModLessonProvider.LESSON_EOL; + questionForm: FormGroup; // The FormGroup for question pages. + title: string; // The page title. + lesson: any; // The lesson object. + currentPage: number; // Current page being viewed. + review: boolean; // Whether the user is reviewing. + messages: any[]; // Messages to display to the user. + menuModal: Modal; // Modal to navigate through the pages. + canManage: boolean; // Whether the user can manage the lesson. + retake: number; // Current retake number. + showRetake: boolean; // Whether the retake number needs to be displayed. + lessonWidth: string; // Width of the lesson (if slideshow mode). + lessonHeight: string; // Height of the lesson (if slideshow mode). + endTime: number; // End time of the lesson if it's timed. + pageData: any; // Current page data. + pageContent: string; // Current page contents. + pageButtons: any[]; // List of buttons of the current page. + question: any; // Question of the current page (if it's a question page). + eolData: any; // Data for EOL page (if current page is EOL). + processData: any; // Data to display after processing a page. + loaded: boolean; // Whether data has been loaded. + displayMenu: boolean; // Whether the lesson menu should be displayed. + originalData: any; // Original question data. It is used to check if data has changed. + + protected courseId: number; // The course ID the lesson belongs to. + protected lessonId: number; // Lesson ID. + protected password: string; // Lesson password (if any). + protected forceLeave = false; // If true, don't perform any check when leaving the view. + protected offline: boolean; // Whether we are in offline mode. + protected accessInfo: any; // Lesson access info. + protected jumps: any; // All possible jumps. + protected mediaFile: any; // Media file of the lesson. + protected firstPageLoaded: boolean; // Whether the first page has been loaded. + protected loadingMenu: boolean; // Whether the lesson menu is being loaded. + protected lessonPages: any[]; // Lesson pages (for the lesson menu). + + constructor(protected navParams: NavParams, logger: CoreLoggerProvider, protected translate: TranslateService, + protected eventsProvider: CoreEventsProvider, protected sitesProvider: CoreSitesProvider, + protected syncProvider: CoreSyncProvider, protected domUtils: CoreDomUtilsProvider, popoverCtrl: PopoverController, + protected timeUtils: CoreTimeUtilsProvider, protected lessonProvider: AddonModLessonProvider, + protected lessonHelper: AddonModLessonHelperProvider, protected lessonSync: AddonModLessonSyncProvider, + protected lessonOfflineProvider: AddonModLessonOfflineProvider, protected cdr: ChangeDetectorRef, + modalCtrl: ModalController, protected navCtrl: NavController, protected appProvider: CoreAppProvider, + protected utils: CoreUtilsProvider, protected urlUtils: CoreUrlUtilsProvider, protected fb: FormBuilder) { + + this.lessonId = navParams.get('lessonId'); + this.courseId = navParams.get('courseId'); + this.password = navParams.get('password'); + this.review = !!navParams.get('review'); + this.currentPage = navParams.get('pageId'); + + // Block the lesson so it cannot be synced. + this.syncProvider.blockOperation(this.component, this.lessonId); + + // Create the navigation modal. + this.menuModal = modalCtrl.create('AddonModLessonMenuModalPage', { + page: this + }); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + // Fetch the Lesson data. + this.fetchLessonData().then((success) => { + if (success) { + // Review data loaded or new retake started, remove any retake being finished in sync. + this.lessonSync.deleteRetakeFinishedInSync(this.lessonId); + } + }).finally(() => { + this.loaded = true; + }); + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + // Unblock the lesson so it can be synced. + this.syncProvider.unblockOperation(this.component, this.lessonId); + } + + /** + * Check if we can leave the page or not. + * + * @return {boolean|Promise} Resolved if we can leave it, rejected if not. + */ + ionViewCanLeave(): boolean | Promise { + if (this.forceLeave) { + return true; + } + + if (this.question && !this.eolData && !this.processData && this.originalData) { + // Question shown. Check if there is any change. + if (!this.utils.basicLeftCompare(this.questionForm.getRawValue(), this.originalData, 3)) { + return this.domUtils.showConfirm(this.translate.instant('core.confirmcanceledit')); + } + } + + return Promise.resolve(); + } + + /** + * A button was clicked. + * + * @param {any} data Button data. + */ + buttonClicked(data: any): void { + this.processPage(data); + } + + /** + * Call a function and go offline if allowed and the call fails. + * + * @param {Function} func Function to call. + * @param {any[]} args Arguments to pass to the function. + * @param {number} offlineParamPos Position of the offline parameter in the args. + * @param {number} [jumpsParamPos] Position of the jumps parameter in the args. + * @return {Promise} Promise resolved in success, rejected otherwise. + */ + protected callFunction(func: Function, args: any[], offlineParamPos: number, jumpsParamPos?: number): Promise { + return func.apply(func, args).catch((error) => { + if (!this.offline && !this.review && this.lessonProvider.isLessonOffline(this.lesson) && + !this.utils.isWebServiceError(error)) { + // If it fails, go offline. + this.offline = true; + + // Get the possible jumps now. + return this.lessonProvider.getPagesPossibleJumps(this.lesson.id, true).then((jumpList) => { + this.jumps = jumpList; + + // Call the function again with offline set to true and the new jumps. + args[offlineParamPos] = true; + if (typeof jumpsParamPos != 'undefined') { + args[jumpsParamPos] = this.jumps; + } + + return func.apply(func, args); + }); + } + + return Promise.reject(error); + }); + } + + /** + * Change the page from menu or when continuing from a feedback page. + * + * @param {number} pageId Page to load. + * @param {boolean} [ignoreCurrent] If true, allow loading current page. + */ + changePage(pageId: number, ignoreCurrent?: boolean): void { + if (!ignoreCurrent && !this.eolData && this.currentPage == pageId) { + // Page already loaded, stop. + return; + } + + this.loaded = true; + this.messages = []; + + this.loadPage(pageId).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error loading page'); + }).finally(() => { + this.loaded = true; + }); + } + + /** + * Get the lesson data and load the page. + * + * @return {Promise} Promise resolved with true if success, resolved with false otherwise. + */ + protected fetchLessonData(): Promise { + // Wait for any ongoing sync to finish. We won't sync a lesson while it's being played. + return this.lessonSync.waitForSync(this.lessonId).then(() => { + return this.lessonProvider.getLessonById(this.courseId, this.lessonId); + }).then((lessonData) => { + this.lesson = lessonData; + this.title = this.lesson.name; // Temporary title. + + // If lesson has offline data already, use offline mode. + return this.lessonOfflineProvider.hasOfflineData(this.lessonId); + }).then((offlineMode) => { + this.offline = offlineMode; + + if (!offlineMode && !this.appProvider.isOnline() && this.lessonProvider.isLessonOffline(this.lesson) && !this.review) { + // Lesson doesn't have offline data, but it allows offline and the device is offline. Use offline mode. + this.offline = true; + } + + return this.callFunction(this.lessonProvider.getAccessInformation.bind(this.lessonProvider), + [this.lesson.id, this.offline, true], 1); + }).then((info) => { + const promises = []; + + this.accessInfo = info; + this.canManage = info.canmanage; + this.retake = info.attemptscount; + this.showRetake = !this.currentPage && this.retake > 0; // Only show it in first page if it isn't the first retake. + + if (info.preventaccessreasons && info.preventaccessreasons.length) { + // If it's a password protected lesson and we have the password, allow playing it. + if (!this.password || info.preventaccessreasons.length > 1 || !this.lessonProvider.isPasswordProtected(info)) { + // Lesson cannot be played, show message and go back. + return Promise.reject(info.preventaccessreasons[0].message); + } + } + + if (this.review && this.navParams.get('retake') != info.attemptscount - 1) { + // Reviewing a retake that isn't the last one. Error. + return Promise.reject(this.translate.instant('addon.mod_lesson.errorreviewretakenotlast')); + } + + if (this.password) { + // Lesson uses password, get the whole lesson object. + promises.push(this.callFunction(this.lessonProvider.getLessonWithPassword.bind(this.lessonProvider), + [this.lesson.id, this.password, true, this.offline, true], 3).then((lesson) => { + this.lesson = lesson; + })); + } + + if (this.offline) { + // Offline mode, get the list of possible jumps to allow navigation. + promises.push(this.lessonProvider.getPagesPossibleJumps(this.lesson.id, true).then((jumpList) => { + this.jumps = jumpList; + })); + } + + return Promise.all(promises); + }).then(() => { + this.mediaFile = this.lesson.mediafiles && this.lesson.mediafiles[0]; + + this.lessonWidth = this.lesson.slideshow ? this.domUtils.formatPixelsSize(this.lesson.mediawidth) : ''; + this.lessonHeight = this.lesson.slideshow ? this.domUtils.formatPixelsSize(this.lesson.mediaheight) : ''; + + return this.launchRetake(this.currentPage); + }).then(() => { + return true; + }).catch((error) => { + // An error occurred. + let promise; + + if (this.review && this.navParams.get('retake') && this.utils.isWebServiceError(error)) { + // The user cannot review the retake. Unmark the retake as being finished in sync. + promise = this.lessonSync.deleteRetakeFinishedInSync(this.lessonId); + } else { + promise = Promise.resolve(); + } + + return promise.then(() => { + this.domUtils.showErrorModalDefault(error, 'core.course.errorgetmodule', true); + this.forceLeave = true; + this.navCtrl.pop(); + + return false; + }); + }); + } + + /** + * Finish the retake. + * + * @param {boolean} [outOfTime] Whether the retake is finished because the user ran out of time. + * @return {Promise} Promise resolved when done. + */ + protected finishRetake(outOfTime?: boolean): Promise { + let promise; + + this.messages = []; + + if (this.offline && this.appProvider.isOnline()) { + // Offline mode but the app is online. Try to sync the data. + promise = this.lessonSync.syncLesson(this.lesson.id, true, true).then((result) => { + if (result.warnings && result.warnings.length) { + const error = result.warnings[0]; + + // Some data was deleted. Check if the retake has changed. + return this.lessonProvider.getAccessInformation(this.lesson.id).then((info) => { + if (info.attemptscount != this.accessInfo.attemptscount) { + // The retake has changed. Leave the view and show the error. + this.forceLeave = true; + this.navCtrl.pop(); + + return Promise.reject(error); + } + + // Retake hasn't changed, show the warning and finish the retake in offline. + this.offline = false; + this.domUtils.showErrorModal(error); + }); + } + + this.offline = false; + }, () => { + // Ignore errors. + }); + } else { + promise = Promise.resolve(); + } + + return promise.then(() => { + // Now finish the retake. + const args = [this.lesson, this.courseId, this.password, outOfTime, this.review, this.offline, this.accessInfo]; + + return this.callFunction(this.lessonProvider.finishRetake.bind(this.lessonProvider), args, 5); + }).then((data) => { + this.title = this.lesson.name; + this.eolData = data.data; + this.messages = this.messages.concat(data.messages); + this.processData = undefined; + + // Format activity link if present. + if (this.eolData && this.eolData.activitylink) { + this.eolData.activitylink.value = this.lessonHelper.formatActivityLink(this.eolData.activitylink.value); + } + + // Format review lesson if present. + if (this.eolData && this.eolData.reviewlesson) { + const params = this.urlUtils.extractUrlParams(this.eolData.reviewlesson.value); + + if (!params || !params.pageid) { + // No pageid in the URL, the user cannot review (probably didn't answer any question). + delete this.eolData.reviewlesson; + } else { + this.eolData.reviewlesson.pageid = params.pageid; + } + } + }); + } + + /** + * Jump to a certain page after performing an action. + * + * @param {number} pageId The page to load. + * @return {Promise} Promise resolved when done. + */ + protected jumpToPage(pageId: number): Promise { + if (pageId === 0) { + // Not a valid page, return to entry view. + // This happens, for example, when the user clicks to go to previous page and there is no previous page. + this.forceLeave = true; + this.navCtrl.pop(); + + return Promise.resolve(); + } else if (pageId == AddonModLessonProvider.LESSON_EOL) { + // End of lesson reached. + return this.finishRetake(); + } + + // Load new page. + this.messages = []; + + return this.loadPage(pageId); + } + + /** + * Start or continue a retake. + * + * @param {number} pageId The page to load. + * @return {Promise} Promise resolved when done. + */ + protected launchRetake(pageId: number): Promise { + let promise; + + if (this.review) { + // Review mode, no need to launch the retake. + promise = Promise.resolve({}); + } else if (!this.offline) { + // Not in offline mode, launch the retake. + promise = this.lessonProvider.launchRetake(this.lesson.id, this.password, pageId); + } else { + // Check if there is a finished offline retake. + promise = this.lessonOfflineProvider.hasFinishedRetake(this.lesson.id).then((finished) => { + if (finished) { + // Always show EOL page. + pageId = AddonModLessonProvider.LESSON_EOL; + } + + return {}; + }); + } + + return promise.then((data) => { + this.currentPage = pageId || this.accessInfo.firstpageid; + this.messages = data.messages || []; + + if (this.lesson.timelimit && !this.accessInfo.canmanage) { + // Get the last lesson timer. + return this.lessonProvider.getTimers(this.lesson.id, false, true).then((timers) => { + this.endTime = timers[timers.length - 1].starttime + this.lesson.timelimit; + }); + } + }).then(() => { + return this.loadPage(this.currentPage); + }); + } + + /** + * Load the lesson menu. + * + * @return {Promise} Promise resolved when done. + */ + protected loadMenu(): Promise { + if (this.loadingMenu) { + // Already loading. + return; + } + + this.loadingMenu = true; + + const args = [this.lessonId, this.password, this.offline, true]; + + return this.callFunction(this.lessonProvider.getPages.bind(this.lessonProvider), args, 2).then((pages) => { + this.lessonPages = pages.map((entry) => { + return entry.page; + }); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error loading menu.'); + }).finally(() => { + this.loadingMenu = false; + }); + } + + /** + * Load a certain page. + * + * @param {number} pageId The page to load. + * @return {Promise} Promise resolved when done. + */ + protected loadPage(pageId: number): Promise { + if (pageId == AddonModLessonProvider.LESSON_EOL) { + // End of lesson reached. + return this.finishRetake(); + } + + const args = [this.lesson, pageId, this.password, this.review, true, this.offline, true, this.accessInfo, this.jumps]; + + return this.callFunction(this.lessonProvider.getPageData.bind(this.lessonProvider), args, 5, 8).then((data) => { + if (data.newpageid == AddonModLessonProvider.LESSON_EOL) { + // End of lesson reached. + return this.finishRetake(); + } + + this.pageData = data; + this.title = data.page.title; + this.pageContent = this.lessonHelper.getPageContentsFromPageData(data); + this.loaded = true; + this.currentPage = pageId; + this.messages = this.messages.concat(data.messages); + + // Page loaded, hide EOL and feedback data if shown. + this.eolData = this.processData = undefined; + + if (this.lessonProvider.isQuestionPage(data.page.type)) { + // Create an empty FormGroup without controls, they will be added in getQuestionFromPageData. + this.questionForm = this.fb.group({}); + this.pageButtons = []; + this.question = this.lessonHelper.getQuestionFromPageData(this.questionForm, data); + this.originalData = this.questionForm.getRawValue(); // Use getRawValue to include disabled values. + } else { + this.pageButtons = this.lessonHelper.getPageButtonsFromHtml(data.pagecontent); + this.question = undefined; + this.originalData = undefined; + } + + if (data.displaymenu && !this.displayMenu) { + // Load the menu. + this.loadMenu(); + } + this.displayMenu = !!data.displaymenu; + + if (!this.firstPageLoaded) { + this.firstPageLoaded = true; + } else { + this.showRetake = false; + } + }); + } + + /** + * Process a page, sending some data. + * + * @param {any} data The data to send. + * @return {Promise} Promise resolved when done. + */ + protected processPage(data: any): Promise { + this.loaded = false; + + const args = [this.lesson, this.courseId, this.pageData, data, this.password, this.review, this.offline, this.accessInfo, + this.jumps]; + + return this.callFunction(this.lessonProvider.processPage.bind(this.lessonProvider), args, 6, 8).then((result) => { + if (!this.offline && !this.review && this.lessonProvider.isLessonOffline(this.lesson)) { + // Lesson allows offline and the user changed some data in server. Update cached data. + const retake = this.accessInfo.attemptscount; + + if (this.lessonProvider.isQuestionPage(this.pageData.page.type)) { + this.lessonProvider.getQuestionsAttemptsOnline(this.lessonId, retake, false, undefined, false, true); + } else { + this.lessonProvider.getContentPagesViewedOnline(this.lessonId, retake, false, true); + } + } + + if (result.nodefaultresponse || result.inmediatejump) { + // Don't display feedback or force a redirect to a new page. Load the new page. + return this.jumpToPage(result.newpageid); + } else { + + // Not inmediate jump, show the feedback. + result.feedback = this.lessonHelper.removeQuestionFromFeedback(result.feedback); + this.messages = result.messages; + this.processData = result; + this.processData.buttons = []; + + if (this.lesson.review && !result.correctanswer && !result.noanswer && !result.isessayquestion && + !result.maxattemptsreached && !result.reviewmode) { + // User can try again, show button to do so. + this.processData.buttons.push({ + label: 'addon.mod_lesson.reviewquestionback', + pageId: this.currentPage + }); + } + + // Button to continue. + if (this.lesson.review && !result.correctanswer && !result.noanswer && !result.isessayquestion && + !result.maxattemptsreached) { + this.processData.buttons.push({ + label: 'addon.mod_lesson.reviewquestioncontinue', + pageId: result.newpageid + }); + } else { + this.processData.buttons.push({ + label: 'addon.mod_lesson.continue', + pageId: result.newpageid + }); + } + } + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error processing page'); + }).finally(() => { + this.loaded = true; + }); + } + + /** + * Review the lesson. + * + * @param {number} pageId Page to load. + */ + reviewLesson(pageId: number): void { + this.loaded = false; + this.review = true; + this.offline = false; // Don't allow offline mode in review. + + this.loadPage(pageId).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error loading page'); + }).finally(() => { + this.loaded = true; + }); + } + + /** + * Submit a question. + */ + submitQuestion(): void { + this.loaded = false; + + // Use getRawValue to include disabled values. + this.lessonHelper.prepareQuestionData(this.question, this.questionForm.getRawValue()).then((data) => { + return this.processPage(data); + }).finally(() => { + this.loaded = true; + }); + } + + /** + * Time up. + */ + timeUp(): void { + // Time up called, hide the timer. + this.endTime = undefined; + this.loaded = false; + + this.finishRetake(true).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error finishing attempt'); + }).finally(() => { + this.loaded = true; + }); + } +} diff --git a/src/addon/mod/lesson/pages/user-retake/user-retake.html b/src/addon/mod/lesson/pages/user-retake/user-retake.html new file mode 100644 index 000000000..008f7499c --- /dev/null +++ b/src/addon/mod/lesson/pages/user-retake/user-retake.html @@ -0,0 +1,159 @@ + + + {{ 'addon.mod_lesson.detailedstats' | translate }} + + + + + + + + + + + + + + +

{{student.fullname}}

+ +
+ + + + {{ 'addon.mod_lesson.attemptheader' | translate }} + + {{retake.label}} + + + + +
+ + + +

{{ 'addon.mod_lesson.grade' | translate }}

+

{{ 'core.percentagenumber' | translate:{$a: retake.userstats.grade} }}

+
+ + +

{{ 'addon.mod_lesson.rawgrade' | translate }}

+

{{ retake.userstats.gradeinfo.earned }} / {{ retake.userstats.gradeinfo.total }}

+
+
+
+ +

{{ 'addon.mod_lesson.timetaken' | translate }}

+

{{ retake.userstats.timetakenReadable }}

+
+ +

{{ 'addon.mod_lesson.completed' | translate }}

+

{{ retake.userstats.completed * 1000 | coreFormatDate:"dfmediumdate" }}

+
+
+ + + + {{ 'addon.mod_lesson.notcompleted' | translate }} + + + + + + + +

{{page.qtype}}: {{page.title}}

+
+ +

{{ 'addon.mod_lesson.question' | translate }}

+

+
+ +

{{ 'addon.mod_lesson.answer' | translate }}

+
+ +

{{ 'addon.mod_lesson.didnotanswerquestion' | translate }}

+
+
+
+ + + + + + + +

+
+
+
+ +
+ + + + + +

+ + + +
+ + +
+ + + +

{{ answer[0].value }}

+ + + +
+ + + + + +

+
+ +

{{answer[0].value}}

+ + + +
+
+
+ + + +

+ + + +
+
+ + + +

+ + + +
+
+ + +

{{ 'addon.mod_lesson.response' | translate }}

+

+
+ +

{{page.answerdata.score}}

+
+
+
+
+
+
+
diff --git a/src/addon/mod/lesson/pages/user-retake/user-retake.module.ts b/src/addon/mod/lesson/pages/user-retake/user-retake.module.ts new file mode 100644 index 000000000..82f5f106e --- /dev/null +++ b/src/addon/mod/lesson/pages/user-retake/user-retake.module.ts @@ -0,0 +1,35 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { IonicPageModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { CorePipesModule } from '@pipes/pipes.module'; +import { AddonModLessonUserRetakePage } from './user-retake'; + +@NgModule({ + declarations: [ + AddonModLessonUserRetakePage, + ], + imports: [ + CoreComponentsModule, + CoreDirectivesModule, + CorePipesModule, + IonicPageModule.forChild(AddonModLessonUserRetakePage), + TranslateModule.forChild() + ], +}) +export class AddonModLessonUserRetakePageModule {} diff --git a/src/addon/mod/lesson/pages/user-retake/user-retake.scss b/src/addon/mod/lesson/pages/user-retake/user-retake.scss new file mode 100644 index 000000000..ed414081f --- /dev/null +++ b/src/addon/mod/lesson/pages/user-retake/user-retake.scss @@ -0,0 +1,8 @@ +page-addon-mod-lesson-user-retake { + .addon-mod_lesson-highlight { + background: $blue-light; + .label, .label p { + color: $blue-dark; + } + } +} diff --git a/src/addon/mod/lesson/pages/user-retake/user-retake.ts b/src/addon/mod/lesson/pages/user-retake/user-retake.ts new file mode 100644 index 000000000..8a69c73de --- /dev/null +++ b/src/addon/mod/lesson/pages/user-retake/user-retake.ts @@ -0,0 +1,228 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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, OnInit } from '@angular/core'; +import { IonicPage, NavParams } from 'ionic-angular'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreTimeUtilsProvider } from '@providers/utils/time'; +import { CoreUserProvider } from '@core/user/providers/user'; +import { AddonModLessonProvider } from '../../providers/lesson'; +import { AddonModLessonHelperProvider } from '../../providers/helper'; + +/** + * Page that displays a retake made by a certain user. + */ +@IonicPage({ segment: 'addon-mod-lesson-user-retake' }) +@Component({ + selector: 'page-addon-mod-lesson-user-retake', + templateUrl: 'user-retake.html', +}) +export class AddonModLessonUserRetakePage implements OnInit { + + component = AddonModLessonProvider.COMPONENT; + lesson: any; // The lesson the retake belongs to. + courseId: number; // Course ID the lesson belongs to. + selectedRetake: number; // The retake to see. + student: any; // Data about the student and his retakes. + retake: any; // Data about the retake. + loaded: boolean; // Whether the data has been loaded. + + protected lessonId: number; // The lesson ID the retake belongs to. + protected userId: number; // User ID to see the retakes. + protected retakeNumber: number; // Number of the initial retake to see. + + constructor(navParams: NavParams, sitesProvider: CoreSitesProvider, protected textUtils: CoreTextUtilsProvider, + protected translate: TranslateService, protected domUtils: CoreDomUtilsProvider, + protected userProvider: CoreUserProvider, protected timeUtils: CoreTimeUtilsProvider, + protected lessonProvider: AddonModLessonProvider, protected lessonHelper: AddonModLessonHelperProvider) { + + this.lessonId = navParams.get('lessonId'); + this.courseId = navParams.get('courseId'); + this.userId = navParams.get('userId') || sitesProvider.getCurrentSiteUserId(); + this.retakeNumber = navParams.get('retake'); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + // Fetch the data. + this.fetchData().finally(() => { + this.loaded = true; + }); + } + + /** + * Change the retake displayed. + * + * @param {number} retakeNumber The new retake number. + */ + changeRetake(retakeNumber: number): void { + this.loaded = false; + + this.setRetake(retakeNumber).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error getting attempt.'); + }).finally(() => { + this.loaded = true; + }); + } + + /** + * Pull to refresh. + * + * @param {any} refresher Refresher. + */ + doRefresh(refresher: any): void { + this.refreshData().finally(() => { + refresher.complete(); + }); + } + + /** + * Get lesson and retake data. + * + * @return {Promise} Promise resolved when done. + */ + protected fetchData(): Promise { + return this.lessonProvider.getLessonById(this.courseId, this.lessonId).then((lessonData) => { + this.lesson = lessonData; + + // Get the retakes overview for all participants. + return this.lessonProvider.getRetakesOverview(this.lesson.id); + }).then((data) => { + // Search the student. + let student; + + if (data && data.students) { + for (let i = 0; i < data.students.length; i++) { + if (data.students[i].id == this.userId) { + student = data.students[i]; + break; + } + } + } + + if (!student) { + // Student not found. + return Promise.reject(this.translate.instant('addon.mod_lesson.cannotfinduser')); + } + + if (!student.attempts || !student.attempts.length) { + // No retakes. + return Promise.reject(this.translate.instant('addon.mod_lesson.cannotfindattempt')); + } + + student.bestgrade = this.textUtils.roundToDecimals(student.bestgrade, 2); + student.attempts.forEach((retake) => { + if (this.retakeNumber == retake.try) { + // The retake specified as parameter exists. Use it. + this.selectedRetake = this.retakeNumber; + } + + retake.label = this.lessonHelper.getRetakeLabel(retake); + }); + + if (!this.selectedRetake) { + // Retake number not specified or not valid, use the last retake. + this.selectedRetake = student.attempts[student.attempts.length - 1].try; + } + + // Get the profile image of the user. + return this.userProvider.getProfile(student.id, this.courseId, true).then((user) => { + student.profileimageurl = user.profileimageurl; + + return student; + }).catch(() => { + // Error getting profile, resolve promise without adding any extra data. + return student; + }); + }).then((student) => { + this.student = student; + + return this.setRetake(this.selectedRetake); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error getting data.', true); + }); + } + + /** + * Refreshes data. + * + * @return {Promise} Promise resolved when done. + */ + protected refreshData(): Promise { + const promises = []; + + promises.push(this.lessonProvider.invalidateLessonData(this.courseId)); + if (this.lesson) { + promises.push(this.lessonProvider.invalidateRetakesOverview(this.lesson.id)); + promises.push(this.lessonProvider.invalidateUserRetakesForUser(this.lesson.id, this.userId)); + } + + return Promise.all(promises).catch(() => { + // Ignore errors. + }).then(() => { + return this.fetchData(); + }); + } + + /** + * Set the retake to view and load its data. + * + * @param {number}retakeNumber Retake number to set. + * @return {Promise} Promise resolved when done. + */ + protected setRetake(retakeNumber: number): Promise { + this.selectedRetake = retakeNumber; + + return this.lessonProvider.getUserRetake(this.lessonId, retakeNumber, this.userId).then((data) => { + + if (data && data.completed != -1) { + // Completed. + data.userstats.grade = this.textUtils.roundToDecimals(data.userstats.grade, 2); + data.userstats.timetakenReadable = this.timeUtils.formatTime(data.userstats.timetotake); + } + + if (data && data.answerpages) { + // Format pages data. + data.answerpages.forEach((page) => { + if (this.lessonProvider.answerPageIsContent(page)) { + page.isContent = true; + + if (page.answerdata && page.answerdata.answers) { + page.answerdata.answers.forEach((answer) => { + // Content pages only have 1 valid field in the answer array. + answer[0] = this.lessonHelper.getContentPageAnswerDataFromHtml(answer[0]); + }); + } + } else if (this.lessonProvider.answerPageIsQuestion(page)) { + page.isQuestion = true; + + if (page.answerdata && page.answerdata.answers) { + page.answerdata.answers.forEach((answer) => { + // Only the first field of the answer array requires to be parsed. + answer[0] = this.lessonHelper.getQuestionPageAnswerDataFromHtml(answer[0]); + }); + } + } + }); + } + + this.retake = data; + }); + } +} diff --git a/src/addon/mod/lesson/providers/grade-link-handler.ts b/src/addon/mod/lesson/providers/grade-link-handler.ts new file mode 100644 index 000000000..f71c50586 --- /dev/null +++ b/src/addon/mod/lesson/providers/grade-link-handler.ts @@ -0,0 +1,95 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { NavController } from 'ionic-angular'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreContentLinksModuleGradeHandler } from '@core/contentlinks/classes/module-grade-handler'; +import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; +import { CoreCourseProvider } from '@core/course/providers/course'; +import { CoreCourseHelperProvider } from '@core/course/providers/helper'; +import { AddonModLessonProvider } from './lesson'; + +/** + * Handler to treat links to lesson grade. + */ +@Injectable() +export class AddonModLessonGradeLinkHandler extends CoreContentLinksModuleGradeHandler { + name = 'AddonModLessonGradeLinkHandler'; + canReview = true; + + constructor(courseHelper: CoreCourseHelperProvider, domUtils: CoreDomUtilsProvider, sitesProvider: CoreSitesProvider, + protected lessonProvider: AddonModLessonProvider, protected courseProvider: CoreCourseProvider, + protected linkHelper: CoreContentLinksHelperProvider) { + super(courseHelper, domUtils, sitesProvider, 'AddonModLesson', 'lesson'); + } + + /** + * Go to the page to review. + * + * @param {string} url The URL to treat. + * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param {number} courseId Course ID related to the URL. + * @param {string} siteId Site to use. + * @param {NavController} [navCtrl] Nav Controller to use to navigate. + * @return {Promise} Promise resolved when done. + */ + protected goToReview(url: string, params: any, courseId: number, siteId: string, navCtrl?: NavController): Promise { + + const moduleId = parseInt(params.id, 10), + modal = this.domUtils.showModalLoading(); + let module; + + return this.courseProvider.getModuleBasicInfo(moduleId, siteId).then((mod) => { + module = mod; + courseId = module.course || courseId || params.courseid || params.cid; + + // Check if the user can see the user reports in the lesson. + return this.lessonProvider.getAccessInformation(module.instance); + }).then((info) => { + if (info.canviewreports) { + // User can view reports, go to view the report. + const pageParams = { + courseId: Number(courseId), + lessonId: module.instance, + userId: parseInt(params.userid, 10) + }; + + this.linkHelper.goInSite(navCtrl, 'AddonModLessonUserRetakePage', pageParams, siteId); + } else { + // User cannot view the report, go to lesson index. + this.courseHelper.navigateToModule(moduleId, siteId, courseId, module.section); + } + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'core.course.errorgetmodule', true); + }).finally(() => { + modal.dismiss(); + }); + } + + /** + * Check if the handler is enabled for a certain site (site + user) and a URL. + * If not defined, defaults to true. + * + * @param {string} siteId The site ID. + * @param {string} url The URL to treat. + * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param {number} [courseId] Course ID related to the URL. Optional but recommended. + * @return {boolean|Promise} Whether the handler is enabled for the URL and site. + */ + isEnabled(siteId: string, url: string, params: any, courseId?: number): boolean | Promise { + return this.lessonProvider.isPluginEnabled(); + } +} diff --git a/src/addon/mod/lesson/providers/helper.ts b/src/addon/mod/lesson/providers/helper.ts new file mode 100644 index 000000000..617462a49 --- /dev/null +++ b/src/addon/mod/lesson/providers/helper.ts @@ -0,0 +1,494 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { FormBuilder, FormGroup } from '@angular/forms'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreTimeUtilsProvider } from '@providers/utils/time'; +import { AddonModLessonProvider } from './lesson'; +import * as moment from 'moment'; + +/** + * Helper service that provides some features for quiz. + */ +@Injectable() +export class AddonModLessonHelperProvider { + + protected div = document.createElement('div'); // A div element to search in HTML code. + + constructor(private domUtils: CoreDomUtilsProvider, private fb: FormBuilder, private translate: TranslateService, + private textUtils: CoreTextUtilsProvider, private timeUtils: CoreTimeUtilsProvider) { } + + /** + * Given the HTML of next activity link, format it to extract the href and the text. + * + * @param {string} activityLink HTML of the activity link. + * @return {{formatted: boolean, label: string, href: string}} Formatted data. + */ + formatActivityLink(activityLink: string): {formatted: boolean, label: string, href: string} { + this.div.innerHTML = activityLink; + const anchor = this.div.querySelector('a'); + if (!anchor) { + // Anchor not found, return the original HTML. + return { + formatted: false, + label: activityLink, + href: '' + }; + } + + return { + formatted: true, + label: anchor.innerHTML, + href: anchor.href + }; + } + + /** + * Given the HTML of an answer from a content page, extract the data to render the answer. + * + * @param {String} html Answer's HTML. + * @return {{buttonText: string, content: string}} Data to render the answer. + */ + getContentPageAnswerDataFromHtml(html: string): {buttonText: string, content: string} { + const data = { + buttonText: '', + content: '' + }; + + // Search the input button. + this.div.innerHTML = html; + const button = this.div.querySelector('input[type="button"]'); + + if (button) { + // Extract the button content and remove it from the HTML. + data.buttonText = button.value; + button.remove(); + } + + data.content = this.div.innerHTML.trim(); + + return data; + } + + /** + * Get the buttons to change pages. + * + * @param {string} html Page's HTML. + * @return {any[]} List of buttons. + */ + getPageButtonsFromHtml(html: string): any[] { + const buttons = []; + + // Get the container of the buttons if it exists. + this.div.innerHTML = html; + let buttonsContainer = this.div.querySelector('.branchbuttoncontainer'); + + if (!buttonsContainer) { + // Button container not found, might be a legacy lesson (from 1.9). + if (!this.div.querySelector('form input[type="submit"]')) { + // No buttons found. + return buttons; + } + buttonsContainer = this.div; + } + + const forms = Array.from(buttonsContainer.querySelectorAll('form')); + forms.forEach((form) => { + const buttonSelector = 'input[type="submit"], button[type="submit"]', + buttonEl = form.querySelector(buttonSelector), + inputs = Array.from(form.querySelectorAll('input')); + + if (!buttonEl || !inputs || !inputs.length) { + // Button not found or no inputs, ignore it. + return; + } + + const button = { + id: buttonEl.id, + title: buttonEl.title || buttonEl.value, + content: buttonEl.tagName == 'INPUT' ? buttonEl.value : buttonEl.innerHTML.trim(), + data: {} + }; + + inputs.forEach((input) => { + if (input.type != 'submit') { + button.data[input.name] = input.value; + } + }); + + buttons.push(button); + }); + + return buttons; + } + + /** + * Given a page data (result of getPageData), get the page contents. + * + * @param {any} data Page data. + * @return {string} Page contents. + */ + getPageContentsFromPageData(data: any): string { + // Search the page contents inside the whole page HTML. Use data.pagecontent because it's filtered. + this.div.innerHTML = data.pagecontent; + const contents = this.div.querySelector('.contents'); + + if (contents) { + return contents.innerHTML.trim(); + } + + // Cannot find contents element, return the page.contents (some elements like videos might not work). + return data.page.contents; + } + + /** + * Get a question and all the data required to render it from the page data (result of AddonModLessonProvider.getPageData). + * + * @param {FormGroup} questionForm The form group where to add the controls. + * @param {any} pageData Page data (result of $mmaModLesson#getPageData). + * @return {any} Question data. + */ + getQuestionFromPageData(questionForm: FormGroup, pageData: any): any { + const question: any = {}; + + // Get the container of the question answers if it exists. + this.div.innerHTML = pageData.pagecontent; + const fieldContainer = this.div.querySelector('.fcontainer'); + + // Get hidden inputs and add their data to the form group. + const hiddenInputs = Array.from(this.div.querySelectorAll('input[type="hidden"]')); + hiddenInputs.forEach((input) => { + questionForm.addControl(input.name, this.fb.control(input.value)); + }); + + // Get the submit button and extract its value. + const submitButton = this.div.querySelector('input[type="submit"]'); + question.submitLabel = submitButton ? submitButton.value : this.translate.instant('addon.mod_lesson.submit'); + + if (!fieldContainer) { + // Element not found, return. + return question; + } + + let type; + + switch (pageData.page.qtype) { + case AddonModLessonProvider.LESSON_PAGE_TRUEFALSE: + case AddonModLessonProvider.LESSON_PAGE_MULTICHOICE: + question.template = 'multichoice'; + question.options = []; + + // Get all the inputs. Search radio first. + let inputs = Array.from(fieldContainer.querySelectorAll('input[type="radio"]')); + if (!inputs || !inputs.length) { + // Radio buttons not found, it might be a multi answer. Search for checkbox. + question.multi = true; + inputs = Array.from(fieldContainer.querySelectorAll('input[type="checkbox"]')); + + if (!inputs || !inputs.length) { + // No checkbox found either. Stop. + return question; + } + } + + let controlAdded = false; + inputs.forEach((input) => { + const option: any = { + id: input.id, + name: input.name, + value: input.value, + checked: !!input.checked, + disabled: !!input.disabled + }, + parent = input.parentElement; + + if (option.checked || question.multi) { + // Add the control. + const value = question.multi ? {value: option.checked, disabled: option.disabled} : option.value; + questionForm.addControl(option.name, this.fb.control(value)); + controlAdded = true; + } + + // Remove the input and use the rest of the parent contents as the label. + input.remove(); + option.text = parent.innerHTML.trim(); + + question.options.push(option); + }); + + if (!question.multi) { + question.controlName = inputs[0].name; // All option have the same name in single choice. + + if (!controlAdded) { + // No checked option for single choice, add the control with an empty value. + questionForm.addControl(question.controlName, this.fb.control('')); + } + } + + break; + + case AddonModLessonProvider.LESSON_PAGE_NUMERICAL: + type = 'number'; + case AddonModLessonProvider.LESSON_PAGE_SHORTANSWER: + question.template = 'shortanswer'; + + // Get the input. + const input = fieldContainer.querySelector('input[type="text"], input[type="number"]'); + if (!input) { + return question; + } + + question.input = { + id: input.id, + name: input.name, + maxlength: input.maxLength, + type: type || 'text' + }; + + // Init the control. + questionForm.addControl(input.name, this.fb.control({value: input.value, disabled: input.readOnly})); + break; + + case AddonModLessonProvider.LESSON_PAGE_ESSAY: + question.template = 'essay'; + + // Get the textarea. + const textarea = fieldContainer.querySelector('textarea'); + + if (!textarea) { + // Textarea not found, probably review mode. + const answerEl = fieldContainer.querySelector('.reviewessay'); + if (!answerEl) { + // Answer not found, stop. + return question; + } + question.useranswer = answerEl.innerHTML; + + } else { + question.textarea = { + id: textarea.id, + name: textarea.name || 'answer[text]' + }; + + // Init the control. + question.control = this.fb.control(''); + questionForm.addControl(question.textarea.name, question.control); + } + + break; + + case AddonModLessonProvider.LESSON_PAGE_MATCHING: + question.template = 'matching'; + + const rows = Array.from(fieldContainer.querySelectorAll('.answeroption')); + question.rows = []; + + rows.forEach((row) => { + const label = row.querySelector('label'), + select = row.querySelector('select'), + options = Array.from(row.querySelectorAll('option')), + rowData: any = {}; + + if (!label || !select || !options || !options.length) { + return; + } + + // Get the row's text (label). + rowData.text = label.innerHTML.trim(); + rowData.id = select.id; + rowData.name = select.name; + rowData.options = []; + + // Treat each option. + let controlAdded = false; + options.forEach((option) => { + if (typeof option.value == 'undefined') { + // Option not valid, ignore it. + return; + } + + const opt = { + value: option.value, + label: option.innerHTML.trim(), + selected: option.selected + }; + + if (opt.selected) { + controlAdded = true; + questionForm.addControl(rowData.name, this.fb.control({value: opt.value, disabled: !!select.disabled})); + } + + rowData.options.push(opt); + }); + + if (!controlAdded) { + // No selected option, add the control with an empty value. + questionForm.addControl(rowData.name, this.fb.control({value: '', disabled: !!select.disabled})); + } + + question.rows.push(rowData); + }); + break; + default: + // Nothing to do. + } + + return question; + } + + /** + * Given the HTML of an answer from a question page, extract the data to render the answer. + * + * @param {string} html Answer's HTML. + * @return {any} Object with the data to render the answer. If the answer doesn't require any parsing, return a string with + * the HTML. + */ + getQuestionPageAnswerDataFromHtml(html: string): any { + const data: any = {}; + + this.div.innerHTML = html; + + // Check if it has a checkbox. + let input = this.div.querySelector('input[type="checkbox"][name*="answer"]'); + + if (input) { + // Truefalse or multichoice. + data.isCheckbox = true; + data.checked = !!input.checked; + data.name = input.name; + data.highlight = !!this.div.querySelector('.highlight'); + + input.remove(); + data.content = this.div.innerHTML.trim(); + + return data; + } + + // Check if it has an input text or number. + input = this.div.querySelector('input[type="number"],input[type="text"]'); + if (input) { + // Short answer or numeric. + data.isText = true; + data.value = input.value; + + return data; + } + + // Check if it has a select. + const select = this.div.querySelector('select'); + if (select && select.options) { + // Matching. + const selectedOption = select.options[select.selectedIndex]; + data.isSelect = true; + data.id = select.id; + if (selectedOption) { + data.value = selectedOption.value; + } else { + data.value = ''; + } + + select.remove(); + data.content = this.div.innerHTML.trim(); + + return data; + } + + // The answer doesn't need any parsing, return the HTML as it is. + return html; + } + + /** + * Get a label to identify a retake (lesson attempt). + * + * @param {any} retake Retake object. + * @param {boolean} [includeDuration] Whether to include the duration of the retake. + * @return {string} Retake label. + */ + getRetakeLabel(retake: any, includeDuration?: boolean): string { + const data = { + retake: retake.try + 1, + grade: '', + timestart: '', + duration: '' + }, + hasGrade = retake.grade != null; + + if (hasGrade || retake.end) { + // Retake finished with or without grade (if the lesson only has content pages, it has no grade). + if (hasGrade) { + data.grade = this.translate.instant('core.percentagenumber', {$a: retake.grade}); + } + data.timestart = moment(retake.timestart * 1000).format('LLL'); + if (includeDuration) { + data.duration = this.timeUtils.formatTime(retake.timeend - retake.timestart); + } + } else { + // The user has not completed the retake. + data.grade = this.translate.instant('addon.mod_lesson.notcompleted'); + if (retake.timestart) { + data.timestart = moment(retake.timestart * 1000).format('LLL'); + } + } + + return this.translate.instant('addon.mod_lesson.retakelabel' + (includeDuration ? 'full' : 'short'), data); + } + + /** + * Prepare the question data to be sent to server. + * + * @param {any} question Question to prepare. + * @param {any} data Data to prepare. + * @return {Promise} Promise resolved with the data to send when done. + */ + prepareQuestionData(question: any, data: any): Promise { + if (question.template == 'essay' && question.textarea) { + // The answer might need formatting. Check if rich text editor is enabled or not. + return this.domUtils.isRichTextEditorEnabled().then((enabled) => { + if (!enabled) { + // Rich text editor not enabled, add some HTML to the answer if needed. + data[question.textarea.property] = this.textUtils.formatHtmlLines(data[question.textarea.property]); + } + + return data; + }); + } else if (question.template == 'multichoice' && question.multi) { + // Only send the options with value set to true. + for (const name in data) { + if (name.match(/answer\[\d+\]/) && data[name] == false) { + delete data[name]; + } + } + } + + return Promise.resolve(data); + } + + /** + * Given the feedback of a process page in HTML, remove the question text. + * + * @param {string} html Feedback's HTML. + * @return {string} Feedback without the question text. + */ + removeQuestionFromFeedback(html: string): string { + this.div.innerHTML = html; + + // Remove the question text. + this.domUtils.removeElement(this.div, '.generalbox:not(.feedback):not(.correctanswer)'); + + return this.div.innerHTML.trim(); + } +} diff --git a/src/addon/mod/lesson/providers/index-link-handler.ts b/src/addon/mod/lesson/providers/index-link-handler.ts new file mode 100644 index 000000000..244c55fcd --- /dev/null +++ b/src/addon/mod/lesson/providers/index-link-handler.ts @@ -0,0 +1,105 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreContentLinksModuleIndexHandler } from '@core/contentlinks/classes/module-index-handler'; +import { CoreContentLinksAction } from '@core/contentlinks/providers/delegate'; +import { CoreCourseProvider } from '@core/course/providers/course'; +import { CoreCourseHelperProvider } from '@core/course/providers/helper'; +import { AddonModLessonProvider } from './lesson'; + +/** + * Handler to treat links to lesson index. + */ +@Injectable() +export class AddonModLessonIndexLinkHandler extends CoreContentLinksModuleIndexHandler { + name = 'AddonModLessonIndexLinkHandler'; + + constructor(courseHelper: CoreCourseHelperProvider, protected lessonProvider: AddonModLessonProvider, + protected domUtils: CoreDomUtilsProvider, protected courseProvider: CoreCourseProvider) { + super(courseHelper, 'AddonModLesson', 'lesson'); + } + + /** + * Get the list of actions for a link (url). + * + * @param {string[]} siteIds List of sites the URL belongs to. + * @param {string} url The URL to treat. + * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param {number} [courseId] Course ID related to the URL. Optional but recommended. + * @return {CoreContentLinksAction[]|Promise} List of (or promise resolved with list of) actions. + */ + getActions(siteIds: string[], url: string, params: any, courseId?: number): + CoreContentLinksAction[] | Promise { + + courseId = courseId || params.courseid || params.cid; + + return [{ + action: (siteId, navCtrl?): void => { + /* Ignore the pageid param. If we open the lesson player with a certain page and the user hasn't started + the lesson, an error is thrown: could not find lesson_timer records. */ + if (params.userpassword) { + this.navigateToModuleWithPassword(parseInt(params.id, 10), courseId, params.userpassword, siteId); + } else { + this.courseHelper.navigateToModule(parseInt(params.id, 10), siteId, courseId); + } + } + }]; + } + + /** + * Check if the handler is enabled for a certain site (site + user) and a URL. + * If not defined, defaults to true. + * + * @param {string} siteId The site ID. + * @param {string} url The URL to treat. + * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param {number} [courseId] Course ID related to the URL. Optional but recommended. + * @return {boolean|Promise} Whether the handler is enabled for the URL and site. + */ + isEnabled(siteId: string, url: string, params: any, courseId?: number): boolean | Promise { + return this.lessonProvider.isPluginEnabled(); + } + + /** + * Navigate to a lesson module (index page) with a fixed password. + * + * @param {number} moduleId Module ID. + * @param {number} courseId Course ID. + * @param {string} password Password. + * @param {string} siteId Site ID. + * @return {Promise} Promise resolved when navigated. + */ + protected navigateToModuleWithPassword(moduleId: number, courseId: number, password: string, siteId: string): Promise { + const modal = this.domUtils.showModalLoading(); + + // Get the module. + return this.courseProvider.getModuleBasicInfo(moduleId, siteId).then((module) => { + courseId = courseId || module.course; + + // Store the password so it's automatically used. + return this.lessonProvider.storePassword(parseInt(module.instance, 10), password, siteId).catch(() => { + // Ignore errors. + }).then(() => { + return this.courseHelper.navigateToModule(moduleId, siteId, courseId, module.section); + }); + }).catch(() => { + // Error, go to index page. + return this.courseHelper.navigateToModule(moduleId, siteId, courseId); + }).finally(() => { + modal.dismiss(); + }); + } +} diff --git a/src/addon/mod/lesson/providers/lesson-offline.ts b/src/addon/mod/lesson/providers/lesson-offline.ts new file mode 100644 index 000000000..d1d80cf14 --- /dev/null +++ b/src/addon/mod/lesson/providers/lesson-offline.ts @@ -0,0 +1,598 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { CoreLoggerProvider } from '@providers/logger'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreTimeUtilsProvider } from '@providers/utils/time'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { AddonModLessonProvider } from './lesson'; + +/** + * Service to handle offline lesson. + */ +@Injectable() +export class AddonModLessonOfflineProvider { + + protected logger; + + // Variables for database. We use lowercase in the names to match the WS responses. + protected RETAKES_TABLE = 'addon_mod_lesson_retakes'; + protected PAGE_ATTEMPTS_TABLE = 'addon_mod_lesson_page_attempts'; + protected tablesSchema = [ + { + name: this.RETAKES_TABLE, + columns: [ + { + name: 'lessonid', + type: 'INTEGER', + primaryKey: true // Only 1 offline retake per lesson. + }, + { + name: 'retake', // Retake number. + type: 'INTEGER', + notNull: true + }, + { + name: 'courseid', + type: 'INTEGER' + }, + { + name: 'finished', + type: 'INTEGER' + }, + { + name: 'outoftime', + type: 'INTEGER' + }, + { + name: 'timemodified', + type: 'INTEGER' + }, + { + name: 'lastquestionpage', + type: 'INTEGER' + }, + ] + }, + { + name: this.PAGE_ATTEMPTS_TABLE, + columns: [ + { + name: 'lessonid', + type: 'INTEGER', + notNull: true + }, + { + name: 'retake', // Retake number. + type: 'INTEGER', + notNull: true + }, + { + name: 'pageid', + type: 'INTEGER', + notNull: true + }, + { + name: 'timemodified', + type: 'INTEGER', + notNull: true + }, + { + name: 'courseid', + type: 'INTEGER' + }, + { + name: 'data', + type: 'TEXT' + }, + { + name: 'type', + type: 'INTEGER' + }, + { + name: 'newpageid', + type: 'INTEGER' + }, + { + name: 'correct', + type: 'INTEGER' + }, + { + name: 'answerid', + type: 'INTEGER' + }, + { + name: 'useranswer', + type: 'TEXT' + }, + ], + primaryKeys: ['lessonid', 'retake', 'pageid', 'timemodified'] // A user can attempt several times per page and retake. + } + ]; + + constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private timeUtils: CoreTimeUtilsProvider, + private textUtils: CoreTextUtilsProvider, private utils: CoreUtilsProvider) { + this.logger = logger.getInstance('AddonModLessonOfflineProvider'); + + this.sitesProvider.createTablesFromSchema(this.tablesSchema); + } + + /** + * Delete an offline attempt. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake Lesson retake number. + * @param {number} pageId Page ID. + * @param {number} timemodified The timemodified of the attempt. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + deleteAttempt(lessonId: number, retake: number, pageId: number, timemodified: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().deleteRecords(this.PAGE_ATTEMPTS_TABLE, { + lessonid: lessonId, + retake: retake, + pageid: pageId, + timemodified: timemodified + }); + }); + } + + /** + * Delete offline lesson retake. + * + * @param {number} lessonId Lesson ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + deleteRetake(lessonId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().deleteRecords(this.RETAKES_TABLE, {lessonid: lessonId}); + }); + } + + /** + * Delete offline attempts for a retake and page. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake Lesson retake number. + * @param {number} pageId Page ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + deleteRetakeAttemptsForPage(lessonId: number, retake: number, pageId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().deleteRecords(this.PAGE_ATTEMPTS_TABLE, {lessonid: lessonId, retake: retake, pageid: pageId}); + }); + } + + /** + * Mark a retake as finished. + * + * @param {number} lessonId Lesson ID. + * @param {number} courseId Course ID the lesson belongs to. + * @param {number} retake Retake number. + * @param {boolean} finished Whether retake is finished. + * @param {boolean} outOfTime If the user ran out of time. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved in success, rejected otherwise. + */ + finishRetake(lessonId: number, courseId: number, retake: number, finished?: boolean, outOfTime?: boolean, siteId?: string) + : Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + // Get current stored retake (if any). If not found, it will create a new one. + return this.getRetakeWithFallback(lessonId, courseId, retake, site.id).then((entry) => { + entry.finished = finished ? 1 : 0; + entry.outoftime = outOfTime ? 1 : 0; + entry.timemodified = this.timeUtils.timestamp(); + + return site.getDb().insertRecord(this.RETAKES_TABLE, entry); + }); + }); + } + + /** + * Get all the offline page attempts in a certain site. + * + * @param {string} [siteId] Site ID. If not set, use current site. + * @return {Promise} Promise resolved when the offline attempts are retrieved. + */ + getAllAttempts(siteId?: string): Promise { + return this.sitesProvider.getSiteDb(siteId).then((db) => { + return db.getAllRecords(this.PAGE_ATTEMPTS_TABLE); + }).then((attempts) => { + return this.parsePageAttempts(attempts); + }); + } + + /** + * Get all the lessons that have offline data in a certain site. + * + * @param {string} [siteId] Site ID. If not set, use current site. + * @return {Promise} Promise resolved with an object containing the lessons. + */ + getAllLessonsWithData(siteId?: string): Promise { + const promises = [], + lessons = {}; + + // Get the lessons from page attempts. + promises.push(this.getAllAttempts(siteId).then((entries) => { + this.getLessonsFromEntries(lessons, entries); + }).catch(() => { + // Ignore errors. + })); + + // Get the lessons from retakes. + promises.push(this.getAllRetakes(siteId).then((entries) => { + this.getLessonsFromEntries(lessons, entries); + }).catch(() => { + // Ignore errors. + })); + + return Promise.all(promises).then(() => { + return this.utils.objectToArray(lessons); + }); + } + + /** + * Get all the offline retakes in a certain site. + * + * @param {string} [siteId] Site ID. If not set, use current site. + * @return {Promise} Promise resolved when the offline retakes are retrieved. + */ + getAllRetakes(siteId?: string): Promise { + return this.sitesProvider.getSiteDb(siteId).then((db) => { + return db.getAllRecords(this.RETAKES_TABLE); + }); + } + + /** + * Retrieve the last offline attempt stored in a retake. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake Retake number. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the attempt (undefined if no attempts). + */ + getLastQuestionPageAttempt(lessonId: number, retake: number, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + return this.getRetakeWithFallback(lessonId, 0, retake, siteId).then((retakeData) => { + if (!retakeData.lastquestionpage) { + // No question page attempted. + return; + } + + return this.getRetakeAttemptsForPage(lessonId, retake, retakeData.lastquestionpage, siteId).then((attempts) => { + // Return the attempt with highest timemodified. + return attempts.reduce((a, b) => { + return a.timemodified > b.timemodified ? a : b; + }); + }); + }).catch(() => { + // Error, return undefined. + }); + } + + /** + * Retrieve all offline attempts for a lesson. + * + * @param {number} lessonId Lesson ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the attempts. + */ + getLessonAttempts(lessonId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().getRecords(this.PAGE_ATTEMPTS_TABLE, {lessonid: lessonId}); + }).then((attempts) => { + return this.parsePageAttempts(attempts); + }); + } + + /** + * Given a list of DB entries (either retakes or page attempts), get the list of lessons. + * + * @param {any} lessons Object where to store the lessons. + * @param {any[]} entries List of DB entries. + */ + protected getLessonsFromEntries(lessons: any, entries: any[]): void { + entries.forEach((entry) => { + if (!lessons[entry.lessonid]) { + lessons[entry.lessonid] = { + id: entry.lessonid, + courseId: entry.courseid + }; + } + }); + } + + /** + * Get attempts for question pages and retake in a lesson. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake Retake number. + * @param {boolean} [correct] True to only fetch correct attempts, false to get them all. + * @param {number} [pageId] If defined, only get attempts on this page. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the attempts. + */ + getQuestionsAttempts(lessonId: number, retake: number, correct?: boolean, pageId?: number, siteId?: string): Promise { + let promise; + + if (pageId) { + // Page ID is set, only get the attempts for that page. + promise = this.getRetakeAttemptsForPage(lessonId, retake, pageId, siteId); + } else { + // Page ID not specified, get all the attempts. + promise = this.getRetakeAttemptsForType(lessonId, retake, AddonModLessonProvider.TYPE_QUESTION, siteId); + } + + return promise.then((attempts) => { + if (correct) { + return attempts.filter((attempt) => { + return !!attempt.correct; + }); + } + + return attempts; + }); + } + + /** + * Retrieve a retake from site DB. + * + * @param {number} lessonId Lesson ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the retake. + */ + getRetake(lessonId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().getRecord(this.RETAKES_TABLE, {lessonid: lessonId}); + }); + } + + /** + * Retrieve all offline attempts for a retake. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake Retake number. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the retake attempts. + */ + getRetakeAttempts(lessonId: number, retake: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().getRecords(this.PAGE_ATTEMPTS_TABLE, {lessonid: lessonId, retake: retake}); + }).then((attempts) => { + return this.parsePageAttempts(attempts); + }); + } + + /** + * Retrieve offline attempts for a retake and page. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake Lesson retake number. + * @param {number} pageId Page ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the retake attempts. + */ + getRetakeAttemptsForPage(lessonId: number, retake: number, pageId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().getRecords(this.PAGE_ATTEMPTS_TABLE, {lessonid: lessonId, retake: retake, pageid: pageId}); + }).then((attempts) => { + return this.parsePageAttempts(attempts); + }); + } + + /** + * Retrieve offline attempts for certain pages for a retake. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake Retake number. + * @param {number} type Type of the pages to get: TYPE_QUESTION or TYPE_STRUCTURE. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the retake attempts. + */ + getRetakeAttemptsForType(lessonId: number, retake: number, type: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().getRecords(this.PAGE_ATTEMPTS_TABLE, {lessonid: lessonId, retake: retake, type: type}); + }).then((attempts) => { + return this.parsePageAttempts(attempts); + }); + } + + /** + * Get stored retake. If not found or doesn't match the retake number, return a new one. + * + * @param {number} lessonId Lesson ID. + * @param {number} courseId Course ID the lesson belongs to. + * @param {number} retake Retake number. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the retake. + */ + protected getRetakeWithFallback(lessonId: number, courseId: number, retake: number, siteId?: string): Promise { + // Get current stored retake. + return this.getRetake(lessonId, siteId).then((retakeData) => { + if (retakeData.retake != retake) { + // The stored retake doesn't match the retake number, create a new one. + return Promise.reject(null); + } + + return retakeData; + }).catch(() => { + // No retake, create a new one. + return { + lessonid: lessonId, + retake: retake, + courseid: courseId, + finished: 0 + }; + }); + } + + /** + * Check if there is a finished retake for a certain lesson. + * + * @param {number} lessonId Lesson ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with boolean. + */ + hasFinishedRetake(lessonId: number, siteId?: string): Promise { + return this.getRetake(lessonId, siteId).then((retake) => { + return !!retake.finished; + }).catch(() => { + return false; + }); + } + + /** + * Check if a lesson has offline data. + * + * @param {number} lessonId Lesson ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with boolean. + */ + hasOfflineData(lessonId: number, siteId?: string): Promise { + const promises = []; + let hasData = false; + + promises.push(this.getRetake(lessonId, siteId).then(() => { + hasData = true; + }).catch(() => { + // Ignore errors. + })); + + promises.push(this.getLessonAttempts(lessonId, siteId).then((attempts) => { + hasData = hasData || !!attempts.length; + }).catch(() => { + // Ignore errors. + })); + + return Promise.all(promises).then(() => { + return hasData; + }); + } + + /** + * Check if there are offline attempts for a retake. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake Retake number. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with a boolean. + */ + hasRetakeAttempts(lessonId: number, retake: number, siteId?: string): Promise { + return this.getRetakeAttempts(lessonId, retake, siteId).then((list) => { + return !!list.length; + }).catch(() => { + return false; + }); + } + + /** + * Parse some properties of a page attempt. + * + * @param {any} attempt The attempt to treat. + * @return {any} The treated attempt. + */ + protected parsePageAttempt(attempt: any): any { + attempt.data = this.textUtils.parseJSON(attempt.data); + attempt.useranswer = this.textUtils.parseJSON(attempt.useranswer); + + return attempt; + } + + /** + * Parse some properties of some page attempts. + * + * @param {any[]} attempts The attempts to treat. + * @return {any[]} The treated attempts. + */ + protected parsePageAttempts(attempts: any[]): any[] { + attempts.forEach((attempt) => { + this.parsePageAttempt(attempt); + }); + + return attempts; + } + + /** + * Process a lesson page, saving its data. + * + * @param {number} lessonId Lesson ID. + * @param {number} courseId Course ID the lesson belongs to. + * @param {number} retake Retake number. + * @param {any} page Page. + * @param {any} data Data to save. + * @param {number} newPageId New page ID (calculated). + * @param {number} [answerId] The answer ID that the user answered. + * @param {boolean} [correct] If answer is correct. Only for question pages. + * @param {any} [userAnswer] The user's answer (userresponse from checkAnswer). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved in success, rejected otherwise. + */ + processPage(lessonId: number, courseId: number, retake: number, page: any, data: any, newPageId: number, answerId?: number, + correct?: boolean, userAnswer?: any, siteId?: string): Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + const entry = { + lessonid: lessonId, + retake: retake, + pageid: page.id, + timemodified: this.timeUtils.timestamp(), + courseid: courseId, + data: data ? JSON.stringify(data) : null, + type: page.type, + newpageid: newPageId, + correct: correct ? 1 : 0, + answerid: Number(answerId), + useranswer: userAnswer ? JSON.stringify(userAnswer) : null, + }; + + return site.getDb().insertRecord(this.PAGE_ATTEMPTS_TABLE, entry); + }).then(() => { + if (page.type == AddonModLessonProvider.TYPE_QUESTION) { + // It's a question page, set it as last question page attempted. + return this.setLastQuestionPageAttempted(lessonId, courseId, retake, page.id, siteId); + } + }); + } + + /** + * Set the last question page attempted in a retake. + * + * @param {number} lessonId Lesson ID. + * @param {number} courseId Course ID the lesson belongs to. + * @param {number} retake Retake number. + * @param {number} lastPage ID of the last question page attempted. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved in success, rejected otherwise. + */ + setLastQuestionPageAttempted(lessonId: number, courseId: number, retake: number, lastPage: number, siteId?: string) + : Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + // Get current stored retake (if any). If not found, it will create a new one. + return this.getRetakeWithFallback(lessonId, courseId, retake, site.id).then((entry) => { + entry.lastquestionpage = lastPage; + entry.timemodified = this.timeUtils.timestamp(); + + return site.getDb().insertRecord(this.RETAKES_TABLE, entry); + }); + }); + } +} diff --git a/src/addon/mod/lesson/providers/lesson-sync.ts b/src/addon/mod/lesson/providers/lesson-sync.ts new file mode 100644 index 000000000..e2cd2841c --- /dev/null +++ b/src/addon/mod/lesson/providers/lesson-sync.ts @@ -0,0 +1,498 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { TranslateService } from '@ngx-translate/core'; +import { CoreAppProvider } from '@providers/app'; +import { CoreEventsProvider } from '@providers/events'; +import { CoreLoggerProvider } from '@providers/logger'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreSyncProvider } from '@providers/sync'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreTimeUtilsProvider } from '@providers/utils/time'; +import { CoreUrlUtilsProvider } from '@providers/utils/url'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreCourseProvider } from '@core/course/providers/course'; +import { CoreSyncBaseProvider } from '@classes/base-sync'; +import { AddonModLessonProvider } from './lesson'; +import { AddonModLessonOfflineProvider } from './lesson-offline'; +import { AddonModLessonPrefetchHandler } from './prefetch-handler'; + +/** + * Data returned by a lesson sync. + */ +export interface AddonModLessonSyncResult { + /** + * List of warnings. + * @type {string[]} + */ + warnings: string[]; + + /** + * Whether some data was sent to the server or offline data was updated. + * @type {boolean} + */ + updated: boolean; +} + +/** + * Service to sync lesson. + */ +@Injectable() +export class AddonModLessonSyncProvider extends CoreSyncBaseProvider { + + static AUTO_SYNCED = 'addon_mod_lesson_autom_synced'; + static SYNC_TIME = 300000; + + protected componentTranslate: string; + + // Variables for database. + protected RETAKES_FINISHED_TABLE = 'addon_mod_lesson_retakes_finished_sync'; + protected tablesSchema = { + name: this.RETAKES_FINISHED_TABLE, + columns: [ + { + name: 'lessonId', + type: 'INTEGER', + primaryKey: true + }, + { + name: 'retake', + type: 'INTEGER' + }, + { + name: 'pageId', + type: 'INTEGER' + }, + { + name: 'timefinished', + type: 'INTEGER' + } + ] + }; + + constructor(loggerProvider: CoreLoggerProvider, sitesProvider: CoreSitesProvider, appProvider: CoreAppProvider, + syncProvider: CoreSyncProvider, textUtils: CoreTextUtilsProvider, translate: TranslateService, + courseProvider: CoreCourseProvider, private eventsProvider: CoreEventsProvider, + private lessonProvider: AddonModLessonProvider, private lessonOfflineProvider: AddonModLessonOfflineProvider, + private prefetchHandler: AddonModLessonPrefetchHandler, private timeUtils: CoreTimeUtilsProvider, + private utils: CoreUtilsProvider, private urlUtils: CoreUrlUtilsProvider) { + + super('AddonModLessonSyncProvider', loggerProvider, sitesProvider, appProvider, syncProvider, textUtils, translate); + + this.componentTranslate = courseProvider.translateModuleName('lesson'); + + this.sitesProvider.createTableFromSchema(this.tablesSchema); + } + + /** + * Unmark a retake as finished in a synchronization. + * + * @param {number} lessonId Lesson ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + deleteRetakeFinishedInSync(lessonId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().deleteRecords(this.RETAKES_FINISHED_TABLE, {lessonId}); + }).catch(() => { + // Ignore errors, maybe there is none. + }); + } + + /** + * Get a retake finished in a synchronization for a certain lesson (if any). + * + * @param {number} lessonId Lesson ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the retake entry (undefined if no retake). + */ + getRetakeFinishedInSync(lessonId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().getRecord(this.RETAKES_FINISHED_TABLE, {lessonId}); + }).catch(() => { + // Ignore errors, return undefined. + }); + } + + /** + * Check if a lesson has data to synchronize. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake Retake number. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with boolean: whether it has data to sync. + */ + hasDataToSync(lessonId: number, retake: number, siteId?: string): Promise { + const promises = []; + let hasDataToSync = false; + + promises.push(this.lessonOfflineProvider.hasRetakeAttempts(lessonId, retake, siteId).then((hasAttempts) => { + hasDataToSync = hasDataToSync || hasAttempts; + }).catch(() => { + // Ignore errors. + })); + + promises.push(this.lessonOfflineProvider.hasFinishedRetake(lessonId, siteId).then((hasFinished) => { + hasDataToSync = hasDataToSync || hasFinished; + })); + + return Promise.all(promises).then(() => { + return hasDataToSync; + }); + } + + /** + * Mark a retake as finished in a synchronization. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake The retake number. + * @param {number} pageId The page ID to start reviewing from. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + setRetakeFinishedInSync(lessonId: number, retake: number, pageId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().insertRecord(this.RETAKES_FINISHED_TABLE, { + lessonId: lessonId, + retake: Number(retake), + pageId: Number(pageId), + timefinished: this.timeUtils.timestamp() + }); + }); + } + + /** + * Try to synchronize all the lessons in a certain site or in all sites. + * + * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @return {Promise} Promise resolved if sync is successful, rejected if sync fails. + */ + syncAllLessons(siteId?: string): Promise { + return this.syncOnSites('all lessons', this.syncAllLessonsFunc.bind(this), [], siteId); + } + + /** + * Sync all lessons on a site. + * + * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @param {Promise} Promise resolved if sync is successful, rejected if sync fails. + */ + protected syncAllLessonsFunc(siteId?: string): Promise { + // Get all the lessons that have something to be synchronized. + return this.lessonOfflineProvider.getAllLessonsWithData(siteId).then((lessons) => { + // Sync all lessons that haven't been synced for a while. + const promises = []; + + lessons.forEach((lesson) => { + promises.push(this.syncLessonIfNeeded(lesson.id, false, siteId).then((result) => { + if (result && result.updated) { + // Sync successful, send event. + this.eventsProvider.trigger(AddonModLessonSyncProvider.AUTO_SYNCED, { + lessonId: lesson.id, + warnings: result.warnings + }, siteId); + } + })); + }); + + return Promise.all(promises); + }); + } + + /** + * Sync a lesson only if a certain time has passed since the last time. + * + * @param {any} lessonId Lesson ID. + * @param {boolean} [askPreflight] Whether we should ask for password if needed. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the lesson is synced or if it doesn't need to be synced. + */ + syncLessonIfNeeded(lessonId: number, askPassword?: boolean, siteId?: string): Promise { + return this.isSyncNeeded(lessonId, siteId).then((needed) => { + if (needed) { + return this.syncLesson(lessonId, askPassword, false, siteId); + } + }); + } + + /** + * Try to synchronize a lesson. + * + * @param {number} lessonId Lesson ID. + * @param {boolean} askPassword True if we should ask for password if needed, false otherwise. + * @param {boolean} ignoreBlock True to ignore the sync block setting. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved in success. + */ + syncLesson(lessonId: number, askPassword?: boolean, ignoreBlock?: boolean, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const result: AddonModLessonSyncResult = { + warnings: [], + updated: false + }; + let syncPromise, + lesson, + courseId, + password, + accessInfo; + + if (this.isSyncing(lessonId, siteId)) { + // There's already a sync ongoing for this lesson, return the promise. + return this.getOngoingSync(lessonId, siteId); + } + + // Verify that lesson isn't blocked. + if (!ignoreBlock && this.syncProvider.isBlocked(AddonModLessonProvider.COMPONENT, lessonId, siteId)) { + this.logger.debug('Cannot sync lesson ' + lessonId + ' because it is blocked.'); + + return Promise.reject(this.translate.instant('core.errorsyncblocked', {$a: this.componentTranslate})); + } + + this.logger.debug('Try to sync lesson ' + lessonId + ' in site ' + siteId); + + // Try to synchronize the attempts first. + syncPromise = this.lessonOfflineProvider.getLessonAttempts(lessonId, siteId).then((attempts) => { + if (!attempts.length) { + return; + } else if (!this.appProvider.isOnline()) { + // Cannot sync in offline. + return Promise.reject(null); + } + + courseId = attempts[0].courseid; + + // Get the info, access info and the lesson password if needed. + return this.lessonProvider.getLessonById(courseId, lessonId, false, siteId).then((lessonData) => { + lesson = lessonData; + + return this.prefetchHandler.getLessonPassword(lessonId, false, true, askPassword, siteId); + }).then((data) => { + const attemptsLength = attempts.length, + promises = []; + + accessInfo = data.accessInfo; + password = data.password; + lesson = data.lesson || lesson; + + // Filter the attempts, get only the ones that belong to the current retake. + attempts = attempts.filter((attempt) => { + if (attempt.retake != accessInfo.attemptscount) { + // Attempt doesn't belong to current retake, delete. + promises.push(this.lessonOfflineProvider.deleteAttempt(lesson.id, attempt.retake, attempt.pageid, + attempt.timemodified, siteId).catch(() => { + // Ignore errors. + })); + + return false; + } + + return true; + }); + + if (attempts.length != attemptsLength) { + // Some attempts won't be sent, add a warning. + result.warnings.push(this.translate.instant('core.warningofflinedatadeleted', { + component: this.componentTranslate, + name: lesson.name, + error: this.translate.instant('addon.mod_lesson.warningretakefinished') + })); + } + + return Promise.all(promises); + }).then(() => { + if (!attempts.length) { + return; + } + + // Send the attempts in the same order they were answered. + attempts.sort((a, b) => { + return a.timemodified - b.timemodified; + }); + + attempts = attempts.map((attempt) => { + return { + func: this.sendAttempt.bind(this), + params: [lesson, password, attempt, result, siteId], + blocking: true + }; + }); + + return this.utils.executeOrderedPromises(attempts); + }); + }).then(() => { + // Attempts sent or there was none. If there is a finished retake, send it. + return this.lessonOfflineProvider.getRetake(lessonId, siteId).then((retake) => { + if (!retake.finished) { + // The retake isn't marked as finished, nothing to send. Delete the retake. + return this.lessonOfflineProvider.deleteRetake(lessonId, siteId); + } else if (!this.appProvider.isOnline()) { + // Cannot sync in offline. + return Promise.reject(null); + } + + let promise; + + courseId = retake.courseid || courseId; + + if (lesson) { + // Data already retrieved when syncing attempts. + promise = Promise.resolve(); + } else { + promise = this.lessonProvider.getLessonById(courseId, lessonId, false, siteId).then((lessonData) => { + lesson = lessonData; + + return this.prefetchHandler.getLessonPassword(lessonId, false, true, askPassword, siteId); + }).then((data) => { + accessInfo = data.accessInfo; + password = data.password; + lesson = data.lesson || lesson; + }); + } + + return promise.then(() => { + if (retake.retake != accessInfo.attemptscount) { + // The retake changed, add a warning if it isn't there already. + if (!result.warnings.length) { + result.warnings.push(this.translate.instant('core.warningofflinedatadeleted', { + component: this.componentTranslate, + name: lesson.name, + error: this.translate.instant('addon.mod_lesson.warningretakefinished') + })); + } + + return this.lessonOfflineProvider.deleteRetake(lessonId, siteId); + } + + // All good, finish the retake. + return this.lessonProvider.finishRetakeOnline(lessonId, password, false, false, siteId).then((response) => { + result.updated = true; + + if (!ignoreBlock) { + // Mark the retake as finished in a sync if it can be reviewed. + if (response.data && response.data.reviewlesson) { + const params = this.urlUtils.extractUrlParams(response.data.reviewlesson.value); + if (params && params.pageid) { + // The retake can be reviewed, mark it as finished. Don't block the user for this. + this.setRetakeFinishedInSync(lessonId, retake.retake, params.pageid, siteId); + } + } + } + + return this.lessonOfflineProvider.deleteRetake(lessonId, siteId); + }).catch((error) => { + if (error && this.utils.isWebServiceError(error)) { + // The WebService has thrown an error, this means that responses cannot be submitted. Delete them. + result.updated = true; + + return this.lessonOfflineProvider.deleteRetake(lessonId, siteId).then(() => { + // Retake deleted, add a warning. + result.warnings.push(this.translate.instant('core.warningofflinedatadeleted', { + component: this.componentTranslate, + name: lesson.name, + error: error + })); + }); + } else { + // Couldn't connect to server, reject. + return Promise.reject(error); + } + }); + }); + }, () => { + // No retake stored, nothing to do. + }); + }).then(() => { + if (result.updated && courseId) { + // Data has been sent to server. Now invalidate the WS calls. + const promises = []; + + promises.push(this.lessonProvider.invalidateAccessInformation(lessonId, siteId)); + promises.push(this.lessonProvider.invalidateContentPagesViewed(lessonId, siteId)); + promises.push(this.lessonProvider.invalidateQuestionsAttempts(lessonId, siteId)); + promises.push(this.lessonProvider.invalidatePagesPossibleJumps(lessonId, siteId)); + promises.push(this.lessonProvider.invalidateTimers(lessonId, siteId)); + + return this.utils.allPromises(promises).catch(() => { + // Ignore errors. + }).then(() => { + // Sync successful, update some data that might have been modified. + return this.lessonProvider.getAccessInformation(lessonId, false, false, siteId).then((info) => { + const promises = [], + retake = info.attemptscount; + + promises.push(this.lessonProvider.getContentPagesViewedOnline(lessonId, retake, false, false, siteId)); + promises.push(this.lessonProvider.getQuestionsAttemptsOnline(lessonId, retake, false, undefined, false, + false, siteId)); + + return Promise.all(promises); + }).catch(() => { + // Ignore errors. + }); + }); + } + }).then(() => { + // Sync finished, set sync time. + return this.setSyncTime(lessonId, siteId).catch(() => { + // Ignore errors. + }); + }).then(() => { + // All done, return the result. + return result; + }); + + return this.addOngoingSync(lessonId, syncPromise, siteId); + } + + /** + * Send an attempt to the site and delete it afterwards. + * + * @param {any} lesson Lesson. + * @param {string} password Password (if any). + * @param {any} attempt Attempt to send. + * @param {AddonModLessonSyncResult} result Result where to store the data. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + protected sendAttempt(lesson: any, password: string, attempt: any, result: AddonModLessonSyncResult, siteId?: string) + : Promise { + + return this.lessonProvider.processPageOnline(lesson.id, attempt.pageid, attempt.data, password, false, siteId).then(() => { + result.updated = true; + + return this.lessonOfflineProvider.deleteAttempt(lesson.id, attempt.retake, attempt.pageid, attempt.timemodified, + siteId); + }).catch((error) => { + if (error && this.utils.isWebServiceError(error)) { + // The WebService has thrown an error, this means that the attempt cannot be submitted. Delete it. + result.updated = true; + + return this.lessonOfflineProvider.deleteAttempt(lesson.id, attempt.retake, attempt.pageid, attempt.timemodified, + siteId).then(() => { + + // Attempt deleted, add a warning. + result.warnings.push(this.translate.instant('core.warningofflinedatadeleted', { + component: this.componentTranslate, + name: lesson.name, + error: error + })); + }); + } else { + // Couldn't connect to server, reject. + return Promise.reject(error); + } + }); + } +} diff --git a/src/addon/mod/lesson/providers/lesson.ts b/src/addon/mod/lesson/providers/lesson.ts new file mode 100644 index 000000000..529b332e4 --- /dev/null +++ b/src/addon/mod/lesson/providers/lesson.ts @@ -0,0 +1,3235 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { TranslateService } from '@ngx-translate/core'; +import { CoreLoggerProvider } from '@providers/logger'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreGradesProvider } from '@core/grades/providers/grades'; +import { CoreSiteWSPreSets } from '@classes/site'; +import { AddonModLessonOfflineProvider } from './lesson-offline'; + +/** + * Result of check answer. + */ +export interface AddonModLessonCheckAnswerResult { + answerid?: number; + noanswer?: boolean; + correctanswer?: boolean; + isessayquestion?: boolean; + response?: string; + newpageid?: number; + studentanswer?: any; + userresponse?: any; + feedback?: string; + nodefaultresponse?: boolean; + inmediatejump?: boolean; + studentanswerformat?: number; + useranswer?: any; +} + +/** + * Result of record attempt. + */ +export interface AddonModLessonRecordAttemptResult extends AddonModLessonCheckAnswerResult { + attemptsremaining?: number; + maxattemptsreached?: boolean; +} + +/** + * Result of lesson grade. + */ +export interface AddonModLessonGrade { + /** + * Number of questions answered. + * @type {number} + */ + nquestions: number; + + /** + * Number of question attempts. + * @type {number} + */ + attempts: number; + + /** + * Max points possible. + * @type {number} + */ + total: number; + + /** + * Points earned by the student. + * @type {number} + */ + earned: number; + + /** + * Calculated percentage grade. + * @type {number} + */ + grade: number; + + /** + * Numer of manually graded questions. + * @type {number} + */ + nmanual: number; + + /** + * Point value for manually graded questions. + * @type {number} + */ + manualpoints: number; +} + +/** + * Service that provides some features for lesson. + * + * Lesson terminology is a bit confusing and ambiguous in Moodle. For that reason, in the app it has been decided to use + * the following terminology: + * - Retake: An attempt in a lesson. In Moodle it's sometimes called "attempt", "try" or "retry". + * - Attempt: An attempt in a page inside a retake. In the app, this includes content pages. + * - Content page: A page with only content (no question). In Moodle it's sometimes called "branch table". + * - Page answers: List of possible answers for a page (configured by the teacher). NOT the student answer for the page. + * + * This terminology sometimes won't match with WebServices names, params or responses. + */ +@Injectable() +export class AddonModLessonProvider { + static COMPONENT = 'mmaModLesson'; + + // This page. + static LESSON_THISPAGE = 0; + // Next page -> any page not seen before. + static LESSON_UNSEENPAGE = 1; + // Next page -> any page not answered correctly. + static LESSON_UNANSWEREDPAGE = 2; + // Jump to Next Page. + static LESSON_NEXTPAGE = -1; + // End of Lesson. + static LESSON_EOL = -9; + // Jump to an unseen page within a branch and end of branch or end of lesson. + static LESSON_UNSEENBRANCHPAGE = -50; + // Jump to a random page within a branch and end of branch or end of lesson. + static LESSON_RANDOMPAGE = -60; + // Jump to a random Branch. + static LESSON_RANDOMBRANCH = -70; + // Cluster Jump. + static LESSON_CLUSTERJUMP = -80; + + // Type of page: question or structure (content). + static TYPE_QUESTION = 0; + static TYPE_STRUCTURE = 1; + + // Type of question pages. + static LESSON_PAGE_SHORTANSWER = 1; + static LESSON_PAGE_TRUEFALSE = 2; + static LESSON_PAGE_MULTICHOICE = 3; + static LESSON_PAGE_MATCHING = 5; + static LESSON_PAGE_NUMERICAL = 8; + static LESSON_PAGE_ESSAY = 10; + static LESSON_PAGE_BRANCHTABLE = 20; // Content page. + static LESSON_PAGE_ENDOFBRANCH = 21; + static LESSON_PAGE_CLUSTER = 30; + static LESSON_PAGE_ENDOFCLUSTER = 31; + + // Variables for database. + protected PASSWORD_TABLE = 'addon_mod_lesson_password'; + protected tablesSchema = { + name: this.PASSWORD_TABLE, + columns: [ + { + name: 'lessonId', + type: 'INTEGER', + primaryKey: true + }, + { + name: 'password', + type: 'TEXT' + }, + { + name: 'timemodified', + type: 'INTEGER' + } + ] + }; + + protected ROOT_CACHE_KEY = 'mmaModLesson:'; + protected logger; + protected div = document.createElement('div'); // A div element to search in HTML code. + + constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private utils: CoreUtilsProvider, + private translate: TranslateService, private textUtils: CoreTextUtilsProvider, + private lessonOfflineProvider: AddonModLessonOfflineProvider) { + this.logger = logger.getInstance('AddonModLessonProvider'); + + this.sitesProvider.createTableFromSchema(this.tablesSchema); + } + + /** + * Add a message to a list of messages, following the format of the messages returned by WS. + * + * @param {any[]} messages List of messages where to add the message. + * @param {string} stringName The ID of the message to be translated. E.g. 'addon.mod_lesson.numberofpagesviewednotice'. + * @param {any} [stringParams] The params of the message (if any). + */ + protected addMessage(messages: any[], stringName: string, stringParams?: any): void { + messages.push({ + message: this.translate.instant(stringName, stringParams) + }); + } + + /** + * Add a property to the result of the "process EOL page" simulation in offline. + * + * @param {any} result Result where to add the value. + * @param {string} name Name of the property. + * @param {any} value Value to add. + * @param {boolean} addMessage Whether to add a message related to the value. + */ + protected addResultValueEolPage(result: any, name: string, value: any, addMessage?: boolean): void { + let message = ''; + + if (addMessage) { + const params = typeof value != 'boolean' ? {$a: value} : undefined; + message = this.translate.instant('addon.mod_lesson.' + name, params); + } + + result.data[name] = { + name: name, + value: value, + message: message + }; + } + + /** + * Check if an answer page (from getUserRetake) is a content page. + * + * @param {any} page Answer page. + * @return {boolean} Whether it's a content page. + */ + answerPageIsContent(page: any): boolean { + // The page doesn't have any reliable field to use for checking this. Check qtype first (translated string). + if (page.qtype == this.translate.instant('addon.mod_lesson.branchtable')) { + return true; + } + + // The qtype doesn't match, but that doesn't mean it's not a content page, maybe the language is different. + // Check it's not a question page. + if (page.answerdata && !this.answerPageIsQuestion(page)) { + // It isn't a question page, but it can be an end of branch, etc. Check if the first answer has a button. + if (page.answerdata.answers && page.answerdata.answers[0]) { + this.div.innerHTML = page.answerdata.answers[0][0]; + + return !!this.div.querySelector('input[type="button"]'); + } + } + + return false; + } + + /** + * Check if an answer page (from getUserRetake) is a question page. + * + * @param {any} page Answer page. + * @return {boolean} Whether it's a question page. + */ + answerPageIsQuestion(page: any): boolean { + if (!page.answerdata) { + return false; + } + + if (page.answerdata.score) { + // Only question pages have a score. + return true; + } + + if (page.answerdata.answers) { + for (let i = 0; i < page.answerdata.answers.length; i++) { + const answer = page.answerdata.answers[i]; + if (answer[1]) { + // Only question pages have a statistic. + return true; + } + } + } + + return false; + } + + /** + * Calculate some offline data like progress and ongoingscore. + * + * @param {any} lesson Lesson. + * @param {any} accessInfo Result of get access info. + * @param {string} [password] Lesson password (if any). + * @param {boolean} [review] If the user wants to review just after finishing (1 hour margin). + * @param {any} [pageIndex] Object containing all the pages indexed by ID. If not defined, it will be calculated. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise<{reviewMode: boolean, progress: number, ongoingScore: string}>} Promise resolved with the data. + */ + protected calculateOfflineData(lesson: any, accessInfo?: any, password?: string, review?: boolean, pageIndex?: any, + siteId?: string): Promise<{reviewMode: boolean, progress: number, ongoingScore: string}> { + + accessInfo = accessInfo || {}; + + const reviewMode = review || accessInfo.reviewmode, + promises = []; + let ongoingMessage = '', + progress: number; + + if (!accessInfo.canmanage) { + if (lesson.ongoing && !reviewMode) { + promises.push(this.getOngoingScoreMessage(lesson, accessInfo, password, review, pageIndex, siteId) + .then((message) => { + ongoingMessage = message; + })); + } + if (lesson.progressbar) { + promises.push(this.calculateProgress(lesson.id, accessInfo, password, review, pageIndex, siteId).then((p) => { + progress = p; + })); + } + } + + return Promise.all(promises).then(() => { + return { + reviewMode: reviewMode, + progress: progress, + ongoingScore: ongoingMessage + }; + }); + } + + /** + * Calculate the progress of the current user in the lesson. + * Based on Moodle's calculate_progress. + * + * @param {number} lessonId Lesson ID. + * @param {any} accessInfo Result of get access info. + * @param {string} [password] Lesson password (if any). + * @param {boolean} [review] If the user wants to review just after finishing (1 hour margin). + * @param {any} [pageIndex] Object containing all the pages indexed by ID. If not defined, it will be calculated. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with a number: the progress (scale 0-100). + */ + calculateProgress(lessonId: number, accessInfo: any, password?: string, review?: boolean, pageIndex?: any, siteId?: string) + : Promise { + + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + // Check if the user is reviewing the attempt. + if (review) { + return Promise.resolve(100); + } + + const retake = accessInfo.attemptscount; + let viewedPagesIds, + promise; + + if (pageIndex) { + promise = Promise.resolve(); + } else { + // Retrieve the index. + promise = this.getPages(lessonId, password, true, false, siteId).then((pages) => { + pageIndex = this.createPagesIndex(pages); + }); + } + + return promise.then(() => { + // Get the list of question pages attempted. + return this.getPagesIdsWithQuestionAttempts(lessonId, retake, false, siteId); + }).then((ids) => { + viewedPagesIds = ids; + + // Get the list of viewed content pages. + return this.getContentPagesViewedIds(lessonId, retake, siteId); + }).then((viewedContentPagesIds) => { + const validPages = {}; + let pageId = accessInfo.firstpageid; + + viewedPagesIds = this.utils.mergeArraysWithoutDuplicates(viewedPagesIds, viewedContentPagesIds); + + // Filter out the following pages: + // - End of Cluster + // - End of Branch + // - Pages found inside of Clusters + // Do not filter out Cluster Page(s) because we count a cluster as one. + // By keeping the cluster page, we get our 1. + while (pageId) { + pageId = this.validPageAndView(pageIndex, pageIndex[pageId], validPages, viewedPagesIds); + } + + // Progress calculation as a percent. + return this.textUtils.roundToDecimals(viewedPagesIds.length / Object.keys(validPages).length, 2) * 100; + }); + } + + /** + * Check if the answer provided by the user is correct or not and return the result object. + * This method is based on the check_answer implementation of all page types (Moodle). + * + * @param {any} lesson Lesson. + * @param {any} pageData Result of getPageData for the page to process. + * @param {any} data Data containing the user answer. + * @param {any} jumps Result of get pages possible jumps. + * @param {any} pageIndex Object containing all the pages indexed by ID. + * @return {AddonModLessonCheckAnswerResult} Result. + */ + protected checkAnswer(lesson: any, pageData: any, data: any, jumps: any, pageIndex: any): AddonModLessonCheckAnswerResult { + // Default result. + const result: AddonModLessonCheckAnswerResult = { + answerid: 0, + noanswer: false, + correctanswer: false, + isessayquestion: false, + response: '', + newpageid: 0, + studentanswer: '', + userresponse: null, + feedback: '', + nodefaultresponse: false, + inmediatejump: false + }; + + switch (pageData.page.qtype) { + case AddonModLessonProvider.LESSON_PAGE_BRANCHTABLE: + // Load the new page immediately. + result.inmediatejump = true; + result.newpageid = this.getNewPageId(pageData.page.id, data.jumpto, jumps); + break; + + case AddonModLessonProvider.LESSON_PAGE_ESSAY: + this.checkAnswerEssay(pageData, data, result); + break; + + case AddonModLessonProvider.LESSON_PAGE_MATCHING: + this.checkAnswerMatching(pageData, data, result); + break; + + case AddonModLessonProvider.LESSON_PAGE_MULTICHOICE: + this.checkAnswerMultichoice(lesson, pageData, data, pageIndex, result); + break; + + case AddonModLessonProvider.LESSON_PAGE_NUMERICAL: + this.checkAnswerNumerical(lesson, pageData, data, pageIndex, result); + break; + + case AddonModLessonProvider.LESSON_PAGE_SHORTANSWER: + this.checkAnswerShort(lesson, pageData, data, pageIndex, result); + break; + + case AddonModLessonProvider.LESSON_PAGE_TRUEFALSE: + this.checkAnswerTruefalse(lesson, pageData, data, pageIndex, result); + break; + default: + // Nothing to do. + } + + return result; + } + + /** + * Check an essay answer. + * + * @param {any} pageData Result of getPageData for the page to process. + * @param {any} data Data containing the user answer. + * @param {AddonModLessonCheckAnswerResult} result Object where to store the result. + */ + protected checkAnswerEssay(pageData: any, data: any, result: AddonModLessonCheckAnswerResult): void { + let studentAnswer; + + result.isessayquestion = true; + + if (!data) { + result.inmediatejump = true; + result.newpageid = pageData.page.id; + + return; + } + + if (typeof data['answer[text]'] != 'undefined') { + studentAnswer = data['answer[text]']; + } else if (typeof data.answer == 'object') { + studentAnswer = data.answer.text; + } else { + studentAnswer = data.answer; + } + + if (!studentAnswer || studentAnswer.trim() === '') { + result.noanswer = true; + + return; + } + + // Essay pages should only have 1 possible answer. + pageData.answers.forEach((answer) => { + result.answerid = answer.id; + result.newpageid = answer.jumpto; + }); + + result.userresponse = { + sent: 0, + graded: 0, + score: 0, + answer: studentAnswer, + answerformat: 1, + response: '', + responseformat: 1 + }; + result.studentanswerformat = 1; + result.studentanswer = studentAnswer; + } + + /** + * Check a matching answer. + * + * @param {any} pageData Result of getPageData for the page to process. + * @param {any} data Data containing the user answer. + * @param {AddonModLessonCheckAnswerResult} result Object where to store the result. + */ + protected checkAnswerMatching(pageData: any, data: any, result: AddonModLessonCheckAnswerResult): void { + if (!data) { + result.inmediatejump = true; + result.newpageid = pageData.page.id; + + return; + } + + const response = this.getUserResponseMatching(data), + getAnswers = this.utils.clone(pageData.answers), + correct = getAnswers.shift(), + wrong = getAnswers.shift(), + answers = {}; + + getAnswers.forEach((answer) => { + if (answer.answer !== '' || answer.response !== '') { + answers[answer.id] = answer; + } + }); + + // Get the user's exact responses for record keeping. + const userResponse = []; + let hits = 0; + + result.studentanswer = ''; + result.studentanswerformat = 1; + + for (const id in response) { + let value = response[id]; + + if (!value) { + result.noanswer = true; + + return; + } + + value = this.textUtils.decodeHTML(value); + userResponse.push(value); + + if (typeof answers[id] != 'undefined') { + const answer = answers[id]; + + result.studentanswer += '
' + answer.answer + ' = ' + value; + if (answer.response && answer.response.trim() == value.trim()) { + hits++; + } + } + } + + result.userresponse = userResponse.join(','); + + if (hits == Object.keys(answers).length) { + result.correctanswer = true; + result.response = correct.answer; + result.answerid = correct.id; + result.newpageid = correct.jumpto; + } else { + result.correctanswer = false; + result.response = wrong.answer; + result.answerid = wrong.id; + result.newpageid = wrong.jumpto; + } + } + + /** + * Check a multichoice answer. + * + * @param {any} lesson Lesson. + * @param {any} pageData Result of getPageData for the page to process. + * @param {any} data Data containing the user answer. + * @param {any} pageIndex Object containing all the pages indexed by ID. + * @param {AddonModLessonCheckAnswerResult} result Object where to store the result. + */ + protected checkAnswerMultichoice(lesson: any, pageData: any, data: any, pageIndex: any, + result: AddonModLessonCheckAnswerResult): void { + + if (!data) { + result.inmediatejump = true; + result.newpageid = pageData.page.id; + + return; + } + + const answers = this.getUsedAnswersMultichoice(pageData); + + if (pageData.page.qoption) { + // Multianswer allowed, user's answer is an array. + const studentAnswers = this.getUserResponseMultichoice(data); + + if (!studentAnswers || !Array.isArray(studentAnswers)) { + result.noanswer = true; + + return; + } + + // Get what the user answered. + result.userresponse = studentAnswers.join(','); + + // Get the answers in a set order, the id order. + const responses = []; + let nHits = 0, + nCorrect = 0, + correctAnswerId = 0, + wrongAnswerId = 0, + correctPageId, + wrongPageId; + + // Store student's answers for displaying on feedback page. + result.studentanswer = ''; + result.studentanswerformat = 1; + answers.forEach((answer) => { + for (const i in studentAnswers) { + const answerId = studentAnswers[i]; + + if (answerId == answer.id) { + result.studentanswer += '
' + answer.answer; + if (this.textUtils.cleanTags(answer.response).trim()) { + responses.push(answer.response); + } + break; + } + } + }); + + // Iterate over all the possible answers. + answers.forEach((answer) => { + const correctAnswer = this.isAnswerCorrect(lesson, pageData.page.id, answer, pageIndex); + + // Iterate over all the student answers to check if he selected the current possible answer. + studentAnswers.forEach((answerId) => { + if (answerId == answer.id) { + if (correctAnswer) { + nHits++; + } else { + // Always use the first student wrong answer. + if (typeof wrongPageId == 'undefined') { + wrongPageId = answer.jumpto; + } + // Save the answer id for scoring. + if (!wrongAnswerId) { + wrongAnswerId = answer.id; + } + } + } + }); + + if (correctAnswer) { + nCorrect++; + + // Save the first jumpto. + if (typeof correctPageId == 'undefined') { + correctPageId = answer.jumpto; + } + // Save the answer id for scoring. + if (!correctAnswerId) { + correctAnswerId = answer.id; + } + } + }); + + if (studentAnswers.length == nCorrect && nHits == nCorrect) { + result.correctanswer = true; + result.response = responses.join('
'); + result.newpageid = correctPageId; + result.answerid = correctAnswerId; + } else { + result.correctanswer = false; + result.response = responses.join('
'); + result.newpageid = wrongPageId; + result.answerid = wrongAnswerId; + } + } else { + // Only one answer allowed. + if (typeof data.answerid == 'undefined' || (!data.answerid && Number(data.answerid) !== 0)) { + result.noanswer = true; + + return; + } + + result.answerid = data.answerid; + + // Search the answer. + for (const i in pageData.answers) { + const answer = pageData.answers[i]; + if (answer.id == data.answerid) { + result.correctanswer = this.isAnswerCorrect(lesson, pageData.page.id, answer, pageIndex); + result.newpageid = answer.jumpto; + result.response = answer.response; + result.userresponse = result.studentanswer = answer.answer; + break; + } + } + } + } + + /** + * Check a numerical answer. + * + * @param {any} lesson Lesson. + * @param {any} pageData Result of getPageData for the page to process. + * @param {any} data Data containing the user answer. + * @param {any} pageIndex Object containing all the pages indexed by ID. + * @param {any} result Object where to store the result. + */ + protected checkAnswerNumerical(lesson: any, pageData: any, data: any, pageIndex: any, result: AddonModLessonCheckAnswerResult) + : void { + + const parsedAnswer = parseFloat(data.answer); + + // Set defaults. + result.response = ''; + result.newpageid = 0; + + if (!data.answer || isNaN(parsedAnswer)) { + result.noanswer = true; + + return; + } else { + result.useranswer = parsedAnswer; + } + + result.studentanswer = result.userresponse = result.useranswer; + + // Find the answer. + for (const i in pageData.answers) { + const answer = pageData.answers[i]; + let max, min; + + if (answer.answer && answer.answer.indexOf(':') != -1) { + // There's a pair of values. + const split = answer.answer.split(':'); + min = parseFloat(split[0]); + max = parseFloat(split[1]); + } else { + // Only one value. + min = parseFloat(answer.answer); + max = min; + } + + if (result.useranswer >= min && result.useranswer <= max) { + result.newpageid = answer.jumpto; + result.response = answer.response; + result.correctanswer = this.isAnswerCorrect(lesson, pageData.page.id, answer, pageIndex); + result.answerid = answer.id; + break; + } + } + } + + /** + * Check a short answer. + * + * @param {any} lesson Lesson. + * @param {any} pageData Result of getPageData for the page to process. + * @param {any} data Data containing the user answer. + * @param {any} pageIndex Object containing all the pages indexed by ID. + * @param {any} result Object where to store the result. + */ + protected checkAnswerShort(lesson: any, pageData: any, data: any, pageIndex: any, result: AddonModLessonCheckAnswerResult) + : void { + + let studentAnswer = data.answer && data.answer.trim ? data.answer.trim() : false; + if (!studentAnswer) { + result.noanswer = true; + + return; + } + + // Search the answer in the list of possible answers. + for (const i in pageData.answers) { + const answer = pageData.answers[i], + useRegExp = pageData.page.qoption; + let expectedAnswer = answer.answer, + isMatch = false, + markIt = false, + ignoreCase; + + if (useRegExp) { + ignoreCase = ''; + if (expectedAnswer.substr(-2) == '/i') { + expectedAnswer = expectedAnswer.substr(0, expectedAnswer.length - 2); + ignoreCase = 'i'; + } + } else { + expectedAnswer = expectedAnswer.replace('*', '#####'); + expectedAnswer = this.textUtils.escapeForRegex(expectedAnswer); + expectedAnswer = expectedAnswer.replace('#####', '.*'); + } + + // See if user typed in any of the correct answers. + if (this.isAnswerCorrect(lesson, pageData.page.id, answer, pageIndex)) { + if (!useRegExp) { // We are using 'normal analysis', which ignores case. + if (studentAnswer.match(new RegExp('^' + expectedAnswer + '$', 'i'))) { + isMatch = true; + } + } else { + if (studentAnswer.match(new RegExp('^' + expectedAnswer + '$', ignoreCase))) { + isMatch = true; + } + } + if (isMatch) { + result.correctanswer = true; + } + } else { + if (!useRegExp) { // We are using 'normal analysis'. + // See if user typed in any of the wrong answers; don't worry about case. + if (studentAnswer.match(new RegExp('^' + expectedAnswer + '$', 'i'))) { + isMatch = true; + } + } else { // We are using regular expressions analysis. + const startCode = expectedAnswer.substr(0, 2); + + switch (startCode){ + // 1- Check for absence of required string in studentAnswer (coded by initial '--'). + case '--': + expectedAnswer = expectedAnswer.substr(2); + if (!studentAnswer.match(new RegExp('^' + expectedAnswer + '$', ignoreCase))) { + isMatch = true; + } + break; + + // 2- Check for code for marking wrong strings (coded by initial '++'). + case '++': + expectedAnswer = expectedAnswer.substr(2); + markIt = true; + + // Check for one or several matches. + const matches = studentAnswer.match(new RegExp(expectedAnswer, 'g' + ignoreCase)); + if (matches) { + isMatch = true; + const nb = matches[0].length, + original = [], + marked = []; + + for (let j = 0; j < nb; j++) { + original.push(matches[0][j]); + marked.push('' + matches[0][j] + ''); + } + + studentAnswer = studentAnswer.replace(original, marked); + } + break; + + // 3- Check for wrong answers belonging neither to -- nor to ++ categories. + default: + if (studentAnswer.match(new RegExp('^' + expectedAnswer + '$', ignoreCase))) { + isMatch = true; + } + break; + } + + result.correctanswer = false; + } + } + + if (isMatch) { + result.newpageid = answer.jumpto; + result.response = answer.response; + result.answerid = answer.id; + break; // Quit answer analysis immediately after a match has been found. + } + } + + result.userresponse = studentAnswer; + result.studentanswer = this.textUtils.s(studentAnswer); // Clean student answer as it goes to output. + } + + /** + * Check a truefalse answer. + * + * @param {any} lesson Lesson. + * @param {any} pageData Result of getPageData for the page to process. + * @param {any} data Data containing the user answer. + * @param {any} pageIndex Object containing all the pages indexed by ID. + * @param {any} result Object where to store the result. + */ + protected checkAnswerTruefalse(lesson: any, pageData: any, data: any, pageIndex: any, result: AddonModLessonCheckAnswerResult) + : void { + + if (!data.answerid) { + result.noanswer = true; + + return; + } + + result.answerid = data.answerid; + + // Get the answer. + for (const i in pageData.answers) { + const answer = pageData.answers[i]; + if (answer.id == data.answerid) { + // Answer found. + result.correctanswer = this.isAnswerCorrect(lesson, pageData.page.id, answer, pageIndex); + result.newpageid = answer.jumpto; + result.response = answer.response; + result.studentanswer = result.userresponse = answer.answer; + break; + } + } + } + + /** + * Create a list of pages indexed by page ID based on a list of pages. + * + * @param {Object[]} pageList Result of get pages. + * @return {any} Pages index. + */ + protected createPagesIndex(pageList: any[]): any { + // Index the pages by page ID. + const pages = {}; + + pageList.forEach((pageData) => { + pages[pageData.page.id] = pageData.page; + }); + + return pages; + } + + /** + * Finishes a retake. + * + * @param {any} lesson Lesson. + * @param {number} courseId Course ID the lesson belongs to. + * @param {string} [password] Lesson password (if any). + * @param {boolean} [outOfTime] If the user ran out of time. + * @param {boolean} [review] If the user wants to review just after finishing (1 hour margin). + * @param {boolean} [offline] Whether it's offline mode. + * @param {any} [accessInfo] Result of get access info. Required if offline is true. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved in success, rejected otherwise. + */ + finishRetake(lesson: any, courseId: number, password?: string, outOfTime?: boolean, review?: boolean, offline?: boolean, + accessInfo?: any, siteId?: string): Promise { + + if (offline) { + const retake = accessInfo.attemptscount; + + return this.lessonOfflineProvider.finishRetake(lesson.id, courseId, retake, true, outOfTime, siteId).then(() => { + // Get the lesson grade. + return this.lessonGrade(lesson, retake, password, review, undefined, siteId).catch(() => { + // Ignore errors. + return {}; + }); + }).then((gradeInfo: AddonModLessonGrade) => { + // Retake marked, now return the response. We won't return all the possible data. + // This code is based in Moodle's process_eol_page. + const result = { + data: {}, + messages: [], + warnings: [] + }, + promises = []; + let gradeLesson = true, + messageParams, + entryData; + + this.addResultValueEolPage(result, 'offline', true); // Mark the result as offline. + this.addResultValueEolPage(result, 'gradeinfo', gradeInfo); + + if (lesson.custom && !accessInfo.canmanage) { + /* Before we calculate the custom score make sure they answered the minimum number of questions. + We only need to do this for custom scoring as we can not get the miniumum score the user should achieve. + If we are not using custom scoring (so all questions are valued as 1) then we simply check if they + answered more than the minimum questions, if not, we mark it out of the number specified in the minimum + questions setting - which is done in lesson_grade(). */ + + // Get the number of answers given. + if (gradeInfo.nquestions < lesson.minquestions) { + gradeLesson = false; + messageParams = { + nquestions: gradeInfo.nquestions, + minquestions: lesson.minquestions + }; + this.addMessage(result.messages, 'addon.mod_lesson.numberofpagesviewednotice', {$a: messageParams}); + } + } + + if (!accessInfo.canmanage) { + if (gradeLesson) { + promises.push(this.calculateProgress(lesson.id, accessInfo, password, review, undefined, siteId) + .then((progress) => { + this.addResultValueEolPage(result, 'progresscompleted', progress); + })); + + if (gradeInfo.attempts) { + // User has answered questions. + if (!lesson.custom) { + this.addResultValueEolPage(result, 'numberofpagesviewed', gradeInfo.nquestions, true); + if (lesson.minquestions) { + if (gradeInfo.nquestions < lesson.minquestions) { + this.addResultValueEolPage(result, 'youshouldview', lesson.minquestions, true); + } + } + this.addResultValueEolPage(result, 'numberofcorrectanswers', gradeInfo.earned, true); + } + + entryData = { + score: gradeInfo.earned, + grade: gradeInfo.total + }; + if (gradeInfo.nmanual) { + entryData.tempmaxgrade = gradeInfo.total - gradeInfo.manualpoints; + entryData.essayquestions = gradeInfo.nmanual; + this.addResultValueEolPage(result, 'displayscorewithessays', entryData, true); + } else { + this.addResultValueEolPage(result, 'displayscorewithoutessays', entryData, true); + } + + if (lesson.grade != CoreGradesProvider.TYPE_NONE) { + entryData = { + grade: this.textUtils.roundToDecimals(gradeInfo.grade * lesson.grade / 100, 1), + total: lesson.grade + }; + this.addResultValueEolPage(result, 'yourcurrentgradeisoutof', entryData, true); + } + + } else { + // User hasn't answered any question, only content pages. + if (lesson.timelimit) { + if (outOfTime) { + this.addResultValueEolPage(result, 'eolstudentoutoftimenoanswers', true, true); + } + } else { + this.addResultValueEolPage(result, 'welldone', true, true); + } + } + } + } else { + // Display for teacher. + if (lesson.grade != CoreGradesProvider.TYPE_NONE) { + this.addResultValueEolPage(result, 'displayofgrade', true, true); + } + } + + if (lesson.modattempts && accessInfo.canmanage) { + this.addResultValueEolPage(result, 'modattemptsnoteacher', true, true); + } + + if (gradeLesson) { + this.addResultValueEolPage(result, 'gradelesson', 1); + } + + return result; + }); + } + + return this.finishRetakeOnline(lesson.id, password, outOfTime, review, siteId); + } + + /** + * Finishes a retake. It will fail if offline or cannot connect. + * + * @param {number} lessonId Lesson ID. + * @param {string} [password] Lesson password (if any). + * @param {boolean} [outOfTime] If the user ran out of time. + * @param {boolean} [review] If the user wants to review just after finishing (1 hour margin). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved in success, rejected otherwise. + */ + finishRetakeOnline(lessonId: number, password?: string, outOfTime?: boolean, review?: boolean, siteId?: string): Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + const params: any = { + lessonid: lessonId, + outoftime: outOfTime ? 1 : 0, + review: review ? 1 : 0 + }; + + if (typeof password == 'string') { + params.password = password; + } + + return site.write('mod_lesson_finish_attempt', params).then((response) => { + // Convert the data array into an object and decode the values. + const map = {}; + + response.data.forEach((entry) => { + if (entry.value && typeof entry.value == 'string' && entry.value !== '1') { + // It's a JSON encoded object. Try to decode it. + entry.value = this.textUtils.parseJSON(entry.value); + } + + map[entry.name] = entry; + }); + response.data = map; + + return response; + }); + }); + } + + /** + * Get the access information of a certain lesson. + * + * @param {number} lessonId Lesson ID. + * @param {boolean} [forceCache] Whether it should always return cached data. Has priority over ignoreCache. + * @param {boolean} [ignoreCache] Whether it should ignore cached data (it will always fail in offline or server down). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the access information. + */ + getAccessInformation(lessonId: number, forceCache?: boolean, ignoreCache?: boolean, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + lessonid: lessonId + }, + preSets: CoreSiteWSPreSets = { + cacheKey: this.getAccessInformationCacheKey(lessonId) + }; + + if (forceCache) { + preSets.omitExpires = true; + } else if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + return site.read('mod_lesson_get_lesson_access_information', params, preSets); + }); + } + + /** + * Get cache key for access information WS calls. + * + * @param {number} lessonId Lesson ID. + * @return {string} Cache key. + */ + protected getAccessInformationCacheKey(lessonId: number): string { + return this.ROOT_CACHE_KEY + 'accessInfo:' + lessonId; + } + + /** + * Get content pages viewed in online and offline. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake Retake number. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise<{online: any[], offline: any[]}>} Promise resolved with an object with the online and offline viewed pages. + */ + getContentPagesViewed(lessonId: number, retake: number, siteId?: string): Promise<{online: any[], offline: any[]}> { + const promises = [], + type = AddonModLessonProvider.TYPE_STRUCTURE, + result = { + online: [], + offline: [] + }; + + // Get the online pages. + promises.push(this.getContentPagesViewedOnline(lessonId, retake, false, false, siteId).then((pages) => { + result.online = pages; + })); + + // Get the offline pages. + promises.push(this.lessonOfflineProvider.getRetakeAttemptsForType(lessonId, retake, type, siteId).catch(() => { + return []; + }).then((pages) => { + result.offline = pages; + })); + + return Promise.all(promises).then(() => { + return result; + }); + } + + /** + * Get cache key for get content pages viewed WS calls. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake Retake number. + * @return {string} Cache key. + */ + protected getContentPagesViewedCacheKey(lessonId: number, retake: number): string { + return this.getContentPagesViewedCommonCacheKey(lessonId) + ':' + retake; + } + + /** + * Get common cache key for get content pages viewed WS calls. + * + * @param {number} lessonId Lesson ID. + * @return {string} Cache key. + */ + protected getContentPagesViewedCommonCacheKey(lessonId: number): string { + return this.ROOT_CACHE_KEY + 'contentPagesViewed:' + lessonId; + } + + /** + * Get IDS of content pages viewed in online and offline. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake Retake number. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with list of IDs. + */ + getContentPagesViewedIds(lessonId: number, retake: number, siteId?: string): Promise { + return this.getContentPagesViewed(lessonId, retake, siteId).then((result) => { + const ids = {}, + pages = result.online.concat(result.offline); + + pages.forEach((page) => { + if (!ids[page.pageid]) { + ids[page.pageid] = true; + } + }); + + return Object.keys(ids).map((id) => { + return Number(id); + }); + }); + } + + /** + * Get the list of content pages viewed in the site for a certain retake. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake Retake number. + * @param {boolean} [forceCache] Whether it should always return cached data. Has priority over ignoreCache. + * @param {boolean} [ignoreCache] Whether it should ignore cached data (it will always fail in offline or server down). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the viewed pages. + */ + getContentPagesViewedOnline(lessonId: number, retake: number, forceCache?: boolean, ignoreCache?: boolean, siteId?: string) + : Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + lessonid: lessonId, + lessonattempt: retake + }, + preSets: CoreSiteWSPreSets = { + cacheKey: this.getContentPagesViewedCacheKey(lessonId, retake) + }; + + if (forceCache) { + preSets.omitExpires = true; + } else if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + return site.read('mod_lesson_get_content_pages_viewed', params, preSets).then((result) => { + return result.pages; + }); + }); + } + + /** + * Get the last content page viewed. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake Retake number. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the last content page viewed. + */ + getLastContentPageViewed(lessonId: number, retake: number, siteId?: string): Promise { + return this.getContentPagesViewed(lessonId, retake, siteId).then((data) => { + let lastPage, + maxTime = 0; + + data.online.forEach((page) => { + if (page.timeseen > maxTime) { + lastPage = page; + maxTime = page.timeseen; + } + }); + + data.offline.forEach((page) => { + if (page.timemodified > maxTime) { + lastPage = page; + maxTime = page.timemodified; + } + }); + + return lastPage; + }).catch(() => { + // Error getting last page, don't return anything. + }); + } + + /** + * Get the last page seen. + * Based on Moodle's get_last_page_seen. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake Retake number. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the last page seen. + */ + getLastPageSeen(lessonId: number, retake: number, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + let lastPageSeen: number; + + // Get the last question answered. + return this.lessonOfflineProvider.getLastQuestionPageAttempt(lessonId, retake, siteId).then((answer) => { + if (answer) { + lastPageSeen = answer.newpageid; + } + + // Now get the last content page viewed. + return this.getLastContentPageViewed(lessonId, retake, siteId).then((page) => { + if (page) { + if (answer) { + if (page.timemodified > answer.timemodified) { + // This content page was viewed more recently than the question page. + lastPageSeen = page.newpageid || page.pageid; + } + } else { + // Has not answered any questions but has viewed a content page. + lastPageSeen = page.newpageid || page.pageid; + } + } + + return lastPageSeen; + }); + }); + } + + /** + * Get a Lesson by module ID. + * + * @param {number} courseId Course ID. + * @param {number} cmid Course module ID. + * @param {boolean} [forceCache] Whether it should always return cached data. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the lesson is retrieved. + */ + getLesson(courseId: number, cmId: number, forceCache?: boolean, siteId?: string): Promise { + return this.getLessonByField(courseId, 'coursemodule', cmId, forceCache, siteId); + } + + /** + * Get a Lesson with key=value. If more than one is found, only the first will be returned. + * + * @param {number} courseId Course ID. + * @param {string} key Name of the property to check. + * @param {any} value Value to search. + * @param {boolean} [forceCache] Whether it should always return cached data. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the lesson is retrieved. + */ + protected getLessonByField(courseId: number, key: string, value: any, forceCache?: boolean, siteId?: string): Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + courseids: [courseId] + }, + preSets: CoreSiteWSPreSets = { + cacheKey: this.getLessonDataCacheKey(courseId) + }; + + if (forceCache) { + preSets.omitExpires = true; + } + + return site.read('mod_lesson_get_lessons_by_courses', params, preSets).then((response) => { + if (response && response.lessons) { + const currentLesson = response.lessons.find((lesson) => { + return lesson[key] == value; + }); + + if (currentLesson) { + return currentLesson; + } + } + + return Promise.reject(null); + }); + }); + } + + /** + * Get a Lesson by lesson ID. + * + * @param {number} courseId Course ID. + * @param {number} id Lesson ID. + * @param {boolean} [forceCache] Whether it should always return cached data. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the lesson is retrieved. + */ + getLessonById(courseId: number, id: number, forceCache?: boolean, siteId?: string): Promise { + return this.getLessonByField(courseId, 'id', id, forceCache, siteId); + } + + /** + * Get cache key for Lesson data WS calls. + * + * @param {number} courseId Course ID. + * @return {string} Cache key. + */ + protected getLessonDataCacheKey(courseId: number): string { + return this.ROOT_CACHE_KEY + 'lesson:' + courseId; + } + + /** + * Get a lesson protected with password. + * + * @param {number} lessonId Lesson ID. + * @param {string} [password] Password. + * @param {boolean} [validatePassword=true] If true, the function will fail if the password is wrong. + * If false, it will return a lesson with the basic data if password is wrong. + * @param {boolean} [forceCache] Whether it should always return cached data. Has priority over ignoreCache. + * @param {boolean} [ignoreCache] Whether it should ignore cached data (it will always fail in offline or server down). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the lesson. + */ + getLessonWithPassword(lessonId: number, password?: string, validatePassword: boolean = true, forceCache?: boolean, + ignoreCache?: boolean, siteId?: string): Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + const params: any = { + lessonid: lessonId + }, + preSets: CoreSiteWSPreSets = { + cacheKey: this.getLessonWithPasswordCacheKey(lessonId) + }; + + if (typeof password == 'string') { + params.password = password; + } + + if (forceCache) { + preSets.omitExpires = true; + } else if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + return site.read('mod_lesson_get_lesson', params, preSets).then((response) => { + if (typeof response.lesson.ongoing == 'undefined') { + // Basic data not received, password is wrong. Remove stored password. + this.removeStoredPassword(lessonId, site.id); + + if (validatePassword) { + // Invalidate the data and reject. + return this.invalidateLessonWithPassword(lessonId, site.id).catch(() => { + // Shouldn't happen. + }).then(() => { + return Promise.reject(this.translate.instant('addon.mod_lesson.loginfail')); + }); + } + } + + return response.lesson; + }); + }); + } + + /** + * Get cache key for get lesson with password WS calls. + * + * @param {number} lessonId Lesson ID. + * @return {string} Cache key. + */ + protected getLessonWithPasswordCacheKey(lessonId: number): string { + return this.ROOT_CACHE_KEY + 'lessonWithPswrd:' + lessonId; + } + + /** + * Given a page ID, a jumpto and all the possible jumps, calcualate the new page ID. + * + * @param {number} pageId Current page ID. + * @param {number} jumpTo The jumpto. + * @param {any} jumps Result of get pages possible jumps. + * @return {number} New page ID. + */ + protected getNewPageId(pageId: number, jumpTo: number, jumps: any): number { + // If jump not found, return current jumpTo. + if (jumps && jumps[pageId] && jumps[pageId][jumpTo]) { + return jumps[pageId][jumpTo].calculatedjump; + } else if (!jumpTo) { + // Return current page. + return pageId; + } + + return jumpTo; + } + + /** + * Get the ongoing score message for the user (depending on the user permission and lesson settings). + * + * @param {any} lesson Lesson. + * @param {any} accessInfo Result of get access info. + * @param {string} [password] Lesson password (if any). + * @param {boolean} [review] If the user wants to review just after finishing (1 hour margin). + * @param {any} [pageIndex] Object containing all the pages indexed by ID. If not provided, it will be calculated. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the ongoing score message. + */ + getOngoingScoreMessage(lesson: any, accessInfo: any, password?: string, review?: boolean, pageIndex?: any, siteId?: string) + : Promise { + + if (accessInfo.canmanage) { + return Promise.resolve(this.translate.instant('addon.mod_lesson.teacherongoingwarning')); + } else { + let retake = accessInfo.attemptscount; + if (review) { + retake--; + } + + return this.lessonGrade(lesson, retake, password, review, pageIndex, siteId).then((gradeInfo) => { + const data: any = {}; + + if (lesson.custom) { + data.score = gradeInfo.earned; + data.currenthigh = gradeInfo.total; + + return this.translate.instant('addon.mod_lesson.ongoingcustom', {$a: data}); + } else { + data.correct = gradeInfo.earned; + data.viewed = gradeInfo.attempts; + + return this.translate.instant('addon.mod_lesson.ongoingnormal', {$a: data}); + } + }); + } + } + + /** + * Get the possible answers from a page. + * + * @param {any} lesson Lesson. + * @param {number} pageId Page ID. + * @param {string} [password] Lesson password (if any). + * @param {boolean} [review] If the user wants to review just after finishing (1 hour margin). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the list of possible answers. + */ + protected getPageAnswers(lesson: any, pageId: number, password?: string, review?: boolean, siteId?: string): Promise { + return this.getPageData(lesson, pageId, password, review, true, true, false, undefined, undefined, siteId).then((data) => { + return data.answers; + }); + } + + /** + * Get all the possible answers from a list of pages, indexed by answerId. + * + * @param {any} lesson Lesson. + * @param {number[]} pageIds List of page IDs. + * @param {string} [password] Lesson password (if any). + * @param {boolean} [review] If the user wants to review just after finishing (1 hour margin). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with an object containing the answers. + */ + protected getPagesAnswers(lesson: any, pageIds: number[], password?: string, review?: boolean, siteId?: string) + : Promise { + + const answers = {}, + promises = []; + + pageIds.forEach((pageId) => { + promises.push(this.getPageAnswers(lesson, pageId, password, review, siteId).then((pageAnswers) => { + pageAnswers.forEach((answer) => { + // Include the pageid in each answer and add them to the final list. + answer.pageid = pageId; + answers[answer.id] = answer; + }); + })); + }); + + return Promise.all(promises).then(() => { + return answers; + }); + } + + /** + * Get page data. + * + * @param {any} lesson Lesson. + * @param {number} pageId Page ID. + * @param {string} [password] Lesson password (if any). + * @param {boolean} [review] If the user wants to review just after finishing (1 hour margin). + * @param {boolean} [includeContents] Include the page rendered contents. + * @param {boolean} [forceCache] Whether it should always return cached data. Has priority over ignoreCache. + * @param {boolean} [ignoreCache] Whether it should ignore cached data (it will always fail in offline or server down). + * @param {any} [accessInfo] Result of get access info. Required if offline is true. + * @param {any} [jumps] Result of get pages possible jumps. Required if offline is true. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the page data. + */ + getPageData(lesson: any, pageId: number, password?: string, review?: boolean, includeContents?: boolean, forceCache?: boolean, + ignoreCache?: boolean, accessInfo?: any, jumps?: any, siteId?: string): Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + const params: any = { + lessonid: lesson.id, + pageid: Number(pageId), + review: review ? 1 : 0, + returncontents: includeContents ? 1 : 0 + }, + preSets: CoreSiteWSPreSets = { + cacheKey: this.getPageDataCacheKey(lesson.id, pageId) + }; + + if (typeof password == 'string') { + params.password = password; + } + + if (forceCache) { + preSets.omitExpires = true; + } else if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + if (review) { + // Force online mode in review. + preSets.getFromCache = false; + preSets.saveToCache = false; + preSets.emergencyCache = false; + } + + return site.read('mod_lesson_get_page_data', params, preSets).then((data) => { + if (forceCache && accessInfo && data.page) { + // Offline mode and valid page. Calculate the data that might be affected. + return this.calculateOfflineData(lesson, accessInfo, password, review, undefined, siteId).then((calcData) => { + Object.assign(data, calcData); + + return this.getPageViewMessages(lesson, accessInfo, data.page, review, jumps, password, siteId); + }).then((messages) => { + data.messages = messages; + + return data; + }); + } + + return data; + }); + }); + } + + /** + * Get cache key for get page data WS calls. + * + * @param {number} lessonId Lesson ID. + * @param {number} pageId Page ID. + * @return {string} Cache key. + */ + protected getPageDataCacheKey(lessonId: number, pageId: number): string { + return this.getPageDataCommonCacheKey(lessonId) + ':' + pageId; + } + + /** + * Get common cache key for get page data WS calls. + * + * @param {number} lessonId Lesson ID. + * @return {string} Cache key. + */ + protected getPageDataCommonCacheKey(lessonId: number): string { + return this.ROOT_CACHE_KEY + 'pageData:' + lessonId; + } + + /** + * Get lesson pages. + * + * @param {number} lessonId Lesson ID. + * @param {string} [password] Lesson password (if any). + * @param {boolean} [forceCache] Whether it should always return cached data. Has priority over ignoreCache. + * @param {boolean} [ignoreCache] Whether it should ignore cached data (it will always fail in offline or server down). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the pages. + */ + getPages(lessonId: number, password?: string, forceCache?: boolean, ignoreCache?: boolean, siteId?: string): Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + const params: any = { + lessonid: lessonId, + }, + preSets: CoreSiteWSPreSets = { + cacheKey: this.getPagesCacheKey(lessonId) + }; + + if (typeof password == 'string') { + params.password = password; + } + + if (forceCache) { + preSets.omitExpires = true; + } else if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + return site.read('mod_lesson_get_pages', params, preSets).then((response) => { + return response.pages; + }); + }); + } + + /** + * Get cache key for get pages WS calls. + * + * @param {number} lessonId Lesson ID. + * @return {string} Cache key. + */ + protected getPagesCacheKey(lessonId: number): string { + return this.ROOT_CACHE_KEY + 'pages:' + lessonId; + } + + /** + * Get possible jumps for a lesson. + * + * @param {number} lessonId Lesson ID. + * @param {boolean} [forceCache] Whether it should always return cached data. Has priority over ignoreCache. + * @param {boolean} [ignoreCache] Whether it should ignore cached data (it will always fail in offline or server down). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the jumps. + */ + getPagesPossibleJumps(lessonId: number, forceCache?: boolean, ignoreCache?: boolean, siteId?: string): Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + lessonid: lessonId, + }, + preSets: CoreSiteWSPreSets = { + cacheKey: this.getPagesPossibleJumpsCacheKey(lessonId) + }; + + if (forceCache) { + preSets.omitExpires = true; + } else if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + return site.read('mod_lesson_get_pages_possible_jumps', params, preSets).then((response) => { + // Index the jumps by page and jumpto. + if (response.jumps) { + const jumps = {}; + + response.jumps.forEach((jump) => { + if (typeof jumps[jump.pageid] == 'undefined') { + jumps[jump.pageid] = {}; + } + jumps[jump.pageid][jump.jumpto] = jump; + }); + + return jumps; + } + + return Promise.reject(null); + }); + }); + } + + /** + * Get cache key for get pages possible jumps WS calls. + * + * @param {number} lessonId Lesson ID. + * @return {string} Cache key. + */ + protected getPagesPossibleJumpsCacheKey(lessonId: number): string { + return this.ROOT_CACHE_KEY + 'pagesJumps:' + lessonId; + } + + /** + * Get different informative messages when processing a lesson page. + * Please try to use WS response messages instead of this function if possible. + * Based on Moodle's add_messages_on_page_process. + * + * @param {any} lesson Lesson. + * @param {any} accessInfo Result of get access info. + * @param {any} result Result of process page. + * @param {boolean} review If the user wants to review just after finishing (1 hour margin). + * @param {any} jumps Result of get pages possible jumps. + * @return {any[]} Array with the messages. + */ + getPageProcessMessages(lesson: any, accessInfo: any, result: any, review: boolean, jumps: any): any[] { + const messages = []; + + if (accessInfo.canmanage) { + // Warning for teachers to inform them that cluster and unseen does not work while logged in as a teacher. + if (this.lessonDisplayTeacherWarning(jumps)) { + this.addMessage(messages, 'addon.mod_lesson.teacherjumpwarning', {$a: { + cluster: this.translate.instant('addon.mod_lesson.clusterjump'), + unseen: this.translate.instant('addon.mod_lesson.unseenpageinbranch') + }}); + } + + // Inform teacher that s/he will not see the timer. + if (lesson.timelimit) { + this.addMessage(messages, 'addon.mod_lesson.teachertimerwarning'); + } + } + // Report attempts remaining. + if (result.attemptsremaining > 0 && lesson.review && !review) { + this.addMessage(messages, 'addon.mod_lesson.attemptsremaining', {$a: result.attemptsremaining}); + } + + return messages; + } + + /** + * Get the IDs of all the pages that have at least 1 question attempt. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake Retake number. + * @param {boolean} [correct] True to only fetch correct attempts, false to get them all. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User ID. If not defined, site's user. + * @return {Promise} Promise resolved with the IDs. + */ + getPagesIdsWithQuestionAttempts(lessonId: number, retake: number, correct?: boolean, siteId?: string, userId?: number) + : Promise { + + return this.getQuestionsAttempts(lessonId, retake, correct, undefined, siteId, userId).then((result) => { + const ids = {}, + attempts = result.online.concat(result.offline); + + attempts.forEach((attempt) => { + if (!ids[attempt.pageid]) { + ids[attempt.pageid] = true; + } + }); + + return Object.keys(ids).map((id) => { + return Number(id); + }); + }); + } + + /** + * Get different informative messages when viewing a lesson page. + * Please try to use WS response messages instead of this function if possible. + * Based on Moodle's add_messages_on_page_view. + * + * @param {any} lesson Lesson. + * @param {any} accessInfo Result of get access info. Required if offline is true. + * @param {any} page Page loaded. + * @param {boolean} review If the user wants to review just after finishing (1 hour margin). + * @param {any} jumps Result of get pages possible jumps. + * @param {string} [password] Lesson password (if any). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the list of messages. + */ + getPageViewMessages(lesson: any, accessInfo: any, page: any, review: boolean, jumps: any, password?: string, siteId?: string) + : Promise { + + const messages = []; + let promise = Promise.resolve(); + + if (!accessInfo.canmanage) { + if (page.qtype == AddonModLessonProvider.LESSON_PAGE_BRANCHTABLE && lesson.minquestions) { + // Tell student how many questions they have seen, how many are required and their grade. + const retake = accessInfo.attemptscount; + + promise = this.lessonGrade(lesson, retake, password, review, undefined, siteId).then((gradeInfo) => { + if (gradeInfo.attempts) { + if (gradeInfo.nquestions < lesson.minquestions) { + this.addMessage(messages, 'addon.mod_lesson.numberofpagesviewednotice', {$a: { + nquestions: gradeInfo.nquestions, + minquestions: lesson.minquestions + }}); + } + + if (!review && !lesson.retake) { + this.addMessage(messages, 'addon.mod_lesson.numberofcorrectanswers', {$a: gradeInfo.earned}); + + if (lesson.grade != CoreGradesProvider.TYPE_NONE) { + this.addMessage(messages, 'addon.mod_lesson.yourcurrentgradeisoutof', {$a: { + grade: this.textUtils.roundToDecimals(gradeInfo.grade * lesson.grade / 100, 1), + total: lesson.grade + }}); + } + } + } + }).catch(() => { + // Ignore errors. + }); + } + } else { + if (lesson.timelimit) { + this.addMessage(messages, 'addon.mod_lesson.teachertimerwarning'); + } + + if (this.lessonDisplayTeacherWarning(jumps)) { + // Warning for teachers to inform them that cluster and unseen does not work while logged in as a teacher. + this.addMessage(messages, 'addon.mod_lesson.teacherjumpwarning', {$a: { + cluster: this.translate.instant('addon.mod_lesson.clusterjump'), + unseen: this.translate.instant('addon.mod_lesson.unseenpageinbranch') + }}); + } + } + + return promise.then(() => { + return messages; + }); + } + + /** + * Get questions attempts, including offline attempts. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake Retake number. + * @param {boolean} [correct] True to only fetch correct attempts, false to get them all. + * @param {number} [pageId] If defined, only get attempts on this page. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User ID. If not defined, site's user. + * @return {Promise<{online: any[], offline: any[]}>} Promise resolved with the questions attempts. + */ + getQuestionsAttempts(lessonId: number, retake: number, correct?: boolean, pageId?: number, siteId?: string, userId?: number) + : Promise<{online: any[], offline: any[]}> { + + const promises = [], + result = { + online: [], + offline: [] + }; + + promises.push(this.getQuestionsAttemptsOnline(lessonId, retake, correct, pageId, false, false, siteId, userId) + .then((attempts) => { + result.online = attempts; + })); + + promises.push(this.lessonOfflineProvider.getQuestionsAttempts(lessonId, retake, correct, pageId, siteId).catch(() => { + // Error, assume no attempts. + return []; + }).then((attempts) => { + result.offline = attempts; + })); + + return Promise.all(promises).then(() => { + return result; + }); + } + + /** + * Get cache key for get questions attempts WS calls. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake Retake number. + * @param {number} userId User ID. + * @return {string} Cache key. + */ + protected getQuestionsAttemptsCacheKey(lessonId: number, retake: number, userId: number): string { + return this.getQuestionsAttemptsCommonCacheKey(lessonId) + ':' + userId + ':' + retake; + } + + /** + * Get common cache key for get questions attempts WS calls. + * + * @param {number} lessonId Lesson ID. + * @return {string} Cache key. + */ + protected getQuestionsAttemptsCommonCacheKey(lessonId: number): string { + return this.ROOT_CACHE_KEY + 'questionsAttempts:' + lessonId; + } + + /** + * Get questions attempts from the site. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake Retake number. + * @param {boolean} [correct] True to only fetch correct attempts, false to get them all. + * @param {number} [pageId] If defined, only get attempts on this page. + * @param {boolean} [forceCache] Whether it should always return cached data. Has priority over ignoreCache. + * @param {boolean} [ignoreCache] Whether it should ignore cached data (it will always fail in offline or server down). + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User ID. If not defined, site's user. + * @return {Promise} Promise resolved with the questions attempts. + */ + getQuestionsAttemptsOnline(lessonId: number, retake: number, correct?: boolean, pageId?: number, forceCache?: boolean, + ignoreCache?: boolean, siteId?: string, userId?: number): Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + // Don't pass "pageId" and "correct" params, they will be filtered locally. + const params = { + lessonid: lessonId, + attempt: retake, + userid: userId + }, + preSets: CoreSiteWSPreSets = { + cacheKey: this.getQuestionsAttemptsCacheKey(lessonId, retake, userId) + }; + + if (forceCache) { + preSets.omitExpires = true; + } else if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + return site.read('mod_lesson_get_questions_attempts', params, preSets).then((response) => { + if (pageId || correct) { + // Filter the attempts. + return response.attempts.filter((attempt) => { + if (correct && !attempt.correct) { + return false; + } + + if (pageId && attempt.pageid != pageId) { + return false; + } + + return true; + }); + } + + return response.attempts; + }); + }); + } + + /** + * Get the overview of retakes in a lesson (named "attempts overview" in Moodle). + * + * @param {number} lessonId Lesson ID. + * @param {number} [groupId] The group to get. If not defined, all participants. + * @param {boolean} [forceCache] Whether it should always return cached data. Has priority over ignoreCache. + * @param {boolean} [ignoreCache] Whether it should ignore cached data (it will always fail in offline or server down). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the retakes overview. + */ + getRetakesOverview(lessonId: number, groupId?: number, forceCache?: boolean, ignoreCache?: boolean, siteId?: string) + : Promise { + + groupId = groupId || 0; + + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + lessonid: lessonId, + groupid: groupId + }, + preSets: CoreSiteWSPreSets = { + cacheKey: this.getRetakesOverviewCacheKey(lessonId, groupId) + }; + + if (forceCache) { + preSets.omitExpires = true; + } else if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + return site.read('mod_lesson_get_attempts_overview', params, preSets).then((response) => { + return response.data; + }); + }); + } + + /** + * Get cache key for get retakes overview WS calls. + * + * @param {number} lessonId Lesson ID. + * @param {number} groupId Group ID. + * @return {string} Cache key. + */ + protected getRetakesOverviewCacheKey(lessonId: number, groupId: number): string { + return this.getRetakesOverviewCommonCacheKey(lessonId) + ':' + groupId; + } + + /** + * Get common cache key for get retakes overview WS calls. + * + * @param {number} lessonId Lesson ID. + * @return {string} Cache key. + */ + protected getRetakesOverviewCommonCacheKey(lessonId: number): string { + return this.ROOT_CACHE_KEY + 'retakesOverview:' + lessonId; + } + + /** + * Get a password stored in DB. + * + * @param {number} lessonId Lesson ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with password on success, rejected otherwise. + */ + getStoredPassword(lessonId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().getRecord(this.PASSWORD_TABLE, {lessonId}).then((entry) => { + return entry.password; + }); + }); + } + + /** + * Finds all pages that appear to be a subtype of the provided pageId until an end point specified within "ends" is + * encountered or no more pages exist. + * Based on Moodle's get_sub_pages_of. + * + * @param {any} pages Index of lesson pages, indexed by page ID. See createPagesIndex. + * @param {number} pageId Page ID to get subpages of. + * @param {number[]} end An array of LESSON_PAGE_* types that signify an end of the subtype. + * @return {Object[]} List of subpages. + */ + getSubpagesOf(pages: any, pageId: number, ends: number[]): any[] { + const subPages = []; + + pageId = pages[pageId].nextpageid; // Move to the first page after the given page. + ends = ends || []; + + while (true) { + if (!pageId || ends.indexOf(pages[pageId].qtype) != -1) { + // No more pages or it reached a page of the searched types. Stop. + break; + } + + subPages.push(pages[pageId]); + pageId = pages[pageId].nextpageid; + } + + return subPages; + } + + /** + * Get lesson timers. + * + * @param {number} lessonId Lesson ID. + * @param {boolean} [forceCache] Whether it should always return cached data. Has priority over ignoreCache. + * @param {boolean} [ignoreCache] Whether it should ignore cached data (it will always fail in offline or server down). + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User ID. If not defined, site's current user. + * @return {Promise} Promise resolved with the pages. + */ + getTimers(lessonId: number, forceCache?: boolean, ignoreCache?: boolean, siteId?: string, userId?: number): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + const params = { + lessonid: lessonId, + userid: userId + }, + preSets: CoreSiteWSPreSets = { + cacheKey: this.getTimersCacheKey(lessonId, userId) + }; + + if (forceCache) { + preSets.omitExpires = true; + } else if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + return site.read('mod_lesson_get_user_timers', params, preSets).then((response) => { + return response.timers; + }); + }); + } + + /** + * Get cache key for get timers WS calls. + * + * @param {number} lessonId Lesson ID. + * @param {number} userId User ID. + * @return {string} Cache key. + */ + protected getTimersCacheKey(lessonId: number, userId: number): string { + return this.getTimersCommonCacheKey(lessonId) + ':' + userId; + } + + /** + * Get common cache key for get timers WS calls. + * + * @param {number} lessonId Lesson ID. + * @return {string} Cache key. + */ + protected getTimersCommonCacheKey(lessonId: number): string { + return this.ROOT_CACHE_KEY + 'timers:' + lessonId; + } + + /** + * Get the list of used answers (with valid answer) in a multichoice question page. + * + * @param {any} pageData Result of getPageData for the page to process. + * @return {any[]} List of used answers. + */ + protected getUsedAnswersMultichoice(pageData: any): any[] { + const answers = this.utils.clone(pageData.answers); + + return answers.filter((entry) => { + return entry.answer !== ''; + }); + } + + /** + * Get the user's response in a matching question page. + * + * @param {any} data Data containing the user answer. + * @return {any} User response. + */ + protected getUserResponseMatching(data: any): any { + if (data.response) { + // The data is already stored as expected. Return it. + return data.response; + } + + // Data is stored in properties like 'response[379]'. Recreate the response object. + const response = {}; + + for (const key in data) { + const match = key.match(/^response\[(\d+)\]/); + + if (match && match.length > 1) { + response[match[1]] = data[key]; + } + } + + return response; + } + + /** + * Get the user's response in a multichoice page if multiple answers are allowed. + * + * @param {any} data Data containing the user answer. + * @return {any[]} User response. + */ + protected getUserResponseMultichoice(data: any): any[] { + if (data.answer) { + // The data is already stored as expected. If it's valid, parse the values to int. + if (Array.isArray(data.answer)) { + return data.answer.map((value) => { + return parseInt(value, 10); + }); + } + + return data.answer; + } + + // Data is stored in properties like 'answer[379]'. Recreate the answer array. + const answer = []; + for (const key in data) { + const match = key.match(/^answer\[(\d+)\]/); + if (match && match.length > 1) { + answer.push(parseInt(match[1], 10)); + } + } + + return answer; + } + + /** + * Get a user's retake. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake Retake number + * @param {number} [userId] User ID. Undefined for current user. + * @param {boolean} [forceCache] Whether it should always return cached data. Has priority over ignoreCache. + * @param {boolean} [ignoreCache] Whether it should ignore cached data (it will always fail in offline or server down). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the retake data. + */ + getUserRetake(lessonId: number, retake: number, userId?: number, forceCache?: boolean, ignoreCache?: boolean, siteId?: string) + : Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + const params = { + lessonid: lessonId, + userid: userId, + lessonattempt: retake + }, + preSets: CoreSiteWSPreSets = { + cacheKey: this.getUserRetakeCacheKey(lessonId, userId, retake) + }; + + if (forceCache) { + preSets.omitExpires = true; + } else if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + return site.read('mod_lesson_get_user_attempt', params, preSets); + }); + } + + /** + * Get cache key for get user retake WS calls. + * + * @param {number} lessonId Lesson ID. + * @param {number} userId User ID. + * @param {number} retake Retake number + * @return {string} Cache key. + */ + protected getUserRetakeCacheKey(lessonId: number, userId: number, retake: number): string { + return this.getUserRetakeUserCacheKey(lessonId, userId) + ':' + retake; + } + + /** + * Get user cache key for get user retake WS calls. + * + * @param {number} lessonId Lesson ID. + * @param {number} userId User ID. + * @return {string} Cache key. + */ + protected getUserRetakeUserCacheKey(lessonId: number, userId: number): string { + return this.getUserRetakeLessonCacheKey(lessonId) + ':' + userId; + } + + /** + * Get lesson cache key for get user retake WS calls. + * + * @param {number} lessonId Lesson ID. + * @return {string} Cache key. + */ + protected getUserRetakeLessonCacheKey(lessonId: number): string { + return this.ROOT_CACHE_KEY + 'userRetake:' + lessonId; + } + + /** + * Check if a jump is correct. + * Based in Moodle's jumpto_is_correct. + * + * @param {number} pageId ID of the page from which you are jumping from. + * @param {number} jumpTo The jumpto number. + * @param {any} pageIndex Object containing all the pages indexed by ID. See createPagesIndex. + * @return {boolean} Whether jump is correct. + */ + jumptoIsCorrect(pageId: number, jumpTo: number, pageIndex: any): boolean { + // First test the special values. + if (!jumpTo) { + // Same page + return false; + } else if (jumpTo == AddonModLessonProvider.LESSON_NEXTPAGE) { + return true; + } else if (jumpTo == AddonModLessonProvider.LESSON_UNSEENBRANCHPAGE) { + return true; + } else if (jumpTo == AddonModLessonProvider.LESSON_RANDOMPAGE) { + return true; + } else if (jumpTo == AddonModLessonProvider.LESSON_CLUSTERJUMP) { + return true; + } else if (jumpTo == AddonModLessonProvider.LESSON_EOL) { + return true; + } + + let aPageId = pageIndex[pageId].nextpageid; + while (aPageId) { + if (jumpTo == aPageId) { + return true; + } + + aPageId = pageIndex[aPageId].nextpageid; + } + + return false; + } + + /** + * Invalidates Lesson data. + * + * @param {number} lessonId Lesson ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateAccessInformation(lessonId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getAccessInformationCacheKey(lessonId)); + }); + } + + /** + * Invalidates content pages viewed for all retakes. + * + * @param {number} lessonId Lesson ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateContentPagesViewed(lessonId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKeyStartingWith(this.getContentPagesViewedCommonCacheKey(lessonId)); + }); + } + + /** + * Invalidates content pages viewed for a certain retake. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake Retake number. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateContentPagesViewedForRetake(lessonId: number, retake: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getContentPagesViewedCacheKey(lessonId, retake)); + }); + } + + /** + * Invalidates Lesson data. + * + * @param {number} courseId Course ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateLessonData(courseId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getLessonDataCacheKey(courseId)); + }); + } + + /** + * Invalidates lesson with password. + * + * @param {number} lessonId Lesson ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateLessonWithPassword(lessonId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getLessonWithPasswordCacheKey(lessonId)); + }); + } + + /** + * Invalidates page data for all pages. + * + * @param {number} lessonId Lesson ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidatePageData(lessonId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKeyStartingWith(this.getPageDataCommonCacheKey(lessonId)); + }); + } + + /** + * Invalidates page data for a certain page. + * + * @param {number} lessonId Lesson ID. + * @param {number} pageId Page ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidatePageDataForPage(lessonId: number, pageId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getPageDataCacheKey(lessonId, pageId)); + }); + } + + /** + * Invalidates pages. + * + * @param {number} lessonId Lesson ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidatePages(lessonId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getPagesCacheKey(lessonId)); + }); + } + + /** + * Invalidates pages possible jumps. + * + * @param {number} lessonId Lesson ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidatePagesPossibleJumps(lessonId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getPagesPossibleJumpsCacheKey(lessonId)); + }); + } + + /** + * Invalidates questions attempts for all retakes. + * + * @param {number} lessonId Lesson ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateQuestionsAttempts(lessonId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKeyStartingWith(this.getQuestionsAttemptsCommonCacheKey(lessonId)); + }); + } + + /** + * Invalidates question attempts for a certain retake and user. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake Retake number. + * @param {string} [siteId] Site ID. If not defined, current site.. + * @param {number} [userId] User ID. If not defined, site's user. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateQuestionsAttemptsForRetake(lessonId: number, retake: number, siteId?: string, userId?: number): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + return site.invalidateWsCacheForKey(this.getQuestionsAttemptsCacheKey(lessonId, retake, userId)); + }); + } + + /** + * Invalidates retakes overview for all groups in a lesson. + * + * @param {number} lessonId Lesson ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateRetakesOverview(lessonId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKeyStartingWith(this.getRetakesOverviewCommonCacheKey(lessonId)); + }); + } + + /** + * Invalidates retakes overview for a certain group in a lesson. + * + * @param {number} lessonId Lesson ID. + * @param {number} groupId Group ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateRetakesOverviewForGroup(lessonId: number, groupId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getRetakesOverviewCacheKey(lessonId, groupId)); + }); + } + + /** + * Invalidates timers for all users in a lesson. + * + * @param {number} lessonId Lesson ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateTimers(lessonId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKeyStartingWith(this.getTimersCommonCacheKey(lessonId)); + }); + } + + /** + * Invalidates timers for a certain user. + * + * @param {number} lessonId Lesson ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User ID. If not defined, site's current user. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateTimersForUser(lessonId: number, siteId?: string, userId?: number): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + return site.invalidateWsCacheForKey(this.getTimersCacheKey(lessonId, userId)); + }); + } + + /** + * Invalidates a certain retake for a certain user. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake Retake number. + * @param {number} [userId] User ID. Undefined for current user. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateUserRetake(lessonId: number, retake: number, userId?: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + return site.invalidateWsCacheForKey(this.getUserRetakeCacheKey(lessonId, userId, retake)); + }); + } + + /** + * Invalidates all retakes for all users in a lesson. + * + * @param {number} lessonId Lesson ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateUserRetakesForLesson(lessonId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKeyStartingWith(this.getUserRetakeLessonCacheKey(lessonId)); + }); + } + + /** + * Invalidates all retakes for a certain user in a lesson. + * + * @param {number} lessonId Lesson ID. + * @param {number} [userId] User ID. Undefined for current user. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateUserRetakesForUser(lessonId: number, userId?: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + return site.invalidateWsCacheForKeyStartingWith(this.getUserRetakeUserCacheKey(lessonId, userId)); + }); + } + + /** + * Check if a page answer is correct. + * + * @param {any} lesson Lesson. + * @param {number} pageId The page ID. + * @param {any} answer The answer to check. + * @param {any} pageIndex Object containing all the pages indexed by ID. + * @return {boolean} Whether the answer is correct. + */ + protected isAnswerCorrect(lesson: any, pageId: number, answer: any, pageIndex: any): boolean { + if (lesson.custom) { + // Custom scores. If score on answer is positive, it is correct. + return answer.score > 0; + } else { + return this.jumptoIsCorrect(pageId, answer.jumpto, pageIndex); + } + } + + /** + * Check if a lesson is enabled to be used in offline. + * + * @param {any} lesson Lesson. + * @return {boolean} Whether offline is enabled. + */ + isLessonOffline(lesson: any): boolean { + return !!lesson.allowofflineattempts; + } + + /** + * Check if a lesson is password protected based in the access info. + * + * @param {any} info Lesson access info. + * @return {boolean} Whether the lesson is password protected. + */ + isPasswordProtected(info: any): boolean { + if (info && info.preventaccessreasons) { + for (let i = 0; i < info.preventaccessreasons.length; i++) { + const entry = info.preventaccessreasons[i]; + + if (entry.reason == 'passwordprotectedlesson') { + return true; + } + } + } + + return false; + } + + /** + * Return whether or not the plugin is enabled in a certain site. Plugin is enabled if the lesson WS are available. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with true if plugin is enabled, rejected or resolved with false otherwise. + */ + isPluginEnabled(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + // All WS were introduced at the same time so checking one is enough. + return site.wsAvailable('mod_lesson_get_lesson_access_information'); + }); + } + + /** + * Check if a page is a question page or a content page. + * + * @param {number} type Type of the page. + * @return {boolean} True if question page, false if content page. + */ + isQuestionPage(type: number): boolean { + return type == AddonModLessonProvider.TYPE_QUESTION; + } + + /** + * Start or continue a retake. + * + * @param {string} id Lesson ID. + * @param {string} [password] Lesson password (if any). + * @param {number} [pageId] Page id to continue from (only when continuing a retake). + * @param {boolean} [review] If the user wants to review just after finishing (1 hour margin). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the WS call is successful. + */ + launchRetake(id: number, password?: string, pageId?: number, review?: boolean, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params: any = { + lessonid: id, + review: review ? 1 : 0 + }; + + if (typeof password == 'string') { + params.password = password; + } + if (typeof pageId == 'number') { + params.pageid = pageId; + } + + return site.write('mod_lesson_launch_attempt', params); + }); + } + + /** + * Check if the user left during a timed session. + * + * @param {any} info Lesson access info. + * @return {boolean} True if left during timed, false otherwise. + */ + leftDuringTimed(info: any): boolean { + return info && info.lastpageseen && info.lastpageseen != AddonModLessonProvider.LESSON_EOL && info.leftduringtimedsession; + } + + /** + * Checks to see if a LESSON_CLUSTERJUMP or a LESSON_UNSEENBRANCHPAGE is used in a lesson. + * Based on Moodle's lesson_display_teacher_warning. + * + * @param {any} jumps Result of get pages possible jumps. + * @return {boolean} Whether the lesson uses one of those jumps. + */ + lessonDisplayTeacherWarning(jumps: any): boolean { + if (!jumps) { + return false; + } + + // Check if any jump is to cluster or unseen content page. + for (const pageId in jumps) { + for (const jumpto in jumps[pageId]) { + const jumptoNum = Number(jumpto); + + if (jumptoNum == AddonModLessonProvider.LESSON_CLUSTERJUMP || + jumptoNum == AddonModLessonProvider.LESSON_UNSEENBRANCHPAGE) { + return true; + } + } + } + + return false; + } + + /** + * Calculates a user's grade for a lesson. + * Based on Moodle's lesson_grade. + * + * @param {any} lesson Lesson. + * @param {number} retake Retake number. + * @param {string} [password] Lesson password (if any). + * @param {boolean} [review] If the user wants to review just after finishing (1 hour margin). + * @param {any} [pageIndex] Object containing all the pages indexed by ID. If not provided, it will be calculated. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User ID. If not defined, site's user. + * @return {Promise} Promise resolved with the grade data. + */ + lessonGrade(lesson: any, retake: number, password?: string, review?: boolean, pageIndex?: any, siteId?: string, + userId?: number): Promise { + + // Initialize all variables. + let nViewed = 0, + nManual = 0, + manualPoints = 0, + theGrade = 0, + nQuestions = 0, + total = 0, + earned = 0; + + // Get the questions attempts for the user. + return this.getQuestionsAttempts(lesson.id, retake, false, undefined, siteId, userId).then((attemptsData) => { + const attempts = attemptsData.online.concat(attemptsData.offline); + + if (!attempts.length) { + // No attempts. + return; + } + + const attemptSet = {}; + let promise; + + // Create the pageIndex if it isn't provided. + if (!pageIndex) { + promise = this.getPages(lesson.id, password, true, false, siteId).then((pages) => { + pageIndex = this.createPagesIndex(pages); + }); + } else { + promise = Promise.resolve(); + } + + return promise.then(() => { + const pageIds = []; + + // Group each try with its page. + attempts.forEach((attempt) => { + if (!attemptSet[attempt.pageid]) { + attemptSet[attempt.pageid] = []; + pageIds.push(attempt.pageid); + } + attemptSet[attempt.pageid].push(attempt); + }); + + // Drop all attempts that go beyond max attempts for the lesson. + for (const pageId in attemptSet) { + // Sort the list by time in ascending order. + const attempts = attemptSet[pageId].sort((a, b) => { + return (a.timeseen || a.timemodified) - (b.timeseen || b.timemodified); + }); + + attemptSet[pageId] = attempts.slice(0, lesson.maxattempts); + } + + // Get all the answers from the pages the user answered. + return this.getPagesAnswers(lesson, pageIds, password, review, siteId); + }).then((answers) => { + // Number of pages answered. + nQuestions = Object.keys(attemptSet).length; + + for (const pageId in attemptSet) { + const attempts = attemptSet[pageId], + lastAttempt = attempts[attempts.length - 1]; + + if (lesson.custom) { + // If essay question, handle it, otherwise add to score. + if (pageIndex[lastAttempt.pageid].qtype == AddonModLessonProvider.LESSON_PAGE_ESSAY) { + if (lastAttempt.useranswer && typeof lastAttempt.useranswer.score != 'undefined') { + earned += lastAttempt.useranswer.score; + } + nManual++; + manualPoints += answers[lastAttempt.answerid].score; + } else if (lastAttempt.answerid) { + earned += answers[lastAttempt.answerid].score; + } + } else { + attempts.forEach((attempt) => { + earned += attempt.correct ? 1 : 0; + }); + + // If essay question, increase numbers. + if (pageIndex[lastAttempt.pageid].qtype == AddonModLessonProvider.LESSON_PAGE_ESSAY) { + nManual++; + manualPoints++; + } + } + + // Number of times answered. + nViewed += attempts.length; + } + + if (lesson.custom) { + const bestScores = {}; + + // Find the highest possible score per page to get our total. + for (const answerId in answers) { + const answer = answers[answerId]; + + if (typeof bestScores[answer.pageid] == 'undefined') { + bestScores[answer.pageid] = answer.score; + } else if (bestScores[answer.pageid] < answer.score) { + bestScores[answer.pageid] = answer.score; + } + } + + // Sum all the scores. + for (const pageId in bestScores) { + total += bestScores[pageId]; + } + } else { + // Check to make sure the student has answered the minimum questions. + if (lesson.minquestions && nQuestions < lesson.minquestions) { + // Nope, increase number viewed by the amount of unanswered questions. + total = nViewed + (lesson.minquestions - nQuestions); + } else { + total = nViewed; + } + } + }); + }).then(() => { + if (total) { // Not zero. + theGrade = this.textUtils.roundToDecimals(earned * 100 / total, 5); + } + + return { + nquestions: nQuestions, + attempts: nViewed, + total: total, + earned: earned, + grade: theGrade, + nmanual: nManual, + manualpoints: manualPoints + }; + }); + } + + /** + * Report a lesson as being viewed. + * + * @param {string} id Module ID. + * @param {string} [password] Lesson password (if any). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the WS call is successful. + */ + logViewLesson(id: number, password?: string, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params: any = { + lessonid: id + }; + + if (typeof password == 'string') { + params.password = password; + } + + return site.write('mod_lesson_view_lesson', params).then((result) => { + if (!result.status) { + return Promise.reject(null); + } + + return result; + }); + }); + } + + /** + * Process a lesson page, saving its data. + * + * @param {any} lesson Lesson. + * @param {number} courseId Course ID the lesson belongs to. + * @param {any} pageData Result of getPageData for the page to process. + * @param {any} data Data to save. + * @param {string} [password] Lesson password (if any). + * @param {boolean} [review] If the user wants to review just after finishing (1 hour margin). + * @param {boolean} [offline] Whether it's offline mode. + * @param {any} [accessInfo] Result of get access info. Required if offline is true. + * @param {any} [jumps] Result of get pages possible jumps. Required if offline is true. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + processPage(lesson: any, courseId: number, pageData: any, data: any, password?: string, review?: boolean, offline?: boolean, + accessInfo?: boolean, jumps?: any, siteId?: string): Promise { + + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const page = pageData.page, + pageId = page.id; + let result, + pageIndex; + + if (offline) { + // Get the list of pages of the lesson. + return this.getPages(lesson.id, password, true, false, siteId).then((pages) => { + pageIndex = this.createPagesIndex(pages); + + if (pageData.answers.length) { + return this.recordAttempt(lesson, courseId, pageData, data, review, accessInfo, jumps, pageIndex, siteId); + } else { + // The page has no answers so we will just progress to the next page (as set by newpageid). + return { + nodefaultresponse: true, + newpageid: data.newpageid + }; + } + }).then((res) => { + result = res; + result.newpageid = this.getNewPageId(pageData.page.id, result.newpageid, jumps); + + // Calculate some needed offline data. + return this.calculateOfflineData(lesson, accessInfo, password, review, pageIndex, siteId); + }).then((calculatedData) => { + // Add some default data to match the WS response. + result.warnings = []; + result.displaymenu = pageData.displaymenu; // Keep the same value since we can't calculate it in offline. + result.messages = this.getPageProcessMessages(lesson, accessInfo, result, review, jumps); + Object.assign(result, calculatedData); + + return result; + }); + } + + return this.processPageOnline(lesson.id, pageId, data, password, review, siteId); + } + + /** + * Process a lesson page, saving its data. It will fail if offline or cannot connect. + * + * @param {number} lessonId Lesson ID. + * @param {number} pageId Page ID. + * @param {any} data Data to save. + * @param {string} [password] Lesson password (if any). + * @param {boolean} [review] If the user wants to review just after finishing (1 hour margin). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved in success, rejected otherwise. + */ + processPageOnline(lessonId: number, pageId: number, data: any, password?: string, review?: boolean, siteId?: string) + : Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + const params: any = { + lessonid: lessonId, + pageid: pageId, + data: this.utils.objectToArrayOfObjects(data, 'name', 'value', true), + review: review ? 1 : 0 + }; + + if (typeof password == 'string') { + params.password = password; + } + + return site.write('mod_lesson_process_page', params); + }); + } + + /** + * Records an attempt on a certain page. + * Based on Moodle's record_attempt. + * + * @param {any} lesson Lesson. + * @param {number} courseId Course ID the lesson belongs to. + * @param {any} pageData Result of getPageData for the page to process. + * @param {any} data Data to save. + * @param {boolean} review If the user wants to review just after finishing (1 hour margin). + * @param {any} accessInfo Result of get access info. + * @param {any} jumps Result of get pages possible jumps. + * @param {any} pageIndex Object containing all the pages indexed by ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the result. + */ + protected recordAttempt(lesson: any, courseId: number, pageData: any, data: any, review: boolean, accessInfo: any, jumps: any, + pageIndex: any, siteId?: string): Promise { + + // Check the user answer. Each page type has its own implementation. + const result: AddonModLessonRecordAttemptResult = this.checkAnswer(lesson, pageData, data, jumps, pageIndex), + retake = accessInfo.attemptscount; + + // Processes inmediate jumps. + if (result.inmediatejump) { + if (pageData.page.qtype == AddonModLessonProvider.LESSON_PAGE_BRANCHTABLE) { + // Store the content page data. In Moodle this is stored in a separate table, during checkAnswer. + return this.lessonOfflineProvider.processPage(lesson.id, courseId, retake, pageData.page, data, + result.newpageid, result.answerid, false, result.userresponse, siteId).then(() => { + return result; + }); + } + + return Promise.resolve(result); + } + + let promise = Promise.resolve(), + stop = false, + nAttempts; + + result.attemptsremaining = 0; + result.maxattemptsreached = false; + + if (result.noanswer) { + result.newpageid = pageData.page.id; // Display same page again. + result.feedback = this.translate.instant('addon.mod_lesson.noanswer'); + } else { + if (!accessInfo.canmanage) { + // Get the number of attempts that have been made on this question for this student and retake. + promise = this.getQuestionsAttempts(lesson.id, retake, false, pageData.page.id, siteId).then((attempts) => { + nAttempts = attempts.online.length + attempts.offline.length; + + // Check if they have reached (or exceeded) the maximum number of attempts allowed. + if (nAttempts >= lesson.maxattempts) { + result.maxattemptsreached = true; + result.feedback = this.translate.instant('addon.mod_lesson.maximumnumberofattemptsreached'); + result.newpageid = AddonModLessonProvider.LESSON_NEXTPAGE; + stop = true; // Set stop to true to prevent further calculations. + + return; + } + + let subPromise; + + // Only insert a record if we are not reviewing the lesson. + if (!review) { + if (lesson.retake || (!lesson.retake && !retake)) { + // Store the student's attempt and increase the number of attempts made. + // Calculate and store the new page ID to prevent having to recalculate it later. + const newPageId = this.getNewPageId(pageData.page.id, result.newpageid, jumps); + subPromise = this.lessonOfflineProvider.processPage(lesson.id, courseId, retake, pageData.page, data, + newPageId, result.answerid, result.correctanswer, result.userresponse, siteId); + nAttempts++; + } + } + + // Check if "number of attempts remaining" message is needed. + if (!result.correctanswer && !result.newpageid) { + // Retreive the number of attempts left counter. + if (nAttempts >= lesson.maxattempts) { + if (lesson.maxattempts > 1) { // Don't bother with message if only one attempt. + result.maxattemptsreached = true; + } + result.newpageid = AddonModLessonProvider.LESSON_NEXTPAGE; + } else if (lesson.maxattempts > 1) { // Don't bother with message if only one attempt + result.attemptsremaining = lesson.maxattempts - nAttempts; + } + } + + return subPromise; + }); + } + + promise = promise.then(() => { + if (stop) { + return; + } + + // Determine default feedback if necessary. + if (!result.response) { + if (!lesson.feedback && !result.noanswer && + !(lesson.review && !result.correctanswer && !result.isessayquestion)) { + // These conditions have been met: + // 1. The lesson manager has not supplied feedback to the student. + // 2. Not displaying default feedback. + // 3. The user did provide an answer. + // 4. We are not reviewing with an incorrect answer (and not reviewing an essay question). + + result.nodefaultresponse = true; + } else if (result.isessayquestion) { + result.response = this.translate.instant('addon.mod_lesson.defaultessayresponse'); + } else if (result.correctanswer) { + result.response = this.translate.instant('addon.mod_lesson.thatsthecorrectanswer'); + } else { + result.response = this.translate.instant('addon.mod_lesson.thatsthewronganswer'); + } + } + + if (result.response) { + let subPromise; + + if (lesson.review && !result.correctanswer && !result.isessayquestion) { + // Calculate the number of question attempt in the page if it isn't calculated already. + if (typeof nAttempts == 'undefined') { + subPromise = this.getQuestionsAttempts(lesson.id, retake, false, pageData.page.id, siteId) + .then((result) => { + nAttempts = result.online.length + result.offline.length; + }); + } else { + subPromise = Promise.resolve(); + } + + subPromise.then(() => { + const messageId = nAttempts == 1 ? 'firstwrong' : 'secondpluswrong'; + + result.feedback = ''; + }); + } else { + result.feedback = ''; + subPromise = Promise.resolve(); + } + + let className = 'response'; + if (result.correctanswer) { + className += ' correct'; + } else if (!result.isessayquestion) { + className += ' incorrect'; + } + + return subPromise.then(() => { + result.feedback += '
' + pageData.page.contents + '
'; + result.feedback += '
' + + this.translate.instant('addon.mod_lesson.youranswer') + ' : ' + + (result.studentanswerformat ? result.studentanswer : this.textUtils.cleanTags(result.studentanswer)) + + '
' + result.response + '
'; + }); + } + }); + } + + return promise.then(() => { + return result; + }); + } + + /** + * Remove a password stored in DB. + * + * @param {number} lessonId Lesson ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when removed. + */ + removeStoredPassword(lessonId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().deleteRecords(this.PASSWORD_TABLE, {lessonId}); + }); + } + + /** + * Store a password in DB. + * + * @param {number} lessonId Lesson ID. + * @param {string} password Password to store. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when stored. + */ + storePassword(lessonId: number, password: string, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const entry = { + lessonId: lessonId, + password: password, + timemodified: Date.now() + }; + + return site.getDb().insertRecord(this.PASSWORD_TABLE, entry); + }); + } + + /** + * Function to determine if a page is a valid page. It will add the page to validPages if valid. It can also + * modify the list of viewedPagesIds for cluster pages. + * Based on Moodle's valid_page_and_view. + * + * @param {any} pages Index of lesson pages, indexed by page ID. See createPagesIndex. + * @param {any} page Page to check. + * @param {any} validPages Valid pages, indexed by page ID. + * @param {number[]} viewedPagesIds List of viewed pages IDs. + * @return {number} Next page ID. + */ + validPageAndView(pages: any, page: any, validPages: any, viewedPagesIds: number[]): number { + + if (page.qtype != AddonModLessonProvider.LESSON_PAGE_ENDOFCLUSTER && + page.qtype != AddonModLessonProvider.LESSON_PAGE_ENDOFBRANCH) { + // Add this page as a valid page. + validPages[page.id] = 1; + } + + if (page.qtype == AddonModLessonProvider.LESSON_PAGE_CLUSTER) { + // Get list of pages in the cluster. + const subPages = this.getSubpagesOf(pages, page.id, [AddonModLessonProvider.LESSON_PAGE_ENDOFCLUSTER]); + + subPages.forEach((subPage) => { + const position = viewedPagesIds.indexOf(subPage.id); + + if (position != -1) { + delete viewedPagesIds[position]; // Remove it. + + // Since the user did see one page in the cluster, add the cluster pageid to the viewedPagesIds. + if (viewedPagesIds.indexOf(page.id) == -1) { + viewedPagesIds.push(page.id); + } + } + }); + } + + return page.nextpageid; + } +} diff --git a/src/addon/mod/lesson/providers/module-handler.ts b/src/addon/mod/lesson/providers/module-handler.ts new file mode 100644 index 000000000..6ff5f57f2 --- /dev/null +++ b/src/addon/mod/lesson/providers/module-handler.ts @@ -0,0 +1,72 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { NavController, NavOptions } from 'ionic-angular'; +import { AddonModLessonIndexComponent } from '../components/index/index'; +import { CoreCourseModuleHandler, CoreCourseModuleHandlerData } from '@core/course/providers/module-delegate'; +import { CoreCourseProvider } from '@core/course/providers/course'; +import { AddonModLessonProvider } from './lesson'; + +/** + * Handler to support quiz modules. + */ +@Injectable() +export class AddonModLessonModuleHandler implements CoreCourseModuleHandler { + name = 'AddonModLesson'; + modName = 'lesson'; + + constructor(private courseProvider: CoreCourseProvider, private lessonProvider: AddonModLessonProvider) { } + + /** + * Check if the handler is enabled on a site level. + * + * @return {Promise} Promise resolved with boolean: whether or not the handler is enabled on a site level. + */ + isEnabled(): Promise { + return this.lessonProvider.isPluginEnabled(); + } + + /** + * Get the data required to display the module in the course contents view. + * + * @param {any} module The module object. + * @param {number} courseId The course ID. + * @param {number} sectionId The section ID. + * @return {CoreCourseModuleHandlerData} Data to render the module. + */ + getData(module: any, courseId: number, sectionId: number): CoreCourseModuleHandlerData { + return { + icon: this.courseProvider.getModuleIconSrc('lesson'), + title: module.name, + class: 'addon-mod_lesson-handler', + showDownloadButton: true, + action(event: Event, navCtrl: NavController, module: any, courseId: number, options: NavOptions): void { + navCtrl.push('AddonModLessonIndexPage', {module: module, courseId: courseId}, options); + } + }; + } + + /** + * Get the component to render the module. This is needed to support singleactivity course format. + * The component returned must implement CoreCourseModuleMainComponent. + * + * @param {any} course The course object. + * @param {any} module The module object. + * @return {any} The component to use, undefined if not found. + */ + getMainComponent(course: any, module: any): any { + return AddonModLessonIndexComponent; + } +} diff --git a/src/addon/mod/lesson/providers/prefetch-handler.ts b/src/addon/mod/lesson/providers/prefetch-handler.ts new file mode 100644 index 000000000..4a1dc83c9 --- /dev/null +++ b/src/addon/mod/lesson/providers/prefetch-handler.ts @@ -0,0 +1,449 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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, Injector } from '@angular/core'; +import { ModalController } from 'ionic-angular'; +import { CoreGroupsProvider } from '@providers/groups'; +import { CoreCourseModulePrefetchHandlerBase } from '@core/course/classes/module-prefetch-handler'; +import { AddonModLessonProvider } from './lesson'; + +/** + * Handler to prefetch lessons. + */ +@Injectable() +export class AddonModLessonPrefetchHandler extends CoreCourseModulePrefetchHandlerBase { + 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$/; + + constructor(protected injector: Injector, protected modalCtrl: ModalController, protected groupsProvider: CoreGroupsProvider, + protected lessonProvider: AddonModLessonProvider) { + super(injector); + } + + /** + * Ask password. + * + * @param {any} info Lesson access info. + * @return {Promise} Promise resolved with the password. + */ + protected askUserPassword(info: any): Promise { + // Create and show the modal. + const modal = this.modalCtrl.create('AddonModLessonPasswordModalPage'); + + modal.present(); + + // Wait for modal to be dismissed. + return new Promise((resolve, reject): void => { + modal.onDidDismiss((password) => { + if (typeof password != 'undefined') { + resolve(password); + } else { + reject(this.domUtils.createCanceledError()); + } + }); + }); + } + + /** + * Download the module. + * + * @param {any} module The module object returned by WS. + * @param {number} courseId Course ID. + * @param {string} [dirPath] Path of the directory where to store all the content files. @see downloadOrPrefetch. + * @return {Promise} Promise resolved when all content is downloaded. + */ + download(module: any, courseId: number, dirPath?: string): Promise { + // Same implementation for download and prefetch. + return this.prefetch(module, courseId, false, dirPath); + } + + /** + * Get the download size of a module. + * + * @param {any} module Module. + * @param {Number} courseId Course ID the module belongs to. + * @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section. + * @return {Promise<{size: number, total: boolean}>} Promise resolved with the size and a boolean indicating if it was able + * to calculate the total size. + */ + getDownloadSize(module: any, courseId: any, single?: boolean): Promise<{ size: number, total: boolean }> { + const siteId = this.sitesProvider.getCurrentSiteId(); + let lesson, + password, + result; + + return this.lessonProvider.getLesson(courseId, module.id, false, siteId).then((lessonData) => { + lesson = lessonData; + + // Get the lesson password if it's needed. + return this.getLessonPassword(lesson.id, false, true, single, siteId); + }).then((data) => { + password = data.password; + lesson = data.lesson || lesson; + + // Get intro files and media files. + let files = lesson.mediafiles || []; + files = files.concat(this.getIntroFilesFromInstance(module, lesson)); + + result = this.utils.sumFileSizes(files); + + // Get the pages to calculate the size. + return this.lessonProvider.getPages(lesson.id, password, false, false, siteId); + }).then((pages) => { + pages.forEach((page) => { + result.size += page.filessizetotal; + }); + + return result; + }); + } + + /** + * Get list of files. If not defined, we'll assume they're in module.contents. + * + * @param {any} module Module. + * @param {Number} courseId Course ID the module belongs to. + * @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section. + * @return {Promise} Promise resolved with the list of files. + */ + getFiles(module: any, courseId: number, single?: boolean): Promise { + return Promise.resolve([]); + } + + /** + * Get the lesson password if needed. If not stored, it can ask the user to enter it. + * + * @param {number} lessonId Lesson ID. + * @param {boolean} [forceCache] Whether it should return cached data. Has priority over ignoreCache. + * @param {boolean} [ignoreCache] Whether it should ignore cached data (it will always fail in offline or server down). + * @param {boolean} [askPassword] True if we should ask for password if needed, false otherwise. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise<{password?: string, lesson?: any, accessInfo: any}>} Promise resolved when done. + */ + getLessonPassword(lessonId: number, forceCache?: boolean, ignoreCache?: boolean, askPassword?: boolean, siteId?: string) + : Promise<{password?: string, lesson?: any, accessInfo: any}> { + + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + // Get access information to check if password is needed. + return this.lessonProvider.getAccessInformation(lessonId, forceCache, ignoreCache, siteId).then((info): any => { + if (info.preventaccessreasons && info.preventaccessreasons.length) { + const passwordNeeded = info.preventaccessreasons.length == 1 && this.lessonProvider.isPasswordProtected(info); + if (passwordNeeded) { + + // The lesson requires a password. Check if there is one in DB. + return this.lessonProvider.getStoredPassword(lessonId).catch(() => { + // No password found. + }).then((password) => { + if (password) { + return this.validatePassword(lessonId, info, password, forceCache, ignoreCache, siteId); + } else { + return Promise.reject(null); + } + }).catch(() => { + // No password or error validating it. Ask for it if allowed. + if (askPassword) { + return this.askUserPassword(info).then((password) => { + return this.validatePassword(lessonId, info, password, forceCache, ignoreCache, siteId); + }); + } + + // Cannot ask for password, reject. + return Promise.reject(info.preventaccessreasons[0].message); + }); + } else { + // Lesson cannot be played, reject. + return Promise.reject(info.preventaccessreasons[0].message); + } + } + + // Password not needed. + return { accessInfo: info }; + }); + } + + /** + * Invalidate the prefetched content. + * + * @param {number} moduleId The module ID. + * @param {number} courseId The course ID the module belongs to. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateContent(moduleId: number, courseId: number): Promise { + // Only invalidate the data that doesn't ignore cache when prefetching. + const promises = []; + + promises.push(this.lessonProvider.invalidateLessonData(courseId)); + promises.push(this.courseProvider.invalidateModule(moduleId)); + promises.push(this.groupsProvider.invalidateActivityAllowedGroups(moduleId)); + + return Promise.all(promises); + } + + /** + * Invalidate WS calls needed to determine module status. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @return {Promise} Promise resolved when invalidated. + */ + invalidateModule(module: any, courseId: number): Promise { + const siteId = this.sitesProvider.getCurrentSiteId(); + + // Invalidate data to determine if module is downloadable. + return this.lessonProvider.getLesson(courseId, module.id, false, siteId).then((lesson) => { + const promises = []; + + promises.push(this.lessonProvider.invalidateLessonData(courseId, siteId)); + promises.push(this.lessonProvider.invalidateAccessInformation(lesson.id, siteId)); + + return Promise.all(promises); + }); + } + + /** + * Check if a module can be downloaded. If the function is not defined, we assume that all modules are downloadable. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @return {boolean|Promise} Whether the module can be downloaded. The promise should never be rejected. + */ + isDownloadable(module: any, courseId: number): boolean | Promise { + const siteId = this.sitesProvider.getCurrentSiteId(); + + return this.lessonProvider.getLesson(courseId, module.id, false, siteId).then((lesson) => { + if (!this.lessonProvider.isLessonOffline(lesson)) { + return false; + } + + // Check if there is any prevent access reason. + return this.lessonProvider.getAccessInformation(lesson.id, false, false, siteId).then((info) => { + // It's downloadable if there are no prevent access reasons or there is just 1 and it's password. + return !info.preventaccessreasons || !info.preventaccessreasons.length || + (info.preventaccessreasons.length == 1 && this.lessonProvider.isPasswordProtected(info)); + }); + }); + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return {boolean|Promise} A boolean, or a promise resolved with a boolean, indicating if the handler is enabled. + */ + isEnabled(): boolean | Promise { + return this.lessonProvider.isPluginEnabled(); + } + + /** + * Prefetch a module. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section. + * @param {string} [dirPath] Path of the directory where to store all the content files. @see downloadOrPrefetch. + * @return {Promise} Promise resolved when done. + */ + prefetch(module: any, courseId?: number, single?: boolean, dirPath?: string): Promise { + return this.prefetchPackage(module, courseId, single, this.prefetchLesson.bind(this)); + } + + /** + * Prefetch a lesson. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @param {boolean} single True if we're downloading a single module, false if we're downloading a whole section. + * @param {String} siteId Site ID. + * @return {Promise} Promise resolved when done. + */ + protected prefetchLesson(module: any, courseId: number, single: boolean, siteId: string): Promise { + let lesson, + password, + accessInfo; + + return this.lessonProvider.getLesson(courseId, module.id, false, siteId).then((lessonData) => { + lesson = lessonData; + + // Get the lesson password if it's needed. + return this.getLessonPassword(lesson.id, false, true, single, siteId); + }).then((data) => { + password = data.password; + lesson = data.lesson || lesson; + accessInfo = data.accessInfo; + + if (!this.lessonProvider.leftDuringTimed(accessInfo)) { + // The user didn't left during a timed session. Call launch retake to make sure there is a started retake. + return this.lessonProvider.launchRetake(lesson.id, password, undefined, false, siteId).then(() => { + const promises = []; + + // New data generated, update the download time and refresh the access info. + promises.push(this.filepoolProvider.updatePackageDownloadTime(siteId, this.component, module.id).catch(() => { + // Ignore errors. + })); + + promises.push(this.lessonProvider.getAccessInformation(lesson.id, false, true, siteId).then((info) => { + accessInfo = info; + })); + + return Promise.all(promises); + }); + } + }).then(() => { + const promises = [], + retake = accessInfo.attemptscount; + + // Download intro files and media files. + let files = lesson.mediafiles || []; + files = files.concat(this.getIntroFilesFromInstance(module, lesson)); + + promises.push(this.filepoolProvider.addFilesToQueue(siteId, files, this.component, module.id)); + + // Get the list of pages. + promises.push(this.lessonProvider.getPages(lesson.id, password, false, true, siteId).then((pages) => { + const subPromises = []; + let hasRandomBranch = false; + + // Get the data for each page. + pages.forEach((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. + subPromises.push(this.lessonProvider.getPageData(lesson, data.page.id, password, false, true, false, + true, undefined, undefined, siteId).then((pageData) => { + + // Download the page files. + let pageFiles = pageData.contentfiles || []; + + pageData.answers.forEach((answer) => { + if (answer.answerfiles && answer.answerfiles.length) { + pageFiles = pageFiles.concat(answer.answerfiles); + } + if (answer.responsefiles && answer.responsefiles.length) { + pageFiles = pageFiles.concat(answer.responsefiles); + } + }); + + return this.filepoolProvider.addFilesToQueue(siteId, pageFiles, this.component, module.id); + })); + }); + + // Prefetch the list of possible jumps for offline navigation. Do it here because we know hasRandomBranch. + subPromises.push(this.lessonProvider.getPagesPossibleJumps(lesson.id, false, true, siteId).catch((error) => { + if (hasRandomBranch) { + // The WebSevice probably failed because RANDOMBRANCH aren't supported if the user hasn't seen any page. + return Promise.reject(this.translate.instant('addon.mod_lesson.errorprefetchrandombranch')); + } else { + return Promise.reject(error); + } + })); + + return Promise.all(subPromises); + })); + + // Prefetch user timers to be able to calculate timemodified in offline. + promises.push(this.lessonProvider.getTimers(lesson.id, false, true, siteId).catch(() => { + // Ignore errors. + })); + + // Prefetch viewed pages in last retake to calculate progress. + promises.push(this.lessonProvider.getContentPagesViewedOnline(lesson.id, retake, false, true, siteId)); + + // Prefetch question attempts in last retake for offline calculations. + promises.push(this.lessonProvider.getQuestionsAttemptsOnline(lesson.id, retake, false, undefined, false, true, siteId)); + + // Get module info to be able to handle links. + promises.push(this.courseProvider.getModuleBasicInfo(module.id, siteId)); + + if (accessInfo.canviewreports) { + // Prefetch reports data. + promises.push(this.groupsProvider.getActivityAllowedGroupsIfEnabled(module.id, undefined, siteId).then((groups) => { + const subPromises = []; + + groups.forEach((group) => { + subPromises.push(this.lessonProvider.getRetakesOverview(lesson.id, group.id, false, true, siteId)); + }); + + // Always get group 0, even if there are no groups. + subPromises.push(this.lessonProvider.getRetakesOverview(lesson.id, 0, false, true, siteId).then((data) => { + if (!data || !data.students) { + return; + } + + // Prefetch the last retake for each user. + const retakePromises = []; + + data.students.forEach((student) => { + if (!student.attempts || !student.attempts.length) { + return; + } + + const lastRetake = student.attempts[student.attempts.length - 1]; + if (!lastRetake) { + return; + } + + retakePromises.push(this.lessonProvider.getUserRetake(lesson.id, lastRetake.try, student.id, false, + true, siteId)); + }); + + return Promise.all(retakePromises); + })); + + return Promise.all(subPromises); + })); + } + + return Promise.all(promises); + }); + } + + /** + * Validate the password. + * + * @param {number} lessonId Lesson ID. + * @param {any} info Lesson access info. + * @param {string} pwd Password to check. + * @param {boolean} [forceCache] Whether it should return cached data. Has priority over ignoreCache. + * @param {boolean} [ignoreCache] Whether it should ignore cached data (it will always fail in offline or server down). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise<{password: string, lesson: any, accessInfo: any}>} Promise resolved when done. + */ + protected validatePassword(lessonId: number, info: any, pwd: string, forceCache?: boolean, ignoreCache?: boolean, + siteId?: string): Promise<{password: string, lesson: any, accessInfo: any}> { + + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + return this.lessonProvider.getLessonWithPassword(lessonId, pwd, true, forceCache, ignoreCache, siteId).then((lesson) => { + // Password is ok, store it and return the data. + return this.lessonProvider.storePassword(lesson.id, pwd, siteId).then(() => { + return { + password: pwd, + lesson: lesson, + accessInfo: info + }; + }); + }); + } +} diff --git a/src/addon/mod/lesson/providers/report-link-handler.ts b/src/addon/mod/lesson/providers/report-link-handler.ts new file mode 100644 index 000000000..e07c9978c --- /dev/null +++ b/src/addon/mod/lesson/providers/report-link-handler.ts @@ -0,0 +1,154 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { NavController } from 'ionic-angular'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreContentLinksHandlerBase } from '@core/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@core/contentlinks/providers/delegate'; +import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; +import { CoreCourseProvider } from '@core/course/providers/course'; +import { CoreCourseHelperProvider } from '@core/course/providers/helper'; +import { AddonModLessonProvider } from './lesson'; + +/** + * Handler to treat links to lesson report. + */ +@Injectable() +export class AddonModLessonReportLinkHandler extends CoreContentLinksHandlerBase { + name = 'AddonModLessonReportLinkHandler'; + featureName = 'CoreCourseModuleDelegate_AddonModLesson'; + pattern = /\/mod\/lesson\/report\.php.*([\&\?]id=\d+)/; + + constructor(protected domUtils: CoreDomUtilsProvider, protected lessonProvider: AddonModLessonProvider, + protected courseHelper: CoreCourseHelperProvider, protected linkHelper: CoreContentLinksHelperProvider, + protected courseProvider: CoreCourseProvider) { + super(); + } + + /** + * Get the list of actions for a link (url). + * + * @param {string[]} siteIds List of sites the URL belongs to. + * @param {string} url The URL to treat. + * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param {number} [courseId] Course ID related to the URL. Optional but recommended. + * @return {CoreContentLinksAction[]|Promise} List of (or promise resolved with list of) actions. + */ + getActions(siteIds: string[], url: string, params: any, courseId?: number): + CoreContentLinksAction[] | Promise { + courseId = courseId || params.courseid || params.cid; + + return [{ + action: (siteId, navCtrl?): void => { + if (!params.action || params.action == 'reportoverview') { + // Go to overview. + this.openReportOverview(parseInt(params.id, 10), courseId, parseInt(params.group, 10), siteId, navCtrl); + } else if (params.action == 'reportdetail') { + this.openUserRetake(parseInt(params.id, 10), parseInt(params.userid, 10), courseId, parseInt(params.try, 10), + siteId, navCtrl); + } + } + }]; + } + + /** + * Check if the handler is enabled for a certain site (site + user) and a URL. + * If not defined, defaults to true. + * + * @param {string} siteId The site ID. + * @param {string} url The URL to treat. + * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param {number} [courseId] Course ID related to the URL. Optional but recommended. + * @return {boolean|Promise} Whether the handler is enabled for the URL and site. + */ + isEnabled(siteId: string, url: string, params: any, courseId?: number): boolean | Promise { + if (params.action == 'reportdetail' && !params.userid) { + // Individual details are only available if the teacher is seeing a certain user. + return false; + } + + return this.lessonProvider.isPluginEnabled(); + } + + /** + * Open report overview. + * + * @param {number} moduleId Module ID. + * @param {number} courseId Course ID. + * @param {string} groupId Group ID. + * @param {string} siteId Site ID. + * @param {NavController} [navCtrl] The NavController to use to navigate. + * @return {Promise} Promise resolved when done. + */ + protected openReportOverview(moduleId: number, courseId?: number, groupId?: number, siteId?: string, navCtrl?: NavController) + : Promise { + + const modal = this.domUtils.showModalLoading(); + + // Get the module object. + return this.courseProvider.getModuleBasicInfo(moduleId, siteId).then((module) => { + courseId = courseId || module.course; + + const pageParams = { + module: module, + courseId: Number(courseId), + action: 'report', + group: groupId + }; + + this.linkHelper.goInSite(navCtrl, 'AddonModLessonIndexPage', pageParams, siteId); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error processing link.'); + }).finally(() => { + modal.dismiss(); + }); + } + + /** + * Open a user's retake. + * + * @param {number} moduleId Module ID. + * @param {number} userId User ID. + * @param {number} courseId Course ID. + * @param {number} retake Retake to open. + * @param {string} groupId Group ID. + * @param {string} siteId Site ID. + * @param {NavController} [navCtrl] The NavController to use to navigate. + * @return {Promise} Promise resolved when done. + */ + protected openUserRetake(moduleId: number, userId: number, courseId: number, retake: number, siteId: string, + navCtrl?: NavController): Promise { + + const modal = this.domUtils.showModalLoading(); + + // Get the module object. + return this.courseProvider.getModuleBasicInfo(moduleId, siteId).then((module) => { + courseId = courseId || module.course; + + const pageParams = { + lessonId: module.instance, + courseId: Number(courseId), + userId: userId, + retake: retake || 0 + }; + + this.linkHelper.goInSite(navCtrl, 'AddonModLessonUserRetakePage', pageParams, siteId); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error processing link.'); + }).finally(() => { + modal.dismiss(); + }); + } +} diff --git a/src/addon/mod/lesson/providers/sync-cron-handler.ts b/src/addon/mod/lesson/providers/sync-cron-handler.ts new file mode 100644 index 000000000..a88968db2 --- /dev/null +++ b/src/addon/mod/lesson/providers/sync-cron-handler.ts @@ -0,0 +1,47 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 '@providers/cron'; +import { AddonModLessonSyncProvider } from './lesson-sync'; + +/** + * Synchronization cron handler. + */ +@Injectable() +export class AddonModLessonSyncCronHandler implements CoreCronHandler { + name = 'AddonModLessonSyncCronHandler'; + + constructor(private lessonSync: AddonModLessonSyncProvider) {} + + /** + * Execute the process. + * Receives the ID of the site affected, undefined for all sites. + * + * @param {string} [siteId] ID of the site affected, undefined for all sites. + * @return {Promise} Promise resolved when done, rejected if failure. + */ + execute(siteId?: string): Promise { + return this.lessonSync.syncAllLessons(siteId); + } + + /** + * Get the time between consecutive executions. + * + * @return {number} Time between consecutive executions (in ms). + */ + getInterval(): number { + return 600000; + } +} diff --git a/src/addon/mod/scorm/components/index/index.html b/src/addon/mod/scorm/components/index/index.html index a9bb0b3d0..c96072bb2 100644 --- a/src/addon/mod/scorm/components/index/index.html +++ b/src/addon/mod/scorm/components/index/index.html @@ -17,7 +17,7 @@
- + {{ scorm.warningMessage }}
diff --git a/src/addon/qtype/ddimageortext/component/ddimageortext.html b/src/addon/qtype/ddimageortext/component/ddimageortext.html index 23d97495f..218b25b24 100644 --- a/src/addon/qtype/ddimageortext/component/ddimageortext.html +++ b/src/addon/qtype/ddimageortext/component/ddimageortext.html @@ -4,7 +4,7 @@

- + {{ 'core.question.howtodraganddrop' | translate }}

diff --git a/src/addon/qtype/ddmarker/component/ddmarker.html b/src/addon/qtype/ddmarker/component/ddmarker.html index f6ada3e34..b94b17668 100644 --- a/src/addon/qtype/ddmarker/component/ddmarker.html +++ b/src/addon/qtype/ddmarker/component/ddmarker.html @@ -4,7 +4,7 @@

- + {{ 'core.question.howtodraganddrop' | translate }}

diff --git a/src/addon/qtype/ddwtos/component/ddwtos.html b/src/addon/qtype/ddwtos/component/ddwtos.html index 1152d54f3..fbe7bf186 100644 --- a/src/addon/qtype/ddwtos/component/ddwtos.html +++ b/src/addon/qtype/ddwtos/component/ddwtos.html @@ -1,7 +1,7 @@

- + {{ 'core.question.howtodraganddrop' | translate }}

diff --git a/src/addon/qtype/multichoice/component/multichoice.html b/src/addon/qtype/multichoice/component/multichoice.html index a7dd5baf2..a39aaaa2e 100644 --- a/src/addon/qtype/multichoice/component/multichoice.html +++ b/src/addon/qtype/multichoice/component/multichoice.html @@ -12,7 +12,7 @@

- + diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 0d21aa600..caf403c06 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -88,6 +88,7 @@ import { AddonModFeedbackModule } from '@addon/mod/feedback/feedback.module'; import { AddonModFolderModule } from '@addon/mod/folder/folder.module'; import { AddonModForumModule } from '@addon/mod/forum/forum.module'; import { AddonModGlossaryModule } from '@addon/mod/glossary/glossary.module'; +import { AddonModLessonModule } from '@addon/mod/lesson/lesson.module'; import { AddonModPageModule } from '@addon/mod/page/page.module'; import { AddonModQuizModule } from '@addon/mod/quiz/quiz.module'; import { AddonModScormModule } from '@addon/mod/scorm/scorm.module'; @@ -186,6 +187,7 @@ export const CORE_PROVIDERS: any[] = [ AddonModChatModule, AddonModChoiceModule, AddonModLabelModule, + AddonModLessonModule, AddonModResourceModule, AddonModFeedbackModule, AddonModFolderModule, diff --git a/src/components/show-password/show-password.scss b/src/components/show-password/show-password.scss index 919b6dce4..ef9238aaa 100644 --- a/src/components/show-password/show-password.scss +++ b/src/components/show-password/show-password.scss @@ -11,7 +11,7 @@ core-show-password { background: transparent; padding: 0 ($content-padding / 2); position: absolute; - top: $content-padding / 2; + bottom: $content-padding / 2; right: 0; margin-top: 0; margin-bottom: 0; @@ -25,25 +25,25 @@ core-show-password { .md { .item-label-stacked core-show-password .button[icon-only] { - top: 0; + bottom: 0; } } .ios { .item-label-stacked core-show-password .button[icon-only] { - top: -5px; + bottom: -5px; } core-show-password .button[icon-only] { - top: 0; + bottom: 0; } } .wp { .item-label-stacked core-show-password .button[icon-only] { - top: 7px; + bottom: 7px; } core-show-password .button[icon-only] { - top: 12px; + bottom: 12px; right: 5px; } } diff --git a/src/core/course/components/module/module.ts b/src/core/course/components/module/module.ts index 7ab0a91b7..ccc71b055 100644 --- a/src/core/course/components/module/module.ts +++ b/src/core/course/components/module/module.ts @@ -139,7 +139,7 @@ export class CoreCourseModuleComponent implements OnInit, OnDestroy { this.spinner = true; // Get download size to ask for confirm if it's high. - this.prefetchHandler.getDownloadSize(this.module, this.courseId).then((size) => { + this.prefetchHandler.getDownloadSize(this.module, this.courseId, true).then((size) => { return this.courseHelper.prefetchModule(this.prefetchHandler, this.module, size, this.courseId, refresh); }).catch((error) => { // Error, hide spinner. diff --git a/src/core/course/providers/helper.ts b/src/core/course/providers/helper.ts index e55e9e287..79902b65b 100644 --- a/src/core/course/providers/helper.ts +++ b/src/core/course/providers/helper.ts @@ -908,6 +908,10 @@ export class CoreCourseHelperProvider { } return promise.then(() => { + // Make sure they're numbers. + courseId = Number(courseId); + sectionId = Number(sectionId); + // Get the site. return this.sitesProvider.getSite(siteId); }).then((s) => { diff --git a/src/core/grades/providers/grades.ts b/src/core/grades/providers/grades.ts index b040678c7..4647b6ed6 100644 --- a/src/core/grades/providers/grades.ts +++ b/src/core/grades/providers/grades.ts @@ -22,6 +22,12 @@ import { CoreCoursesProvider } from '@core/courses/providers/courses'; */ @Injectable() export class CoreGradesProvider { + + static TYPE_NONE = 0; // Moodle's GRADE_TYPE_NONE. + static TYPE_VALUE = 1; // Moodle's GRADE_TYPE_VALUE. + static TYPE_SCALE = 2; // Moodle's GRADE_TYPE_SCALE. + static TYPE_TEXT = 3; // Moodle's GRADE_TYPE_TEXT. + protected ROOT_CACHE_KEY = 'mmGrades:'; protected logger; diff --git a/src/core/login/pages/reconnect/reconnect.html b/src/core/login/pages/reconnect/reconnect.html index 74350213e..f2b454762 100644 --- a/src/core/login/pages/reconnect/reconnect.html +++ b/src/core/login/pages/reconnect/reconnect.html @@ -33,7 +33,7 @@
- + diff --git a/src/core/settings/lang/en.json b/src/core/settings/lang/en.json index 00ac951bd..a68e5bcf7 100644 --- a/src/core/settings/lang/en.json +++ b/src/core/settings/lang/en.json @@ -17,6 +17,7 @@ "disableall": "Disable notifications", "disabled": "Disabled", "displayformat": "Display format", + "enabledownloadsection": "Enable download sections", "enablerichtexteditor": "Enable text editor", "enablerichtexteditordescription": "If enabled, a text editor will be available when entering content.", "enablesyncwifi": "Allow sync only when on Wi-Fi", diff --git a/src/providers/utils/text.ts b/src/providers/utils/text.ts index 9e47ed8ea..5d3fcbcf1 100644 --- a/src/providers/utils/text.ts +++ b/src/providers/utils/text.ts @@ -131,6 +131,10 @@ export class CoreTextUtilsProvider { * @return {string} Clean text. */ cleanTags(text: string, singleLine?: boolean): string { + if (typeof text == 'number') { + return text; + } + if (!text) { return ''; }