diff --git a/src/addon/mod/h5pactivity/components/index/addon-mod-h5pactivity-index.html b/src/addon/mod/h5pactivity/components/index/addon-mod-h5pactivity-index.html index 53638796a..823d475cf 100644 --- a/src/addon/mod/h5pactivity/components/index/addon-mod-h5pactivity-index.html +++ b/src/addon/mod/h5pactivity/components/index/addon-mod-h5pactivity-index.html @@ -1,6 +1,7 @@ + diff --git a/src/addon/mod/h5pactivity/components/index/index.ts b/src/addon/mod/h5pactivity/components/index/index.ts index 8f452818b..bf879ebe0 100644 --- a/src/addon/mod/h5pactivity/components/index/index.ts +++ b/src/addon/mod/h5pactivity/components/index/index.ts @@ -319,6 +319,13 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv AddonModH5PActivity.instance.logView(this.h5pActivity.id, this.h5pActivity.name, this.siteId); } + /** + * Go to view user events. + */ + viewMyAttempts(): void { + this.navCtrl.push('AddonModH5PActivityUserAttemptsPage', {courseId: this.courseId, h5pActivityId: this.h5pActivity.id}); + } + /** * Component destroyed. */ diff --git a/src/addon/mod/h5pactivity/lang/en.json b/src/addon/mod/h5pactivity/lang/en.json index 2e7efde5b..7e1619d3b 100644 --- a/src/addon/mod/h5pactivity/lang/en.json +++ b/src/addon/mod/h5pactivity/lang/en.json @@ -1,8 +1,22 @@ { + "all_attempts": "All user attempts", + "attempt_completion_no": "This attempt is not marked as completed", + "attempt_completion_yes": "This attempt is completed", + "attempts_none": "This user has no attempts to display.", + "attempt_success_fail": "Fail", + "attempt_success_pass": "Pass", + "attempt_success_unknown": "Not reported", + "completion": "Completion", "downloadh5pfile": "Download H5P file", + "duration": "Duration", "errorgetactivity": "Error getting H5P activity data.", "filestatenotdownloaded": "The H5P package is not downloaded. You need to download it to be able to use it.", "filestateoutdated": "The H5P package has been modified since the last download. You need to download it again to be able to use it.", + "maxscore": "Max score", "modulenameplural": "H5P", - "offlinedisabledwarning": "You will need to be online to view the H5P package." + "myattempts": "My attempts", + "offlinedisabledwarning": "You will need to be online to view the H5P package.", + "review_my_attempts": "View my attempts", + "score": "Score", + "viewattempt": "View attempt {{$a}}" } \ No newline at end of file diff --git a/src/addon/mod/h5pactivity/pages/user-attempts/user-attempts.html b/src/addon/mod/h5pactivity/pages/user-attempts/user-attempts.html new file mode 100644 index 000000000..7712c6415 --- /dev/null +++ b/src/addon/mod/h5pactivity/pages/user-attempts/user-attempts.html @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + +

{{ user.fullname }}

+

{{ 'addon.mod_h5pactivity.myattempts' | translate }}

+
+
+ + + + + +

{{ attemptsData.scored.title }}

+
+ +
+ + + + +

{{ 'addon.mod_h5pactivity.all_attempts' | translate }}

+
+ +
+
+ + + + +
+
+ + + + + + + # + {{ 'core.date' | translate }} + {{ 'addon.mod_h5pactivity.score' | translate }} + {{ 'addon.mod_h5pactivity.maxscore' | translate }} + {{ 'addon.mod_h5pactivity.duration' | translate }} + {{ 'addon.mod_h5pactivity.completion' | translate }} + {{ 'core.success' | translate }} + + + + + + + {{ attempt.attempt }} + {{ attempt.timemodified | coreFormatDate:'strftimedatetimeshort' }} + + {{ attempt.rawscore }} / {{ attempt.maxscore }} + + {{ attempt.maxscore }} + {{ attempt.durationReadable }} + + + + + + + + + + + + \ No newline at end of file diff --git a/src/addon/mod/h5pactivity/pages/user-attempts/user-attempts.module.ts b/src/addon/mod/h5pactivity/pages/user-attempts/user-attempts.module.ts new file mode 100644 index 000000000..8532f1bb4 --- /dev/null +++ b/src/addon/mod/h5pactivity/pages/user-attempts/user-attempts.module.ts @@ -0,0 +1,35 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { 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 { AddonModH5PActivityUserAttemptsPage } from './user-attempts'; + +@NgModule({ + declarations: [ + AddonModH5PActivityUserAttemptsPage, + ], + imports: [ + CoreComponentsModule, + CoreDirectivesModule, + CorePipesModule, + IonicPageModule.forChild(AddonModH5PActivityUserAttemptsPage), + TranslateModule.forChild(), + ], +}) +export class AddonModH5PActivityUserAttemptsPageModule {} diff --git a/src/addon/mod/h5pactivity/pages/user-attempts/user-attempts.scss b/src/addon/mod/h5pactivity/pages/user-attempts/user-attempts.scss new file mode 100644 index 000000000..f6a9381b0 --- /dev/null +++ b/src/addon/mod/h5pactivity/pages/user-attempts/user-attempts.scss @@ -0,0 +1,37 @@ +ion-app.app-root page-addon-mod-h5pactivity-user-attempts { + + .item.addon-mod_h5pactivity-table-header[detail-push] .item-inner { + background-image: none; + } + + .item.addon-mod_h5pactivity-table-header .item-inner { + font-size: 0.9em; + font-weight: bold; + + .col[text-center] { + @include padding-horizontal(0); + } + } + + .addon-mod_h5pactivity-table-header, .addon-mod_h5pactivity-table-row { + + .item-inner ion-label { + @include margin(null, 0, null, null); + } + + .item { + @include padding(null, null, null, 0); + } + + .label { + margin-top: 0; + margin-bottom: 0; + } + } + + .addon-mod_h5pactivity-table-row { + .addon-mod_h5pactivity-table-success-col { + font-size: 1.4em; + } + } +} diff --git a/src/addon/mod/h5pactivity/pages/user-attempts/user-attempts.ts b/src/addon/mod/h5pactivity/pages/user-attempts/user-attempts.ts new file mode 100644 index 000000000..19bcd9878 --- /dev/null +++ b/src/addon/mod/h5pactivity/pages/user-attempts/user-attempts.ts @@ -0,0 +1,134 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, OnInit } from '@angular/core'; +import { IonicPage, NavParams } from 'ionic-angular'; +import { CoreSites } from '@providers/sites'; +import { CoreDomUtils } from '@providers/utils/dom'; +import { CoreUser } from '@core/user/providers/user'; +import { + AddonModH5PActivity, AddonModH5PActivityData, AddonModH5PActivityUserAttempts +} from '../../providers/h5pactivity'; + +/** + * Page that displays user attempts of a certain user. + */ +@IonicPage({ segment: 'addon-mod-h5pactivity-user-attempts' }) +@Component({ + selector: 'page-addon-mod-h5pactivity-user-attempts', + templateUrl: 'user-attempts.html', +}) +export class AddonModH5PActivityUserAttemptsPage implements OnInit { + loaded: boolean; + h5pActivity: AddonModH5PActivityData; + attemptsData: AddonModH5PActivityUserAttempts; + user: any; + isCurrentUser: boolean; + + protected courseId: number; + protected h5pActivityId: number; + protected userId: number; + + constructor(navParams: NavParams) { + this.courseId = navParams.get('courseId'); + this.h5pActivityId = navParams.get('h5pActivityId'); + this.userId = navParams.get('userId') || CoreSites.instance.getCurrentSiteUserId(); + this.isCurrentUser = this.userId == CoreSites.instance.getCurrentSiteUserId(); + } + + /** + * Component being initialized. + * + * @return Promise resolved when done. + */ + async ngOnInit(): Promise { + try { + await this.fetchData(); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'Error loading attempts.'); + } finally { + this.loaded = true; + } + } + + /** + * Refresh the data. + * + * @param refresher Refresher. + */ + doRefresh(refresher: any): void { + this.refreshData().finally(() => { + refresher.complete(); + }); + } + + /** + * Get quiz data and attempt data. + * + * @return Promise resolved when done. + */ + protected async fetchData(): Promise { + await Promise.all([ + this.fetchActivity(), + this.fetchAttempts(), + this.fetchUserProfile(), + ]); + } + + /** + * Get activity data. + * + * @return Promise resolved when done. + */ + protected async fetchActivity(): Promise { + this.h5pActivity = await AddonModH5PActivity.instance.getH5PActivityById(this.courseId, this.h5pActivityId); + } + + /** + * Get attempts. + * + * @return Promise resolved when done. + */ + protected async fetchAttempts(): Promise { + this.attemptsData = await AddonModH5PActivity.instance.getUserAttempts(this.h5pActivityId, { userId: this.userId }); + } + + /** + * Get user profile. + * + * @return Promise resolved when done. + */ + protected async fetchUserProfile(): Promise { + this.user = await CoreUser.instance.getProfile(this.userId, this.courseId, true); + } + + /** + * Refresh the data. + * + * @return Promise resolved when done. + */ + protected async refreshData(): Promise { + + try { + await Promise.all([ + AddonModH5PActivity.instance.invalidateActivityData(this.courseId), + AddonModH5PActivity.instance.invalidateUserAttempts(this.h5pActivityId, this.userId), + ]); + } catch (error) { + // Ignore errors. + } + + await this.fetchData(); + } +} diff --git a/src/addon/mod/h5pactivity/providers/h5pactivity.ts b/src/addon/mod/h5pactivity/providers/h5pactivity.ts index 70d42524b..4ee07967f 100644 --- a/src/addon/mod/h5pactivity/providers/h5pactivity.ts +++ b/src/addon/mod/h5pactivity/providers/h5pactivity.ts @@ -16,6 +16,7 @@ import { Injectable } from '@angular/core'; import { CoreSites } from '@providers/sites'; import { CoreWSExternalWarning, CoreWSExternalFile } from '@providers/ws'; +import { CoreTimeUtils } from '@providers/utils/time'; import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; import { CoreCourseLogHelper } from '@core/course/providers/log-helper'; import { CoreH5P } from '@core/h5p/providers/h5p'; @@ -32,6 +33,43 @@ export class AddonModH5PActivityProvider { protected ROOT_CACHE_KEY = 'mmaModH5PActivity:'; + /** + * Format an attempt's data. + * + * @param attempt Attempt to format. + */ + protected formatAttempt(attempt: AddonModH5PActivityWSAttempt): AddonModH5PActivityAttempt { + const formattedAttempt: AddonModH5PActivityAttempt = attempt; + + formattedAttempt.timemodified = attempt.timemodified * 1000; // Convert to milliseconds. + formattedAttempt.durationReadable = CoreTimeUtils.instance.formatTime(attempt.duration); + formattedAttempt.durationCompact = CoreTimeUtils.instance.formatDurationShort(attempt.duration); + + return formattedAttempt; + } + + /** + * Format the attempts of a user. + * + * @param data Data to format. + * @return Formatted data. + */ + protected formatUserAttempts(data: AddonModH5PActivityWSUserAttempts): AddonModH5PActivityUserAttempts { + const formatted: AddonModH5PActivityUserAttempts = data; + + for (const i in formatted.attempts) { + formatted.attempts[i] = this.formatAttempt(formatted.attempts[i]); + } + + if (formatted.scored) { + for (const i in formatted.scored.attempts) { + formatted.scored.attempts[i] = this.formatAttempt(formatted.scored.attempts[i]); + } + } + + return formatted; + } + /** * Get cache key for access information WS calls. * @@ -172,6 +210,59 @@ export class AddonModH5PActivityProvider { return this.getH5PActivityByField(courseId, 'id', id, forceCache, siteId); } + /** + * Get cache key for attemps WS calls. + * + * @param id Instance ID. + * @param userIds User IDs. + * @return Cache key. + */ + protected getUserAttemptsCacheKey(id: number, userIds: number[]): string { + return this.getUserAttemptsCommonCacheKey(id) + ':' + JSON.stringify(userIds); + } + + /** + * Get common cache key for attempts WS calls. + * + * @param id Instance ID. + * @return Cache key. + */ + protected getUserAttemptsCommonCacheKey(id: number): string { + return this.ROOT_CACHE_KEY + 'attempts:' + id; + } + + /** + * Get attempts of a certain user. + * + * @param id Activity ID. + * @param options Other options. + * @return Promise resolved with the attempts of the user. + */ + async getUserAttempts(id: number, options?: AddonModH5PActivityGetAttemptsOptions): Promise { + + options = options || {}; + + const site = await CoreSites.instance.getSite(options.siteId); + + const params = { + h5pactivityid: id, + userids: [options.userId || site.getUserId()], + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getUserAttemptsCacheKey(id, params.userids), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES, + }; + + if (options.ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + const response: AddonModH5PActivityGetAttemptsResult = await site.read('mod_h5pactivity_get_attempts', params, preSets); + + return this.formatUserAttempts(response.usersattempts[0]); + } + /** * Invalidates access information. * @@ -199,6 +290,35 @@ export class AddonModH5PActivityProvider { await site.invalidateWsCacheForKey(this.getH5PActivityDataCacheKey(courseId)); } + /** + * Invalidates all users attempts for H5P activity. + * + * @param id Activity ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateAllUserAttempts(id: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getUserAttemptsCommonCacheKey(id)); + } + + /** + * Invalidates attempts of a certain user for H5P activity. + * + * @param id Activity ID. + * @param userId User ID. If not defined, current user in the site. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateUserAttempts(id: number, userId?: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + userId = userId || site.getUserId(); + + await site.invalidateWsCacheForKey(this.getUserAttemptsCacheKey(id, [userId])); + } + /** * Delete launcher. * @@ -286,6 +406,67 @@ export type AddonModH5PActivityAccessInfo = { canreviewattempts?: boolean; // Whether the user has the capability mod/h5pactivity:reviewattempts allowed. }; +/** + * Result of WS mod_h5pactivity_get_attempts. + */ +export type AddonModH5PActivityGetAttemptsResult = { + activityid: number; // Activity course module ID. + usersattempts: AddonModH5PActivityWSUserAttempts[]; // The complete users attempts list. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Attempts data for a user as returned by the WS. + */ +export type AddonModH5PActivityWSUserAttempts = { + userid: number; // The user id. + attempts: AddonModH5PActivityWSAttempt[]; // The complete attempts list. + scored?: { // Attempts used to grade the activity. + title: string; // Scored attempts title. + grademethod: string; // Scored attempts title. + attempts: AddonModH5PActivityWSAttempt[]; // List of the grading attempts. + }; +}; + +/** + * Attempt data as returned by the WS. + */ +export type AddonModH5PActivityWSAttempt = { + id: number; // ID of the context. + h5pactivityid: number; // ID of the H5P activity. + userid: number; // ID of the user. + timecreated: number; // Attempt creation. + timemodified: number; // Attempt modified. + attempt: number; // Attempt number. + rawscore: number; // Attempt score value. + maxscore: number; // Attempt max score. + duration: number; // Attempt duration in seconds. + completion?: number; // Attempt completion. + success?: number; // Attempt success. + scaled: number; // Attempt scaled. +}; + +/** + * Attempts data with some calculated data. + */ +export type AddonModH5PActivityUserAttempts = { + userid: number; // The user id. + attempts: AddonModH5PActivityAttempt[]; // The complete attempts list. + scored?: { // Attempts used to grade the activity. + title: string; // Scored attempts title. + grademethod: string; // Scored attempts title. + attempts: AddonModH5PActivityAttempt[]; // List of the grading attempts. + }; +}; + +/** + * Attempt with some calculated data. + */ +export type AddonModH5PActivityAttempt = AddonModH5PActivityWSAttempt & { + durationReadable?: string; // Duration in a human readable format. + durationCompact?: string; // Duration in a "short" human readable format. +}; + /** * Options to pass to getDeployedFile function. */ @@ -294,3 +475,12 @@ export type AddonModH5PActivityGetDeployedFileOptions = { ignoreCache?: boolean; // Whether to ignore cache. Will fail if offline or server down. siteId?: string; // Site ID. If not defined, current site. }; + +/** + * Options to pass to getAttempts function. + */ +export type AddonModH5PActivityGetAttemptsOptions = { + ignoreCache?: boolean; // Whether to ignore cache. Will fail if offline or server down. + siteId?: string; // Site ID. If not defined, current site. + userId?: number; // User ID. If not defined, user of the site. +}; diff --git a/src/assets/img/icons/empty.svg b/src/assets/img/icons/empty.svg new file mode 100644 index 000000000..a16fb7460 --- /dev/null +++ b/src/assets/img/icons/empty.svg @@ -0,0 +1,3 @@ + +]> \ No newline at end of file diff --git a/src/assets/lang/en.json b/src/assets/lang/en.json index e046cdecd..1f5a0268a 100644 --- a/src/assets/lang/en.json +++ b/src/assets/lang/en.json @@ -659,12 +659,26 @@ "addon.mod_glossary.noentriesfound": "No entries were found.", "addon.mod_glossary.searchquery": "Search query", "addon.mod_glossary.tagarea_glossary_entries": "Glossary entries", + "addon.mod_h5pactivity.all_attempts": "All user attempts", + "addon.mod_h5pactivity.attempt_completion_no": "This attempt is not marked as completed", + "addon.mod_h5pactivity.attempt_completion_yes": "This attempt is completed", + "addon.mod_h5pactivity.attempt_success_fail": "Fail", + "addon.mod_h5pactivity.attempt_success_pass": "Pass", + "addon.mod_h5pactivity.attempt_success_unknown": "Not reported", + "addon.mod_h5pactivity.attempts_none": "This user has no attempts to display.", + "addon.mod_h5pactivity.completion": "Completion", "addon.mod_h5pactivity.downloadh5pfile": "Download H5P file", + "addon.mod_h5pactivity.duration": "Duration", "addon.mod_h5pactivity.errorgetactivity": "Error getting H5P activity data.", "addon.mod_h5pactivity.filestatenotdownloaded": "The H5P package is not downloaded. You need to download it to be able to use it.", "addon.mod_h5pactivity.filestateoutdated": "The H5P package has been modified since the last download. You need to download it again to be able to use it.", + "addon.mod_h5pactivity.maxscore": "Max score", "addon.mod_h5pactivity.modulenameplural": "H5P", + "addon.mod_h5pactivity.myattempts": "My attempts", "addon.mod_h5pactivity.offlinedisabledwarning": "You will need to be online to view the H5P package.", + "addon.mod_h5pactivity.review_my_attempts": "View my attempts", + "addon.mod_h5pactivity.score": "Score", + "addon.mod_h5pactivity.viewattempt": "View attempt {{$a}}", "addon.mod_imscp.deploymenterror": "Content package error!", "addon.mod_imscp.modulenameplural": "IMS content packages", "addon.mod_imscp.showmoduledescription": "Show description", diff --git a/src/core/user/providers/user.ts b/src/core/user/providers/user.ts index 1bbb23c80..4dffe3963 100644 --- a/src/core/user/providers/user.ts +++ b/src/core/user/providers/user.ts @@ -22,6 +22,8 @@ import { CoreAppProvider } from '@providers/app'; import { CoreUserOfflineProvider } from './offline'; import { CorePushNotificationsProvider } from '@core/pushnotifications/providers/pushnotifications'; +import { makeSingleton } from '@singletons/core.singletons'; + /** * Service to provide user functionalities. */ @@ -733,6 +735,8 @@ export class CoreUserProvider { } } +export class CoreUser extends makeSingleton(CoreUserProvider) {} + /** * Data returned by user_summary_exporter. */ diff --git a/src/providers/utils/time.ts b/src/providers/utils/time.ts index 470c30f44..158046a5c 100644 --- a/src/providers/utils/time.ts +++ b/src/providers/utils/time.ts @@ -220,7 +220,7 @@ export class CoreTimeUtilsProvider { * Returns hours, minutes and seconds in a human readable format. * * @param duration Duration in seconds - * @param precision Number of elements to have in precission. 0 or undefined to full precission. + * @param precision Number of elements to have in precision. 0 or undefined to full precission. * @return Duration in a human readable format. */ formatDuration(duration: number, precision?: number): string { @@ -253,6 +253,29 @@ export class CoreTimeUtilsProvider { return durationString.trim(); } + /** + * Returns duration in a short human readable format: minutes and seconds, in fromat: 3' 27''. + * + * @param duration Duration in seconds + * @return Duration in a short human readable format. + */ + formatDurationShort(duration: number): string { + + const minutes = Math.floor(duration / 60); + const seconds = duration - minutes * 60; + const durations = []; + + if (minutes > 0) { + durations.push(minutes + '\''); + } + + if (seconds > 0 || minutes === 0) { + durations.push(seconds + '\'\''); + } + + return durations.join(' '); + } + /** * Return the current timestamp in a "readable" format: YYYYMMDDHHmmSS. *