diff --git a/scripts/langindex.json b/scripts/langindex.json index 3010f8f0a..de6d427b4 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -1557,6 +1557,7 @@ "core.course.highlighted": "moodle", "core.course.insufficientavailablequota": "local_moodlemobileapp", "core.course.insufficientavailablespace": "local_moodlemobileapp", + "core.course.lastaccessedactivity": "local_moodlemobileapp", "core.course.manualcompletionnotsynced": "local_moodlemobileapp", "core.course.modulenotfound": "local_moodlemobileapp", "core.course.nocontentavailable": "local_moodlemobileapp", diff --git a/src/addons/block/recentlyaccesseditems/services/recentlyaccesseditems.ts b/src/addons/block/recentlyaccesseditems/services/recentlyaccesseditems.ts index 8261f24d3..eaafaebec 100644 --- a/src/addons/block/recentlyaccesseditems/services/recentlyaccesseditems.ts +++ b/src/addons/block/recentlyaccesseditems/services/recentlyaccesseditems.ts @@ -49,17 +49,41 @@ export class AddonBlockRecentlyAccessedItemsProvider { cacheKey: this.getRecentItemsCacheKey(), }; - const items: AddonBlockRecentlyAccessedItemsItem[] = + let items: AddonBlockRecentlyAccessedItemsItem[] = await site.read('block_recentlyaccesseditems_get_recent_items', undefined, preSets); - return await Promise.all(items.map(async (item) => { + const cmIds: number[] = []; + + items = await Promise.all(items.map(async (item) => { const modicon = item.icon && CoreDomUtils.getHTMLElementAttribute(item.icon, 'src'); item.iconUrl = await CoreCourse.getModuleIconSrc(item.modname, modicon || undefined); item.iconTitle = item.icon && CoreDomUtils.getHTMLElementAttribute(item.icon, 'title'); + cmIds.push(item.cmid); return item; })); + + // Check if the viewed module should be updated for each activity. + const lastViewedMap = await CoreCourse.getCertainModulesViewed(cmIds, site.getId()); + + items.forEach((recentItem) => { + const timeAccess = recentItem.timeaccess * 1000; + const lastViewed = lastViewedMap[recentItem.cmid]; + + if (lastViewed && lastViewed.timeaccess >= timeAccess) { + return; // No need to update. + } + + // Update access. + CoreCourse.storeModuleViewed(recentItem.courseid, recentItem.cmid, { + timeaccess: recentItem.timeaccess * 1000, + sectionId: lastViewed && lastViewed.sectionId, + siteId: site.getId(), + }); + }); + + return items; } /** diff --git a/src/addons/blog/pages/entries/entries.ts b/src/addons/blog/pages/entries/entries.ts index 98ae5f66a..100eec06b 100644 --- a/src/addons/blog/pages/entries/entries.ts +++ b/src/addons/blog/pages/entries/entries.ts @@ -43,6 +43,7 @@ export class AddonBlogEntriesPage implements OnInit { protected canLoadMoreEntries = false; protected canLoadMoreUserEntries = true; protected siteHomeId: number; + protected fetchSuccess = false; loaded = false; canLoadMore = false; @@ -123,8 +124,6 @@ export class AddonBlogEntriesPage implements OnInit { deepLinkManager.treatLink(); await this.fetchEntries(); - - CoreUtils.ignoreErrors(AddonBlog.logView(this.filter)); } /** @@ -176,13 +175,7 @@ export class AddonBlogEntriesPage implements OnInit { entry.summary = CoreTextUtils.replacePluginfileUrls(entry.summary, entry.summaryfiles || []); - return CoreUser.getProfile(entry.userid, entry.courseid, true).then((user) => { - entry.user = user; - - return; - }).catch(() => { - // Ignore errors. - }); + entry.user = await CoreUtils.ignoreErrors(CoreUser.getProfile(entry.userid, entry.courseid, true)); }); if (refresh) { @@ -205,6 +198,11 @@ export class AddonBlogEntriesPage implements OnInit { } await Promise.all(promises); + + if (!this.fetchSuccess) { + this.fetchSuccess = true; + CoreUtils.ignoreErrors(AddonBlog.logView(this.filter)); + } } catch (error) { CoreDomUtils.showErrorModalDefault(error, 'addon.blog.errorloadentries', true); this.loadMoreError = true; // Set to prevent infinite calls with infinite-loading. diff --git a/src/addons/competency/pages/competency/competency.page.ts b/src/addons/competency/pages/competency/competency.page.ts index e2c2a41a1..763d63ede 100644 --- a/src/addons/competency/pages/competency/competency.page.ts +++ b/src/addons/competency/pages/competency/competency.page.ts @@ -58,6 +58,8 @@ export class AddonCompetencyCompetencyPage implements OnInit, OnDestroy { contextLevel?: string; contextInstanceId?: number; + protected fetchSuccess = false; + constructor() { try { const planId = CoreNavigator.getRouteNumberParam('planId'); @@ -117,33 +119,6 @@ export class AddonCompetencyCompetencyPage implements OnInit, OnDestroy { await source.reload(); await this.competencies.start(); await this.fetchCompetency(); - - if (!this.competency) { - return; - } - - const name = this.competency.competency.competency.shortname; - - if (source instanceof AddonCompetencyPlanCompetenciesSource) { - this.planStatus && await CoreUtils.ignoreErrors( - AddonCompetency.logCompetencyInPlanView( - source.PLAN_ID, - this.requireCompetencyId(), - this.planStatus, - name, - source.user?.id, - ), - ); - } else { - await CoreUtils.ignoreErrors( - AddonCompetency.logCompetencyInCourseView( - source.COURSE_ID, - this.requireCompetencyId(), - name, - source.USER_ID, - ), - ); - } } finally { this.competencyLoaded = true; } @@ -180,6 +155,32 @@ export class AddonCompetencyCompetencyPage implements OnInit, OnDestroy { evidence.description = Translate.instant(key, { $a: evidence.desca }); } }); + + if (!this.fetchSuccess) { + this.fetchSuccess = true; + const name = this.competency.competency.competency.shortname; + + if (source instanceof AddonCompetencyPlanCompetenciesSource) { + this.planStatus && await CoreUtils.ignoreErrors( + AddonCompetency.logCompetencyInPlanView( + source.PLAN_ID, + this.requireCompetencyId(), + this.planStatus, + name, + source.user?.id, + ), + ); + } else { + await CoreUtils.ignoreErrors( + AddonCompetency.logCompetencyInCourseView( + source.COURSE_ID, + this.requireCompetencyId(), + name, + source.USER_ID, + ), + ); + } + } } catch (error) { CoreDomUtils.showErrorModalDefault(error, 'Error getting competency data.'); } diff --git a/src/addons/competency/pages/competencysummary/competencysummary.page.ts b/src/addons/competency/pages/competencysummary/competencysummary.page.ts index b490c4ed4..b6fe06664 100644 --- a/src/addons/competency/pages/competencysummary/competencysummary.page.ts +++ b/src/addons/competency/pages/competencysummary/competencysummary.page.ts @@ -36,6 +36,8 @@ export class AddonCompetencyCompetencySummaryPage implements OnInit { contextLevel?: ContextLevel; contextInstanceId?: number; + protected fetchSuccess = false; // Whether a fetch was finished successfully. + /** * @inheritdoc */ @@ -54,9 +56,6 @@ export class AddonCompetencyCompetencySummaryPage implements OnInit { try { await this.fetchCompetency(); - const name = this.competency!.competency && this.competency!.competency.shortname; - - CoreUtils.ignoreErrors(AddonCompetency.logCompetencyView(this.competencyId, name)); } finally { this.competencyLoaded = true; } @@ -73,10 +72,15 @@ export class AddonCompetencyCompetencySummaryPage implements OnInit { if (!this.contextLevel || this.contextInstanceId === undefined) { // Context not specified, use user context. this.contextLevel = ContextLevel.USER; - this.contextInstanceId = result.usercompetency!.userid; + this.contextInstanceId = result.usercompetency?.userid; } this.competency = result.competency; + + if (!this.fetchSuccess) { + this.fetchSuccess = true; + CoreUtils.ignoreErrors(AddonCompetency.logCompetencyView(this.competencyId, this.competency.competency.shortname)); + } } catch (error) { CoreDomUtils.showErrorModalDefault(error, 'Error getting competency summary data.'); } diff --git a/src/addons/mod/assign/components/index/index.ts b/src/addons/mod/assign/components/index/index.ts index 226d5e28a..b2ae03e97 100644 --- a/src/addons/mod/assign/components/index/index.ts +++ b/src/addons/mod/assign/components/index/index.ts @@ -17,7 +17,6 @@ import { Params } from '@angular/router'; import { CoreSite } from '@classes/site'; import { CoreCourseModuleMainActivityComponent } from '@features/course/classes/main-activity-component'; import { CoreCourseContentsPage } from '@features/course/pages/contents/contents'; -import { CoreCourse } from '@features/course/services/course'; import { IonContent } from '@ionic/angular'; import { CoreGroupInfo, CoreGroups } from '@services/groups'; import { CoreNavigator } from '@services/navigator'; @@ -121,7 +120,7 @@ export class AddonModAssignIndexComponent extends CoreCourseModuleMainActivityCo (data) => { if (this.assign && data.assignmentId == this.assign.id && data.userId == this.currentUserId) { // Assignment submitted, check completion. - CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); + this.checkCompletion(); // Reload data since it can have offline data now. this.showLoadingAndRefresh(true, false); @@ -138,25 +137,6 @@ export class AddonModAssignIndexComponent extends CoreCourseModuleMainActivityCo }, this.siteId); await this.loadContent(false, true); - - if (!this.assign) { - return; - } - - try { - await AddonModAssign.logView(this.assign.id, this.assign.name); - CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); - } catch { - // Ignore errors. Just don't check Module completion. - } - - if (this.canViewAllSubmissions) { - // User can see all submissions, log grading view. - CoreUtils.ignoreErrors(AddonModAssign.logGradingView(this.assign.id, this.assign.name)); - } else if (this.canViewOwnSubmission) { - // User can only see their own submission, log view the user submission. - CoreUtils.ignoreErrors(AddonModAssign.logSubmissionView(this.assign.id, this.assign.name)); - } } /** @@ -232,6 +212,25 @@ export class AddonModAssignIndexComponent extends CoreCourseModuleMainActivityCo } } + /** + * @inheritdoc + */ + protected async logActivity(): Promise { + if (!this.assign) { + return; // Shouldn't happen. + } + + await AddonModAssign.logView(this.assign.id, this.assign.name); + + if (this.canViewAllSubmissions) { + // User can see all submissions, log grading view. + CoreUtils.ignoreErrors(AddonModAssign.logGradingView(this.assign.id, this.assign.name)); + } else if (this.canViewOwnSubmission) { + // User can only see their own submission, log view the user submission. + CoreUtils.ignoreErrors(AddonModAssign.logSubmissionView(this.assign.id, this.assign.name)); + } + } + /** * Set group to see the summary. * diff --git a/src/addons/mod/bigbluebuttonbn/components/index/index.ts b/src/addons/mod/bigbluebuttonbn/components/index/index.ts index d8b639b95..33a5fe693 100644 --- a/src/addons/mod/bigbluebuttonbn/components/index/index.ts +++ b/src/addons/mod/bigbluebuttonbn/components/index/index.ts @@ -15,7 +15,6 @@ import { Component, OnInit, Optional } from '@angular/core'; import { CoreCourseModuleMainActivityComponent } from '@features/course/classes/main-activity-component'; import { CoreCourseContentsPage } from '@features/course/pages/contents/contents'; -import { CoreCourse } from '@features/course/services/course'; import { IonContent } from '@ionic/angular'; import { CoreGroupInfo, CoreGroups } from '@services/groups'; import { CoreDomUtils } from '@services/utils/dom'; @@ -54,18 +53,6 @@ export class AddonModBBBIndexComponent extends CoreCourseModuleMainActivityCompo super.ngOnInit(); await this.loadContent(); - - if (!this.bbb) { - return; - } - - try { - await AddonModBBB.logView(this.bbb.id, this.bbb.name); - - CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); - } catch { - // Ignore errors. - } } /** @@ -82,7 +69,6 @@ export class AddonModBBBIndexComponent extends CoreCourseModuleMainActivityCompo this.groupId = CoreGroups.validateGroupId(this.groupId, this.groupInfo); await this.fetchMeetingInfo(); - } /** @@ -107,6 +93,17 @@ export class AddonModBBBIndexComponent extends CoreCourseModuleMainActivityCompo } } + /** + * @inheritdoc + */ + protected async logActivity(): Promise { + if (!this.bbb) { + return; // Shouldn't happen. + } + + await AddonModBBB.logView(this.bbb.id, this.bbb.name); + } + /** * Update meeting info. * diff --git a/src/addons/mod/book/components/index/index.ts b/src/addons/mod/book/components/index/index.ts index e711670ba..82747aa77 100644 --- a/src/addons/mod/book/components/index/index.ts +++ b/src/addons/mod/book/components/index/index.ts @@ -18,7 +18,6 @@ import { AddonModBook, AddonModBookBookWSData, AddonModBookNumbering, AddonModBo import { CoreCourseContentsPage } from '@features/course/pages/contents/contents'; import { CoreCourse } from '@features/course/services/course'; import { CoreNavigator } from '@services/navigator'; -import { CoreUtils } from '@services/utils/utils'; /** * Component that displays a book entry page. @@ -36,6 +35,7 @@ export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComp hasStartedBook = false; protected book?: AddonModBookBookWSData; + protected checkCompletionAfterLog = false; constructor( @Optional() courseContentsPage?: CoreCourseContentsPage) { super('AddonModBookIndexComponent', courseContentsPage); @@ -48,9 +48,6 @@ export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComp super.ngOnInit(); this.loadContent(); - - // Log book viewed. - await CoreUtils.ignoreErrors(AddonModBook.logView(this.module.instance, undefined, this.module.name)); } /** @@ -93,6 +90,13 @@ export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComp this.chapters = AddonModBook.getTocList(contents); } + /** + * @inheritdoc + */ + protected async logActivity(): Promise { + AddonModBook.logView(this.module.instance, undefined, this.module.name); + } + /** * Open the book in a certain chapter. * diff --git a/src/addons/mod/book/services/book.ts b/src/addons/mod/book/services/book.ts index 16cdd0360..2c62c96b9 100644 --- a/src/addons/mod/book/services/book.ts +++ b/src/addons/mod/book/services/book.ts @@ -391,7 +391,7 @@ export class AddonModBookProvider { async storeLastChapterViewed(id: number, chapterId: number, courseId: number, siteId?: string): Promise { const site = await CoreSites.getSite(siteId); - await site.storeLastViewed(AddonModBookProvider.COMPONENT, id, chapterId, String(courseId)); + await site.storeLastViewed(AddonModBookProvider.COMPONENT, id, chapterId, { data: String(courseId) }); } } diff --git a/src/addons/mod/chat/components/index/index.ts b/src/addons/mod/chat/components/index/index.ts index 159f4e462..813c08cff 100644 --- a/src/addons/mod/chat/components/index/index.ts +++ b/src/addons/mod/chat/components/index/index.ts @@ -15,7 +15,6 @@ import { Component, OnInit, Optional } from '@angular/core'; import { CoreCourseModuleMainActivityComponent } from '@features/course/classes/main-activity-component'; import { CoreCourseContentsPage } from '@features/course/pages/contents/contents'; -import { CoreCourse } from '@features/course/services/course'; import { IonContent } from '@ionic/angular'; import { CoreNavigator } from '@services/navigator'; import { CoreTimeUtils } from '@services/utils/time'; @@ -53,18 +52,6 @@ export class AddonModChatIndexComponent extends CoreCourseModuleMainActivityComp super.ngOnInit(); await this.loadContent(); - - if (!this.chat) { - return; - } - - try { - await AddonModChat.logView(this.chat.id, this.chat.name); - - CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); - } catch { - // Ignore errors. - } } /** @@ -89,6 +76,17 @@ export class AddonModChatIndexComponent extends CoreCourseModuleMainActivityComp this.dataRetrieved.emit(this.chat); } + /** + * @inheritdoc + */ + protected async logActivity(): Promise { + if (!this.chat) { + return; // Shouldn't happen. + } + + await AddonModChat.logView(this.chat.id, this.chat.name); + } + /** * Enter the chat. */ diff --git a/src/addons/mod/choice/components/index/index.ts b/src/addons/mod/choice/components/index/index.ts index a43fc8c58..aaa489e82 100644 --- a/src/addons/mod/choice/components/index/index.ts +++ b/src/addons/mod/choice/components/index/index.ts @@ -15,7 +15,6 @@ import { Component, Optional, OnInit } from '@angular/core'; import { CoreCourseModuleMainActivityComponent } from '@features/course/classes/main-activity-component'; import { CoreCourseContentsPage } from '@features/course/pages/contents/contents'; -import { CoreCourse } from '@features/course/services/course'; import { IonContent } from '@ionic/angular'; import { CoreSites } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; @@ -86,18 +85,6 @@ export class AddonModChoiceIndexComponent extends CoreCourseModuleMainActivityCo this.userId = CoreSites.getCurrentSiteUserId(); await this.loadContent(false, true); - - if (!this.choice) { - return; - } - - try { - await AddonModChoice.logView(this.choice.id, this.choice.name); - - await CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); - } catch { - // Ignore errors. - } } /** @@ -325,6 +312,17 @@ export class AddonModChoiceIndexComponent extends CoreCourseModuleMainActivityCo this.canSeeResults = hasVotes || AddonModChoice.canStudentSeeResults(choice, this.hasAnsweredOnline); } + /** + * @inheritdoc + */ + protected async logActivity(): Promise { + if (!this.choice) { + return; // Shouldn't happen. + } + + await AddonModChoice.logView(this.choice.id, this.choice.name); + } + /** * Check if a choice is open. * @@ -384,7 +382,7 @@ export class AddonModChoiceIndexComponent extends CoreCourseModuleMainActivityCo if (online) { CoreEvents.trigger(CoreEvents.ACTIVITY_DATA_SENT, { module: this.moduleName }); // Check completion since it could be configured to complete once the user answers the choice. - CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); + this.checkCompletion(); } await this.dataUpdated(online); diff --git a/src/addons/mod/data/components/index/index.ts b/src/addons/mod/data/components/index/index.ts index 2c367fa48..164be2986 100644 --- a/src/addons/mod/data/components/index/index.ts +++ b/src/addons/mod/data/components/index/index.ts @@ -18,7 +18,6 @@ import { Params } from '@angular/router'; import { CoreCommentsProvider } from '@features/comments/services/comments'; import { CoreCourseModuleMainActivityComponent } from '@features/course/classes/main-activity-component'; import { CoreCourseContentsPage } from '@features/course/pages/contents/contents'; -import { CoreCourse } from '@features/course/services/course'; import { CoreRatingProvider } from '@features/rating/services/rating'; import { CoreRatingSyncProvider } from '@features/rating/services/rating-sync'; import { IonContent } from '@ionic/angular'; @@ -154,7 +153,6 @@ export class AddonModDataIndexComponent extends CoreCourseModuleMainActivityComp }); await this.loadContent(false, true); - await this.logView(true); } /** @@ -410,7 +408,7 @@ export class AddonModDataIndexComponent extends CoreCourseModuleMainActivityComp try { await this.fetchEntriesData(); // Log activity view for coherence with Moodle web. - await this.logView(); + await this.logActivity(); } catch (error) { CoreDomUtils.showErrorModalDefault(error, 'core.course.errorgetmodule', true); } finally { @@ -456,7 +454,7 @@ export class AddonModDataIndexComponent extends CoreCourseModuleMainActivityComp await this.fetchEntriesData(); // Log activity view for coherence with Moodle web. - return this.logView(); + return this.logActivity(); } catch (error) { CoreDomUtils.showErrorModalDefault(error, 'core.course.errorgetmodule', true); } @@ -522,24 +520,14 @@ export class AddonModDataIndexComponent extends CoreCourseModuleMainActivityComp } /** - * Log viewing the activity. - * - * @param checkCompletion Whether to check completion. - * @return Promise resolved when done. + * @inheritdoc */ - protected async logView(checkCompletion = false): Promise { + protected async logActivity(): Promise { if (!this.database || !this.database.id) { return; } - try { - await AddonModData.logView(this.database.id, this.database.name); - if (checkCompletion) { - CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); - } - } catch { - // Ignore errors, the user could be offline. - } + await AddonModData.logView(this.database.id, this.database.name); } /** diff --git a/src/addons/mod/data/pages/entry/entry.ts b/src/addons/mod/data/pages/entry/entry.ts index 173242ed4..648726a62 100644 --- a/src/addons/mod/data/pages/entry/entry.ts +++ b/src/addons/mod/data/pages/entry/entry.ts @@ -55,6 +55,7 @@ export class AddonModDataEntryPage implements OnInit, OnDestroy { protected entryChangedObserver: CoreEventObserver; // It will observe the changed entry event. protected fields: Record = {}; protected fieldsArray: AddonModDataField[] = []; + protected logAfterFetch = true; moduleId = 0; courseId!: number; @@ -149,7 +150,6 @@ export class AddonModDataEntryPage implements OnInit, OnDestroy { this.commentsEnabled = !CoreComments.areCommentsDisabledInSite(); await this.fetchEntryData(); - this.logView(); } /** @@ -201,6 +201,14 @@ export class AddonModDataEntryPage implements OnInit, OnDestroy { title: this.title, group: this.selectedGroup, }; + + if (this.logAfterFetch) { + this.logAfterFetch = false; + await CoreUtils.ignoreErrors(AddonModData.logView(this.database.id, this.database.name)); + + // Store module viewed. It's done in this page because it can be reached using a link. + CoreCourse.storeModuleViewed(this.courseId, this.moduleId); + } } catch (error) { if (!refresh) { // Some call failed, retry without using cache since it might be a new activity. @@ -225,9 +233,9 @@ export class AddonModDataEntryPage implements OnInit, OnDestroy { this.entryId = undefined; this.entry = undefined; this.entryLoaded = false; + this.logAfterFetch = true; await this.fetchEntryData(); - this.logView(); } /** @@ -286,9 +294,9 @@ export class AddonModDataEntryPage implements OnInit, OnDestroy { this.entry = undefined; this.entryId = undefined; this.entryLoaded = false; + this.logAfterFetch = true; await this.fetchEntryData(); - this.logView(); } /** @@ -397,19 +405,6 @@ export class AddonModDataEntryPage implements OnInit, OnDestroy { AddonModData.invalidateEntryData(this.database!.id, this.entryId!); } - /** - * Log viewing the activity. - * - * @return Promise resolved when done. - */ - protected async logView(): Promise { - if (!this.database || !this.database.id) { - return; - } - - await CoreUtils.ignoreErrors(AddonModData.logView(this.database.id, this.database.name)); - } - /** * Component being destroyed. */ diff --git a/src/addons/mod/feedback/components/index/index.ts b/src/addons/mod/feedback/components/index/index.ts index e31b5f529..cb20702f5 100644 --- a/src/addons/mod/feedback/components/index/index.ts +++ b/src/addons/mod/feedback/components/index/index.ts @@ -82,6 +82,7 @@ export class AddonModFeedbackIndexComponent extends CoreCourseModuleMainActivity protected submitObserver: CoreEventObserver; protected syncEventName = AddonModFeedbackSyncProvider.AUTO_SYNCED; + protected checkCompletionAfterLog = false; constructor( protected content?: IonContent, @@ -125,15 +126,22 @@ export class AddonModFeedbackIndexComponent extends CoreCourseModuleMainActivity try { await this.loadContent(false, true); - - if (this.feedback) { - CoreUtils.ignoreErrors(AddonModFeedback.logView(this.feedback.id, this.feedback.name)); - } } finally { this.tabsReady = true; } } + /** + * @inheritdoc + */ + protected async logActivity(): Promise { + if (!this.feedback) { + return; // Shouldn't happen. + } + + await AddonModFeedback.logView(this.feedback.id, this.feedback.name); + } + /** * @inheritdoc */ diff --git a/src/addons/mod/feedback/pages/attempt/attempt.ts b/src/addons/mod/feedback/pages/attempt/attempt.ts index 35733c4ed..7ee5e6028 100644 --- a/src/addons/mod/feedback/pages/attempt/attempt.ts +++ b/src/addons/mod/feedback/pages/attempt/attempt.ts @@ -16,6 +16,7 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { ActivatedRouteSnapshot } from '@angular/router'; import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker'; import { CoreSwipeNavigationItemsManager } from '@classes/items-management/swipe-navigation-items-manager'; +import { CoreCourse } from '@features/course/services/course'; import { CoreNavigator } from '@services/navigator'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreTextUtils } from '@services/utils/text'; @@ -49,6 +50,7 @@ export class AddonModFeedbackAttemptPage implements OnInit, OnDestroy { loaded = false; protected attemptId: number; + protected fetchSuccess = false; constructor() { this.cmId = CoreNavigator.getRequiredRouteNumberParam('cmId'); @@ -131,6 +133,11 @@ export class AddonModFeedbackAttemptPage implements OnInit, OnDestroy { return attemptItem; }).filter((itemData) => itemData); // Filter items with errors. + if (!this.fetchSuccess) { + this.fetchSuccess = true; + // Store module viewed. It's done in this page because it can be reached using a link. + CoreCourse.storeModuleViewed(this.courseId, this.cmId); + } } catch (message) { // Some call failed on fetch, go back. CoreDomUtils.showErrorModalDefault(message, 'core.course.errorgetmodule', true); diff --git a/src/addons/mod/feedback/pages/attempts/attempts.ts b/src/addons/mod/feedback/pages/attempts/attempts.ts index 40fe0a21f..1da06886d 100644 --- a/src/addons/mod/feedback/pages/attempts/attempts.ts +++ b/src/addons/mod/feedback/pages/attempts/attempts.ts @@ -25,6 +25,7 @@ import { CoreDomUtils } from '@services/utils/dom'; import { CoreUtils } from '@services/utils/utils'; import { AddonModFeedbackAttemptItem, AddonModFeedbackAttemptsSource } from '../../classes/feedback-attempts-source'; import { AddonModFeedbackWSAnonAttempt, AddonModFeedbackWSAttempt } from '../../services/feedback'; +import { CoreCourse } from '@features/course/services/course'; /** * Page that displays feedback attempts. @@ -37,14 +38,14 @@ export class AddonModFeedbackAttemptsPage implements AfterViewInit, OnDestroy { @ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent; - promisedAttempts: CorePromisedValue>; + promisedAttempts: CorePromisedValue; fetchFailed = false; constructor(protected route: ActivatedRoute) { this.promisedAttempts = new CorePromisedValue(); } - get attempts(): CoreListItemsManager | null { + get attempts(): AddonModFeedbackAttemptsManager | null { return this.promisedAttempts.value; } @@ -95,7 +96,7 @@ export class AddonModFeedbackAttemptsPage implements AfterViewInit, OnDestroy { source.selectedGroup = CoreNavigator.getRouteNumberParam('group') || 0; - this.promisedAttempts.resolve(new CoreListItemsManager(source, this.route.component)); + this.promisedAttempts.resolve(new AddonModFeedbackAttemptsManager(source, this.route.component)); } catch (error) { CoreDomUtils.showErrorModal(error); @@ -181,3 +182,18 @@ export class AddonModFeedbackAttemptsPage implements AfterViewInit, OnDestroy { } } + +/** + * Attempts manager. + */ +class AddonModFeedbackAttemptsManager extends CoreListItemsManager { + + /** + * @inheritdoc + */ + protected async logActivity(): Promise { + // Store module viewed. It's done in this page because it can be reached using a link. + CoreCourse.storeModuleViewed(this.getSource().COURSE_ID, this.getSource().CM_ID); + } + +} diff --git a/src/addons/mod/feedback/pages/nonrespondents/nonrespondents.ts b/src/addons/mod/feedback/pages/nonrespondents/nonrespondents.ts index db568a32a..59b4f7ca5 100644 --- a/src/addons/mod/feedback/pages/nonrespondents/nonrespondents.ts +++ b/src/addons/mod/feedback/pages/nonrespondents/nonrespondents.ts @@ -13,6 +13,7 @@ // limitations under the License. import { Component, OnInit } from '@angular/core'; +import { CoreCourse } from '@features/course/services/course'; import { IonRefresher } from '@ionic/angular'; import { CoreGroupInfo, CoreGroups } from '@services/groups'; import { CoreNavigator } from '@services/navigator'; @@ -34,6 +35,7 @@ export class AddonModFeedbackNonRespondentsPage implements OnInit { protected courseId!: number; protected feedback?: AddonModFeedbackWSFeedback; protected page = 0; + protected fetchSuccess = false; selectedGroup!: number; groupInfo?: CoreGroupInfo; @@ -81,6 +83,12 @@ export class AddonModFeedbackNonRespondentsPage implements OnInit { this.selectedGroup = CoreGroups.validateGroupId(this.selectedGroup, this.groupInfo); await this.loadGroupUsers(this.selectedGroup); + + if (!this.fetchSuccess) { + this.fetchSuccess = true; + // Store module viewed. It's done in this page because it can be reached using a link. + CoreCourse.storeModuleViewed(this.courseId, this.cmId); + } } catch (message) { CoreDomUtils.showErrorModalDefault(message, 'core.course.errorgetmodule', true); diff --git a/src/addons/mod/folder/components/index/index.ts b/src/addons/mod/folder/components/index/index.ts index fcfa675c7..8cd1f0504 100644 --- a/src/addons/mod/folder/components/index/index.ts +++ b/src/addons/mod/folder/components/index/index.ts @@ -62,13 +62,6 @@ export class AddonModFolderIndexComponent extends CoreCourseModuleMainResourceCo try { await this.loadContent(); - - try { - await AddonModFolder.logView(this.module.instance, this.module.name); - CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); - } catch { - // Ignore errors. - } } finally { this.showLoading = false; } @@ -97,6 +90,13 @@ export class AddonModFolderIndexComponent extends CoreCourseModuleMainResourceCo this.contents = AddonModFolderHelper.formatContents(contents); } + /** + * @inheritdoc + */ + protected async logActivity(): Promise { + await AddonModFolder.logView(this.module.instance, this.module.name); + } + /** * Navigate to a subfolder. * diff --git a/src/addons/mod/forum/components/index/index.ts b/src/addons/mod/forum/components/index/index.ts index 55ac9daaa..849453c42 100644 --- a/src/addons/mod/forum/components/index/index.ts +++ b/src/addons/mod/forum/components/index/index.ts @@ -99,6 +99,7 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom protected ratingOfflineObserver?: CoreEventObserver; protected ratingSyncObserver?: CoreEventObserver; protected sourceUnsubscribe?: () => void; + protected checkCompletionAfterLog = false; // Use CoreListItemsManager log system instead. constructor( public route: ActivatedRoute, @@ -570,7 +571,7 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom }); // Check completion since it could be configured to complete once the user adds a new discussion or replies. - CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); + this.checkCompletion(); } } @@ -692,15 +693,13 @@ class AddonModForumDiscussionsManager extends CoreListItemsManager { - CoreCourse.checkModuleCompletion(this.page.courseId, this.page.module.completiondata); + try { + await AddonModForum.instance.logView(forum.id, forum.name); - return; - }), - ); + CoreCourse.checkModuleCompletion(this.page.courseId, this.page.module.completiondata); + } catch { + // Ignore errors. + } } } diff --git a/src/addons/mod/forum/pages/discussion/discussion.page.ts b/src/addons/mod/forum/pages/discussion/discussion.page.ts index 12bf5c553..0f735704b 100644 --- a/src/addons/mod/forum/pages/discussion/discussion.page.ts +++ b/src/addons/mod/forum/pages/discussion/discussion.page.ts @@ -17,6 +17,7 @@ import { Component, OnDestroy, ViewChild, OnInit, AfterViewInit, ElementRef, Opt import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router'; import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker'; import { CoreSplitViewComponent } from '@components/split-view/split-view'; +import { CoreCourse } from '@features/course/services/course'; import { CoreFileUploader } from '@features/fileuploader/services/fileuploader'; import { CoreRatingInfo, CoreRatingProvider } from '@features/rating/services/rating'; import { CoreRatingOffline } from '@features/rating/services/rating-offline'; @@ -118,6 +119,7 @@ export class AddonModForumDiscussionPage implements OnInit, AfterViewInit, OnDes protected ratingOfflineObserver?: CoreEventObserver; protected ratingSyncObserver?: CoreEventObserver; protected changeDiscObserver?: CoreEventObserver; + protected fetchSuccess = false; constructor( @Optional() protected splitView: CoreSplitViewComponent, @@ -547,6 +549,12 @@ export class AddonModForumDiscussionPage implements OnInit, AfterViewInit, OnDes this.hasOfflineRatings = await CoreRatingOffline.hasRatings('mod_forum', 'post', ContextLevel.MODULE, this.cmId, this.discussionId); + + if (!this.fetchSuccess) { + this.fetchSuccess = true; + // Store module viewed. It's done in this page because it can be reached using a link. + this.courseId && this.cmId && CoreCourse.storeModuleViewed(this.courseId, this.cmId); + } } catch (error) { CoreDomUtils.showErrorModal(error); } finally { diff --git a/src/addons/mod/glossary/components/index/index.ts b/src/addons/mod/glossary/components/index/index.ts index 4e483a919..8c3bbd65a 100644 --- a/src/addons/mod/glossary/components/index/index.ts +++ b/src/addons/mod/glossary/components/index/index.ts @@ -81,12 +81,13 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity protected sourceUnsubscribe?: () => void; protected ratingOfflineObserver?: CoreEventObserver; protected ratingSyncObserver?: CoreEventObserver; + protected checkCompletionAfterLog = false; // Use CoreListItemsManager log system instead. getDivider?: (entry: AddonModGlossaryEntry) => string; showDivider: (entry: AddonModGlossaryEntry, previous?: AddonModGlossaryEntry) => boolean = () => false; constructor( - protected route: ActivatedRoute, + public route: ActivatedRoute, protected content?: IonContent, @Optional() protected courseContentsPage?: CoreCourseContentsPage, ) { @@ -124,10 +125,7 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity [this.courseId, this.module.id, this.courseContentsPage ? `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/` : ''], ); - this.promisedEntries.resolve(new AddonModGlossaryEntriesManager( - source, - this.route.component, - )); + this.promisedEntries.resolve(new AddonModGlossaryEntriesManager(source, this)); this.sourceUnsubscribe = source.addListener({ onItemsUpdated: items => this.hasOffline = !!items.find(item => source.isOfflineEntry(item)), @@ -139,7 +137,7 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity this.showLoadingAndRefresh(false); // Check completion since it could be configured to complete once the user adds a new entry. - CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); + this.checkCompletion(); } }); @@ -166,12 +164,6 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity await this.loadContent(false, true); await entries.start(this.splitView); - - try { - CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); - } catch (error) { - // Ignore errors. - } } /** @@ -437,6 +429,14 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity */ class AddonModGlossaryEntriesManager extends CoreListItemsManager { + page: AddonModGlossaryIndexComponent; + + constructor(source: AddonModGlossaryEntriesSource, page: AddonModGlossaryIndexComponent) { + super(source, page.route.component); + + this.page = page; + } + get offlineEntries(): AddonModGlossaryOfflineEntry[] { return this.getSource().offlineEntries; } @@ -463,7 +463,13 @@ class AddonModGlossaryEntriesManager extends CoreListItemsManager Promise; + protected checkCompletionAfterLog = false; // It's called later, when the user plays the package. constructor( protected content?: IonContent, @@ -390,7 +390,7 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv // Mark the activity as viewed. await AddonModH5PActivity.logView(this.h5pActivity.id, this.h5pActivity.name, this.siteId); - CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); + this.checkCompletion(); } /** @@ -464,7 +464,7 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv // Check if the H5P has ended. Final statements don't include a subContentId. const hasEnded = data.statements.some(statement => !statement.object.id.includes('subContentId=')); if (hasEnded) { - CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); + this.checkCompletion(); } } } catch (error) { diff --git a/src/addons/mod/h5pactivity/pages/attempt-results/attempt-results.ts b/src/addons/mod/h5pactivity/pages/attempt-results/attempt-results.ts index ad89f085f..458449191 100644 --- a/src/addons/mod/h5pactivity/pages/attempt-results/attempt-results.ts +++ b/src/addons/mod/h5pactivity/pages/attempt-results/attempt-results.ts @@ -25,6 +25,7 @@ import { AddonModH5PActivityData, AddonModH5PActivityAttemptResults, } from '../../services/h5pactivity'; +import { CoreCourse } from '@features/course/services/course'; /** * Page that displays results of an attempt. @@ -45,6 +46,7 @@ export class AddonModH5PActivityAttemptResultsPage implements OnInit { cmId!: number; protected attemptId!: number; + protected fetchSuccess = false; /** * @inheritdoc @@ -62,17 +64,7 @@ export class AddonModH5PActivityAttemptResultsPage implements OnInit { return; } - try { - await this.fetchData(); - - if (this.h5pActivity) { - await AddonModH5PActivity.logViewReport(this.h5pActivity.id, this.h5pActivity.name, { attemptId: this.attemptId }); - } - } catch (error) { - CoreDomUtils.showErrorModalDefault(error, 'Error loading attempt.'); - } finally { - this.loaded = true; - } + await this.fetchData(); } /** @@ -92,13 +84,31 @@ export class AddonModH5PActivityAttemptResultsPage implements OnInit { * @return Promise resolved when done. */ protected async fetchData(): Promise { - this.h5pActivity = await AddonModH5PActivity.getH5PActivity(this.courseId, this.cmId); + try { + this.h5pActivity = await AddonModH5PActivity.getH5PActivity(this.courseId, this.cmId); - this.attempt = await AddonModH5PActivity.getAttemptResults(this.h5pActivity.id, this.attemptId, { - cmId: this.cmId, - }); + this.attempt = await AddonModH5PActivity.getAttemptResults(this.h5pActivity.id, this.attemptId, { + cmId: this.cmId, + }); - await this.fetchUserProfile(); + await this.fetchUserProfile(); + + if (!this.fetchSuccess) { + this.fetchSuccess = true; + CoreUtils.ignoreErrors(AddonModH5PActivity.logViewReport( + this.h5pActivity.id, + this.h5pActivity.name, + { attemptId: this.attemptId }, + )); + + // Store module viewed. It's done in this page because it can be reached using a link. + CoreCourse.storeModuleViewed(this.courseId, this.cmId); + } + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'Error loading attempt.'); + } finally { + this.loaded = true; + } } /** diff --git a/src/addons/mod/h5pactivity/pages/user-attempts/user-attempts.ts b/src/addons/mod/h5pactivity/pages/user-attempts/user-attempts.ts index 0165feb15..a45b31b05 100644 --- a/src/addons/mod/h5pactivity/pages/user-attempts/user-attempts.ts +++ b/src/addons/mod/h5pactivity/pages/user-attempts/user-attempts.ts @@ -26,6 +26,7 @@ import { AddonModH5PActivityData, AddonModH5PActivityUserAttempts, } from '../../services/h5pactivity'; +import { CoreCourse } from '@features/course/services/course'; /** * Page that displays user attempts of a certain user. @@ -46,6 +47,7 @@ export class AddonModH5PActivityUserAttemptsPage implements OnInit { isCurrentUser = false; protected userId!: number; + protected fetchSuccess = false; /** * @inheritdoc @@ -65,17 +67,7 @@ export class AddonModH5PActivityUserAttemptsPage implements OnInit { this.isCurrentUser = this.userId == CoreSites.getCurrentSiteUserId(); - try { - await this.fetchData(); - - if (this.h5pActivity) { - await AddonModH5PActivity.logViewReport(this.h5pActivity.id, this.h5pActivity.name, { userId: this.userId }); - } - } catch (error) { - CoreDomUtils.showErrorModalDefault(error, 'Error loading attempts.'); - } finally { - this.loaded = true; - } + await this.fetchData(); } /** @@ -95,12 +87,30 @@ export class AddonModH5PActivityUserAttemptsPage implements OnInit { * @return Promise resolved when done. */ protected async fetchData(): Promise { - this.h5pActivity = await AddonModH5PActivity.getH5PActivity(this.courseId, this.cmId); + try { + this.h5pActivity = await AddonModH5PActivity.getH5PActivity(this.courseId, this.cmId); - await Promise.all([ - this.fetchAttempts(), - this.fetchUserProfile(), - ]); + await Promise.all([ + this.fetchAttempts(), + this.fetchUserProfile(), + ]); + + if (!this.fetchSuccess) { + this.fetchSuccess = true; + CoreUtils.ignoreErrors(AddonModH5PActivity.logViewReport( + this.h5pActivity.id, + this.h5pActivity.name, + { userId: this.userId }, + )); + + // Store module viewed. It's done in this page because it can be reached using a link. + CoreCourse.storeModuleViewed(this.courseId, this.cmId); + } + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'Error loading attempts.'); + } finally { + this.loaded = true; + } } /** diff --git a/src/addons/mod/h5pactivity/pages/users-attempts/users-attempts.ts b/src/addons/mod/h5pactivity/pages/users-attempts/users-attempts.ts index 0d45c30b5..2acb07e59 100644 --- a/src/addons/mod/h5pactivity/pages/users-attempts/users-attempts.ts +++ b/src/addons/mod/h5pactivity/pages/users-attempts/users-attempts.ts @@ -13,6 +13,7 @@ // limitations under the License. import { Component, OnInit } from '@angular/core'; +import { CoreCourse } from '@features/course/services/course'; import { CoreUser, CoreUserProfile } from '@features/user/services/user'; import { IonRefresher } from '@ionic/angular'; @@ -44,6 +45,7 @@ export class AddonModH5PActivityUsersAttemptsPage implements OnInit { canLoadMore = false; protected page = 0; + protected fetchSuccess = false; /** * @inheritdoc @@ -60,17 +62,7 @@ export class AddonModH5PActivityUsersAttemptsPage implements OnInit { return; } - try { - await this.fetchData(); - - if (this.h5pActivity) { - await AddonModH5PActivity.logViewReport(this.h5pActivity.id, this.h5pActivity.name); - } - } catch (error) { - CoreDomUtils.showErrorModalDefault(error, 'Error loading attempts.'); - } finally { - this.loaded = true; - } + this.fetchData(); } /** @@ -91,11 +83,25 @@ export class AddonModH5PActivityUsersAttemptsPage implements OnInit { * @return Promise resolved when done. */ protected async fetchData(refresh?: boolean): Promise { - this.h5pActivity = await AddonModH5PActivity.getH5PActivity(this.courseId, this.cmId); + try { + this.h5pActivity = await AddonModH5PActivity.getH5PActivity(this.courseId, this.cmId); - await Promise.all([ - this.fetchUsers(refresh), - ]); + await Promise.all([ + this.fetchUsers(refresh), + ]); + + if (!this.fetchSuccess) { + this.fetchSuccess = true; + CoreUtils.ignoreErrors(AddonModH5PActivity.logViewReport(this.h5pActivity.id, this.h5pActivity.name)); + + // Store module viewed. It's done in this page because it can be reached using a link. + CoreCourse.storeModuleViewed(this.courseId, this.cmId); + } + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'Error loading attempts.'); + } finally { + this.loaded = true; + } } /** diff --git a/src/addons/mod/h5pactivity/services/handlers/report-link.ts b/src/addons/mod/h5pactivity/services/handlers/report-link.ts index f2f37c19c..f3272d2bf 100644 --- a/src/addons/mod/h5pactivity/services/handlers/report-link.ts +++ b/src/addons/mod/h5pactivity/services/handlers/report-link.ts @@ -20,6 +20,7 @@ import { CoreCourse } from '@features/course/services/course'; import { CoreNavigator } from '@services/navigator'; import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUtils } from '@services/utils/utils'; import { makeSingleton } from '@singletons'; import { AddonModH5PActivity } from '../h5pactivity'; import { AddonModH5PActivityModuleHandlerService } from './module'; @@ -60,7 +61,7 @@ export class AddonModH5PActivityReportLinkHandlerService extends CoreContentLink } else { const userId = params.userid ? Number(params.userid) : undefined; - this.openUserAttempts(module.id, module.course, siteId, userId); + await this.openUserAttempts(module.id, module.course, instanceId, siteId, userId); } } catch (error) { CoreDomUtils.showErrorModalDefault(error, 'Error processing link.'); @@ -100,13 +101,35 @@ export class AddonModH5PActivityReportLinkHandlerService extends CoreContentLink * * @param cmId Module ID. * @param courseId Course ID. + * @param id Instance ID. * @param siteId Site ID. * @param userId User ID. If not defined, current user in site. * @return Promise resolved when done. */ - protected openUserAttempts(cmId: number, courseId: number, siteId: string, userId?: number): void { - userId = userId || CoreSites.getCurrentSiteUserId(); - const path = AddonModH5PActivityModuleHandlerService.PAGE_NAME + `/${courseId}/${cmId}/userattempts/${userId}`; + protected async openUserAttempts(cmId: number, courseId: number, id: number, siteId: string, userId?: number): Promise { + let canViewAllAttempts = false; + + if (!userId) { + // No user ID specified. Check if current user can view all attempts. + userId = CoreSites.getCurrentSiteUserId(); + canViewAllAttempts = await AddonModH5PActivity.canGetUsersAttempts(siteId); + + if (canViewAllAttempts) { + const accessInfo = await CoreUtils.ignoreErrors(AddonModH5PActivity.getAccessInformation(id, { + cmId, + siteId, + })); + + canViewAllAttempts = !!accessInfo?.canreviewattempts; + } + } + + let path: string; + if (canViewAllAttempts) { + path = `${AddonModH5PActivityModuleHandlerService.PAGE_NAME}/${courseId}/${cmId}/users`; + } else { + path = `${AddonModH5PActivityModuleHandlerService.PAGE_NAME}/${courseId}/${cmId}/userattempts/${userId}`; + } CoreNavigator.navigateToSitePath(path, { siteId, diff --git a/src/addons/mod/imscp/components/index/index.ts b/src/addons/mod/imscp/components/index/index.ts index 6d6e501b4..97eff0801 100644 --- a/src/addons/mod/imscp/components/index/index.ts +++ b/src/addons/mod/imscp/components/index/index.ts @@ -45,13 +45,6 @@ export class AddonModImscpIndexComponent extends CoreCourseModuleMainResourceCom super.ngOnInit(); await this.loadContent(); - - try { - await AddonModImscp.logView(this.module.instance, this.module.name); - CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); - } catch { - // Ignore errors. - } } /** @@ -103,6 +96,13 @@ export class AddonModImscpIndexComponent extends CoreCourseModuleMainResourceCom this.items = AddonModImscp.createItemList(contents); } + /** + * @inheritdoc + */ + protected async logActivity(): Promise { + await AddonModImscp.logView(this.module.instance, this.module.name); + } + /** * Open IMSCP book with a certain item. * diff --git a/src/addons/mod/imscp/services/imscp.ts b/src/addons/mod/imscp/services/imscp.ts index abfcd0d87..226dcd7c5 100644 --- a/src/addons/mod/imscp/services/imscp.ts +++ b/src/addons/mod/imscp/services/imscp.ts @@ -303,7 +303,7 @@ export class AddonModImscpProvider { async storeLastItemViewed(id: number, href: string, courseId: number, siteId?: string): Promise { const site = await CoreSites.getSite(siteId); - await site.storeLastViewed(AddonModImscpProvider.COMPONENT, id, href, String(courseId)); + await site.storeLastViewed(AddonModImscpProvider.COMPONENT, id, href, { data: String(courseId) }); } } diff --git a/src/addons/mod/lesson/components/index/index.ts b/src/addons/mod/lesson/components/index/index.ts index 415bf0555..7b41a3d8b 100644 --- a/src/addons/mod/lesson/components/index/index.ts +++ b/src/addons/mod/lesson/components/index/index.ts @@ -18,7 +18,6 @@ import { Component, Input, ViewChild, ElementRef, OnInit, OnDestroy, Optional } import { CoreTabsComponent } from '@components/tabs/tabs'; import { CoreCourseModuleMainActivityComponent } from '@features/course/classes/main-activity-component'; import { CoreCourseContentsPage } from '@features/course/pages/contents/contents'; -import { CoreCourse } from '@features/course/services/course'; import { CoreUser } from '@features/user/services/user'; import { IonContent, IonInput } from '@ionic/angular'; import { CoreGroupInfo, CoreGroups } from '@services/groups'; @@ -108,12 +107,6 @@ export class AddonModLessonIndexComponent extends CoreCourseModuleMainActivityCo this.selectedTab = this.action == 'report' ? 1 : 0; await this.loadContent(false, true); - - if (!this.lesson || this.preventReasons.length) { - return; - } - - this.logView(); } /** @@ -285,7 +278,7 @@ export class AddonModLessonIndexComponent extends CoreCourseModuleMainActivityCo protected hasSyncSucceed(result: AddonModLessonSyncResult): boolean { if (result.updated || this.dataSent) { // Check completion status if something was sent. - CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); + this.checkCompletion(); } this.dataSent = false; @@ -373,20 +366,14 @@ export class AddonModLessonIndexComponent extends CoreCourseModuleMainActivityCo } /** - * Log viewing the lesson. - * - * @return Promise resolved when done. + * @inheritdoc */ - protected async logView(): Promise { - if (!this.lesson) { + protected async logActivity(): Promise { + if (!this.lesson || this.preventReasons.length) { return; } - await CoreUtils.ignoreErrors( - AddonModLesson.logViewLesson(this.lesson.id, this.password, this.lesson.name), - ); - - CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); + await AddonModLesson.logViewLesson(this.lesson.id, this.password, this.lesson.name); } /** @@ -631,7 +618,7 @@ export class AddonModLessonIndexComponent extends CoreCourseModuleMainActivityCo this.preventReasons = preventReason ? [preventReason] : []; // Log view now that we have the password. - this.logView(); + this.logActivity(); } catch (error) { CoreDomUtils.showErrorModal(error); } finally { diff --git a/src/addons/mod/lesson/pages/user-retake/user-retake.page.ts b/src/addons/mod/lesson/pages/user-retake/user-retake.page.ts index 634e08740..996bb5235 100644 --- a/src/addons/mod/lesson/pages/user-retake/user-retake.page.ts +++ b/src/addons/mod/lesson/pages/user-retake/user-retake.page.ts @@ -35,6 +35,7 @@ import { } from '../../services/lesson'; import { AddonModLessonAnswerData, AddonModLessonHelper } from '../../services/lesson-helper'; import { CoreTimeUtils } from '@services/utils/time'; +import { CoreCourse } from '@features/course/services/course'; /** * Page that displays a retake made by a certain user. @@ -59,6 +60,7 @@ export class AddonModLessonUserRetakePage implements OnInit { protected userId?: number; // User ID to see the retakes. protected retakeNumber?: number; // Number of the initial retake to see. protected previousSelectedRetake?: number; // To be able to detect the previous selected retake when it has changed. + protected fetchSuccess = false; /** * Component being initialized. @@ -160,6 +162,12 @@ export class AddonModLessonUserRetakePage implements OnInit { this.student.profileimageurl = user?.profileimageurl; await this.setRetake(this.selectedRetake); + + if (!this.fetchSuccess) { + this.fetchSuccess = true; + // Store module viewed. It's done in this page because it can be reached using a link. + CoreCourse.storeModuleViewed(this.courseId, this.cmId); + } } catch (error) { CoreDomUtils.showErrorModalDefault(error, 'Error getting data.', true); } diff --git a/src/addons/mod/lti/services/handlers/module.ts b/src/addons/mod/lti/services/handlers/module.ts index 7e80a8f53..953e4264f 100644 --- a/src/addons/mod/lti/services/handlers/module.ts +++ b/src/addons/mod/lti/services/handlers/module.ts @@ -21,6 +21,7 @@ import { makeSingleton } from '@singletons'; import { AddonModLtiHelper } from '../lti-helper'; import { AddonModLtiIndexComponent } from '../../components/index'; import { CoreModuleHandlerBase } from '@features/course/classes/module-base-handler'; +import { CoreCourse } from '@features/course/services/course'; /** * Handler to support LTI modules. @@ -67,6 +68,8 @@ export class AddonModLtiModuleHandlerService extends CoreModuleHandlerBase imple action: (event: Event, module: CoreCourseModuleData, courseId: number): void => { // Launch the LTI. AddonModLtiHelper.getDataAndLaunch(courseId, module); + + CoreCourse.storeModuleViewed(courseId, module.id); }, }]; diff --git a/src/addons/mod/page/components/index/index.ts b/src/addons/mod/page/components/index/index.ts index bbf8ac151..bbcac903a 100644 --- a/src/addons/mod/page/components/index/index.ts +++ b/src/addons/mod/page/components/index/index.ts @@ -52,13 +52,6 @@ export class AddonModPageIndexComponent extends CoreCourseModuleMainResourceComp super.ngOnInit(); await this.loadContent(); - - try { - await AddonModPage.logView(this.module.instance, this.module.name); - CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); - } catch { - // Ignore errors. - } } /** @@ -118,4 +111,11 @@ export class AddonModPageIndexComponent extends CoreCourseModuleMainResourceComp this.timemodified = 'timemodified' in this.page ? this.page.timemodified : undefined; } + /** + * @inheritdoc + */ + protected async logActivity(): Promise { + await AddonModPage.logView(this.module.instance, this.module.name); + } + } diff --git a/src/addons/mod/quiz/components/index/index.ts b/src/addons/mod/quiz/components/index/index.ts index d827022de..bae07b503 100644 --- a/src/addons/mod/quiz/components/index/index.ts +++ b/src/addons/mod/quiz/components/index/index.ts @@ -17,7 +17,6 @@ import { Component, OnDestroy, OnInit, Optional } from '@angular/core'; import { CoreCourseModuleMainActivityComponent } from '@features/course/classes/main-activity-component'; import { CoreCourseContentsPage } from '@features/course/pages/contents/contents'; -import { CoreCourse } from '@features/course/services/course'; import { CoreQuestionBehaviourDelegate } from '@features/question/services/behaviour-delegate'; import { IonContent } from '@ionic/angular'; import { CoreNavigator } from '@services/navigator'; @@ -121,18 +120,6 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp ); await this.loadContent(false, true); - - if (!this.quiz) { - return; - } - - try { - await AddonModQuiz.logViewQuiz(this.quiz.id, this.quiz.name); - - CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); - } catch { - // Ignore errors. - } } /** @@ -387,6 +374,17 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp } } + /** + * @inheritdoc + */ + protected async logActivity(): Promise { + if (!this.quiz) { + return; // Shouldn't happen. + } + + await AddonModQuiz.logViewQuiz(this.quiz.id, this.quiz.name); + } + /** * Go to review an attempt that has just been finished. * @@ -398,7 +396,7 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp } // If we go to auto review it means an attempt was finished. Check completion status. - CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); + this.checkCompletion(); // Verify that user can see the review. const attemptId = this.autoReview.attemptId; @@ -425,7 +423,7 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp protected hasSyncSucceed(result: AddonModQuizSyncResult): boolean { if (result.attemptFinished) { // An attempt was finished, check completion status. - CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); + this.checkCompletion(); } // If the sync call isn't rejected it means the sync was successful. @@ -508,7 +506,7 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp if (syncEventData.attemptFinished) { // An attempt was finished, check completion status. - CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); + this.checkCompletion(); } if (this.quiz && syncEventData.quizId == this.quiz.id) { diff --git a/src/addons/mod/quiz/pages/review/review.page.ts b/src/addons/mod/quiz/pages/review/review.page.ts index 04b7bb7a7..478d234b8 100644 --- a/src/addons/mod/quiz/pages/review/review.page.ts +++ b/src/addons/mod/quiz/pages/review/review.page.ts @@ -13,6 +13,7 @@ // limitations under the License. import { Component, ElementRef, OnInit, ViewChild } from '@angular/core'; +import { CoreCourse } from '@features/course/services/course'; import { CoreQuestionQuestionParsed } from '@features/question/services/question'; import { CoreQuestionHelper } from '@features/question/services/question-helper'; import { IonContent, IonRefresher } from '@ionic/angular'; @@ -73,6 +74,7 @@ export class AddonModQuizReviewPage implements OnInit { protected attemptId!: number; // The attempt being reviewed. protected currentPage!: number; // The current page being reviewed. protected options?: AddonModQuizCombinedReviewOptions; // Review options. + protected fetchSuccess = false; constructor( protected elementRef: ElementRef, @@ -99,10 +101,6 @@ export class AddonModQuizReviewPage implements OnInit { try { await this.fetchData(); - - CoreUtils.ignoreErrors( - AddonModQuiz.logViewAttemptReview(this.attemptId, this.quiz!.id, this.quiz!.name), - ); } finally { this.loaded = true; } @@ -160,6 +158,16 @@ export class AddonModQuizReviewPage implements OnInit { // Load questions. await this.loadPage(this.currentPage); + + if (!this.fetchSuccess) { + this.fetchSuccess = true; + CoreUtils.ignoreErrors( + AddonModQuiz.logViewAttemptReview(this.attemptId, this.quiz.id, this.quiz.name), + ); + + // Store module viewed. It's done in this page because it can be reached using a link. + CoreCourse.storeModuleViewed(this.courseId, this.cmId); + } } catch (error) { CoreDomUtils.showErrorModalDefault(error, 'addon.mod_quiz.errorgetquiz', true); } diff --git a/src/addons/mod/resource/components/index/index.ts b/src/addons/mod/resource/components/index/index.ts index 6c26ec16e..6d6942ca2 100644 --- a/src/addons/mod/resource/components/index/index.ts +++ b/src/addons/mod/resource/components/index/index.ts @@ -90,12 +90,6 @@ export class AddonModResourceIndexComponent extends CoreCourseModuleMainResource }); await this.loadContent(); - try { - await AddonModResource.logView(this.module.instance, this.module.name); - CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); - } catch { - // Ignore errors. - } } /** @@ -189,6 +183,13 @@ export class AddonModResourceIndexComponent extends CoreCourseModuleMainResource } } + /** + * @inheritdoc + */ + protected async logActivity(): Promise { + await AddonModResource.logView(this.module.instance, this.module.name); + } + /** * Opens a file. * diff --git a/src/addons/mod/resource/services/handlers/module.ts b/src/addons/mod/resource/services/handlers/module.ts index 3304a1b8c..c6f7e9e82 100644 --- a/src/addons/mod/resource/services/handlers/module.ts +++ b/src/addons/mod/resource/services/handlers/module.ts @@ -90,6 +90,8 @@ export class AddonModResourceModuleHandlerService extends CoreModuleHandlerBase const hide = await this.hideOpenButton(module); if (!hide) { AddonModResourceHelper.openModuleFile(module, courseId); + + CoreCourse.storeModuleViewed(courseId, module.id); } }, }]; diff --git a/src/addons/mod/scorm/components/index/index.ts b/src/addons/mod/scorm/components/index/index.ts index fcfa22b41..ddc814819 100644 --- a/src/addons/mod/scorm/components/index/index.ts +++ b/src/addons/mod/scorm/components/index/index.ts @@ -16,7 +16,6 @@ import { CoreConstants } from '@/core/constants'; import { Component, OnInit, Optional } from '@angular/core'; import { CoreCourseModuleMainActivityComponent } from '@features/course/classes/main-activity-component'; import { CoreCourseContentsPage } from '@features/course/pages/contents/contents'; -import { CoreCourse } from '@features/course/services/course'; import { IonContent } from '@ionic/angular'; import { CoreNavigator } from '@services/navigator'; import { CoreSync } from '@services/sync'; @@ -114,21 +113,6 @@ export class AddonModScormIndexComponent extends CoreCourseModuleMainActivityCom if (this.skip) { this.open(); } - - try { - await AddonModScorm.logView(this.scorm.id, this.scorm.name); - - this.checkCompletion(); - } catch { - // Ignore errors. - } - } - - /** - * Check the completion. - */ - protected checkCompletion(): void { - CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); } /** @@ -357,6 +341,17 @@ export class AddonModScormIndexComponent extends CoreCourseModuleMainActivityCom this.gradeFormatted = AddonModScorm.formatGrade(scorm, this.grade); } + /** + * @inheritdoc + */ + protected async logActivity(): Promise { + if (!this.scorm) { + return; // Shouldn't happen. + } + + await AddonModScorm.logView(this.scorm.id, this.scorm.name); + } + /** * Checks if sync has succeed from result sync data. * diff --git a/src/addons/mod/survey/components/index/index.ts b/src/addons/mod/survey/components/index/index.ts index eca06619b..28845b771 100644 --- a/src/addons/mod/survey/components/index/index.ts +++ b/src/addons/mod/survey/components/index/index.ts @@ -16,7 +16,6 @@ import { Component, OnInit, Optional } from '@angular/core'; import { CoreIonLoadingElement } from '@classes/ion-loading'; import { CoreCourseModuleMainActivityComponent } from '@features/course/classes/main-activity-component'; import { CoreCourseContentsPage } from '@features/course/pages/contents/contents'; -import { CoreCourse } from '@features/course/services/course'; import { IonContent } from '@ionic/angular'; import { CoreSites } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; @@ -75,13 +74,6 @@ export class AddonModSurveyIndexComponent extends CoreCourseModuleMainActivityCo this.currentUserId = CoreSites.getCurrentSiteUserId(); await this.loadContent(false, true); - - try { - await AddonModSurvey.logView(this.survey!.id, this.survey!.name); - CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); - } catch { - // Ignore errors. Just don't check Module completion. - } } /** @@ -166,6 +158,17 @@ export class AddonModSurveyIndexComponent extends CoreCourseModuleMainActivityCo }); } + /** + * @inheritdoc + */ + protected async logActivity(): Promise { + if (!this.survey) { + return; // Shouldn't happen. + } + + await AddonModSurvey.logView(this.survey.id, this.survey.name); + } + /** * Check if answers are valid to be submitted. * diff --git a/src/addons/mod/url/components/index/index.ts b/src/addons/mod/url/components/index/index.ts index b79ee6610..9c253a334 100644 --- a/src/addons/mod/url/components/index/index.ts +++ b/src/addons/mod/url/components/index/index.ts @@ -47,6 +47,8 @@ export class AddonModUrlIndexComponent extends CoreCourseModuleMainResourceCompo mimetype?: string; displayDescription = true; + protected checkCompletionAfterLog = false; + constructor(@Optional() courseContentsPage?: CoreCourseContentsPage) { super('AddonModUrlIndexComponent', courseContentsPage); } @@ -58,12 +60,6 @@ export class AddonModUrlIndexComponent extends CoreCourseModuleMainResourceCompo super.ngOnInit(); await this.loadContent(); - - if ((this.shouldIframe || - (this.shouldEmbed && this.isOther)) || - (!this.shouldIframe && (!this.shouldEmbed || !this.isOther))) { - this.logView(); - } } /** @@ -170,12 +166,24 @@ export class AddonModUrlIndexComponent extends CoreCourseModuleMainResourceCompo protected async logView(): Promise { try { await AddonModUrl.logView(this.module.instance, this.module.name); - CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); + + this.checkCompletion(); } catch { // Ignore errors. } } + /** + * @inheritdoc + */ + protected async logActivity(): Promise { + if ((this.shouldIframe || + (this.shouldEmbed && this.isOther)) || + (!this.shouldIframe && (!this.shouldEmbed || !this.isOther))) { + this.logView(); + } + } + /** * Opens a file. */ diff --git a/src/addons/mod/url/services/handlers/module.ts b/src/addons/mod/url/services/handlers/module.ts index 8d7adc10e..b9978c872 100644 --- a/src/addons/mod/url/services/handlers/module.ts +++ b/src/addons/mod/url/services/handlers/module.ts @@ -63,7 +63,7 @@ export class AddonModUrlModuleHandlerService extends CoreModuleHandlerBase imple * @param module The module object. * @param courseId The course ID. */ - const openUrl = async (module: CoreCourseModuleData): Promise => { + const openUrl = async (module: CoreCourseModuleData, courseId: number): Promise => { try { if (module.instance) { await AddonModUrl.logView(module.instance, module.name); @@ -73,6 +73,8 @@ export class AddonModUrlModuleHandlerService extends CoreModuleHandlerBase imple // Ignore errors. } + CoreCourse.storeModuleViewed(courseId, module.id); + const contents = await CoreCourse.getModuleContents(module); AddonModUrlHelper.open(contents[0].fileurl); }; @@ -89,7 +91,7 @@ export class AddonModUrlModuleHandlerService extends CoreModuleHandlerBase imple const shouldOpen = await this.shouldOpenLink(module); if (shouldOpen) { - openUrl(module); + openUrl(module, courseId); } else { this.openActivityPage(module, module.course, options); } @@ -101,8 +103,8 @@ export class AddonModUrlModuleHandlerService extends CoreModuleHandlerBase imple hidden: true, // Hide it until we calculate if it should be displayed or not. icon: 'fas-link', label: 'core.openmodinbrowser', - action: (event: Event, module: CoreCourseModuleData): void => { - openUrl(module); + action: (event: Event, module: CoreCourseModuleData, courseId: number): void => { + openUrl(module, courseId); }, }], }; diff --git a/src/addons/mod/wiki/components/index/index.ts b/src/addons/mod/wiki/components/index/index.ts index a9b8780de..bbe537f99 100644 --- a/src/addons/mod/wiki/components/index/index.ts +++ b/src/addons/mod/wiki/components/index/index.ts @@ -148,24 +148,6 @@ export class AddonModWikiIndexComponent extends CoreCourseModuleMainActivityComp this.openMap(); } } - - if (!this.wiki) { - CoreNavigator.back(); - - return; - } - - if (!this.pageId) { - try { - await AddonModWiki.logView(this.wiki.id, this.wiki.name); - - CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); - } catch { - // Ignore errors. - } - } else { - CoreUtils.ignoreErrors(AddonModWiki.logPageView(this.pageId, this.wiki.id, this.wiki.name)); - } } /** @@ -449,6 +431,22 @@ export class AddonModWikiIndexComponent extends CoreCourseModuleMainActivityComp } } + /** + * @inheritdoc + */ + protected async logActivity(): Promise { + if (!this.wiki) { + return; // Shouldn't happen. + } + + if (!this.pageId) { + await AddonModWiki.logView(this.wiki.id, this.wiki.name); + } else { + this.checkCompletionAfterLog = false; + CoreUtils.ignoreErrors(AddonModWiki.logPageView(this.pageId, this.wiki.id, this.wiki.name)); + } + } + /** * Get path to the wiki home view. If cannot determine or it's current view, return undefined. * diff --git a/src/addons/mod/workshop/components/index/index.ts b/src/addons/mod/workshop/components/index/index.ts index 1376c7433..52808f0a9 100644 --- a/src/addons/mod/workshop/components/index/index.ts +++ b/src/addons/mod/workshop/components/index/index.ts @@ -16,7 +16,6 @@ import { Component, Input, OnDestroy, OnInit, Optional } from '@angular/core'; import { Params } from '@angular/router'; import { CoreCourseModuleMainActivityComponent } from '@features/course/classes/main-activity-component'; import { CoreCourseContentsPage } from '@features/course/pages/contents/contents'; -import { CoreCourse } from '@features/course/services/course'; import { IonContent } from '@ionic/angular'; import { CoreGroupInfo, CoreGroups } from '@services/groups'; import { CoreNavigator } from '@services/navigator'; @@ -138,16 +137,6 @@ export class AddonModWorkshopIndexComponent extends CoreCourseModuleMainActivity super.ngOnInit(); await this.loadContent(false, true); - if (!this.workshop) { - return; - } - - try { - await AddonModWorkshop.logView(this.workshop.id, this.workshop.name); - CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); - } catch (error) { - // Ignore errors. - } } /** @@ -164,7 +153,7 @@ export class AddonModWorkshopIndexComponent extends CoreCourseModuleMainActivity this.showLoadingAndRefresh(true); // Check completion since it could be configured to complete once the user adds a new discussion or replies. - CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); + this.checkCompletion(); } } @@ -257,6 +246,17 @@ export class AddonModWorkshopIndexComponent extends CoreCourseModuleMainActivity await this.setPhaseInfo(); } + /** + * @inheritdoc + */ + protected async logActivity(): Promise { + if (!this.workshop) { + return; // Shouldn't happen. + } + + await AddonModWorkshop.logView(this.workshop.id, this.workshop.name); + } + /** * Retrieves and shows submissions grade page. * diff --git a/src/addons/mod/workshop/pages/submission/submission.ts b/src/addons/mod/workshop/pages/submission/submission.ts index a131065af..e6c1fa6a2 100644 --- a/src/addons/mod/workshop/pages/submission/submission.ts +++ b/src/addons/mod/workshop/pages/submission/submission.ts @@ -102,6 +102,7 @@ export class AddonModWorkshopSubmissionPage implements OnInit, OnDestroy, CanLea protected obsAssessmentSaved: CoreEventObserver; protected syncObserver: CoreEventObserver; protected isDestroyed = false; + protected fetchSuccess = false; constructor( protected fb: FormBuilder, @@ -157,12 +158,7 @@ export class AddonModWorkshopSubmissionPage implements OnInit, OnDestroy, CanLea await this.fetchSubmissionData(); - try { - await AddonModWorkshop.logViewSubmission(this.submissionId, this.workshopId, this.workshop.name); - CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); - } catch { - // Ignore errors. - } + this.logView(); } /** @@ -447,6 +443,8 @@ export class AddonModWorkshopSubmissionPage implements OnInit, OnDestroy, CanLea CoreEvents.trigger(AddonModWorkshopProvider.ASSESSMENT_INVALIDATED, null, this.siteId); await this.fetchSubmissionData(); + + this.logView(); } } @@ -596,6 +594,24 @@ export class AddonModWorkshopSubmissionPage implements OnInit, OnDestroy, CanLea }); } + /** + * Log submission viewed. + */ + protected async logView(): Promise { + if (this.fetchSuccess) { + return; // Already done. + } + + this.fetchSuccess = true; + + try { + await AddonModWorkshop.logViewSubmission(this.submissionId, this.workshopId, this.workshop.name); + CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); + } catch { + // Ignore errors. + } + } + /** * Component being destroyed. */ diff --git a/src/addons/notes/pages/list/list.page.ts b/src/addons/notes/pages/list/list.page.ts index be375f2dd..64c0a373d 100644 --- a/src/addons/notes/pages/list/list.page.ts +++ b/src/addons/notes/pages/list/list.page.ts @@ -54,6 +54,7 @@ export class AddonNotesListPage implements OnInit, OnDestroy { currentUserId!: number; protected syncObserver!: CoreEventObserver; + protected logAfterFetch = true; constructor() { try { @@ -91,8 +92,6 @@ export class AddonNotesListPage implements OnInit, OnDestroy { */ async ngOnInit(): Promise { await this.fetchNotes(true); - - CoreUtils.ignoreErrors(AddonNotes.logView(this.courseId, this.userId)); } /** @@ -128,6 +127,11 @@ export class AddonNotesListPage implements OnInit, OnDestroy { } else { this.notes = await AddonNotes.getNotesUserData(notesList); } + + if (this.logAfterFetch) { + this.logAfterFetch = false; + CoreUtils.ignoreErrors(AddonNotes.logView(this.courseId, this.userId)); + } } catch (error) { CoreDomUtils.showErrorModal(error); } finally { @@ -172,9 +176,9 @@ export class AddonNotesListPage implements OnInit, OnDestroy { this.notesLoaded = false; this.refreshIcon = CoreConstants.ICON_LOADING; this.syncIcon = CoreConstants.ICON_LOADING; + this.logAfterFetch = true; await this.fetchNotes(true); - CoreUtils.ignoreErrors(AddonNotes.logView(this.courseId, this.userId)); } /** diff --git a/src/core/classes/items-management/list-items-manager.ts b/src/core/classes/items-management/list-items-manager.ts index 4fae782f3..aceac334a 100644 --- a/src/core/classes/items-management/list-items-manager.ts +++ b/src/core/classes/items-management/list-items-manager.ts @@ -34,6 +34,7 @@ export class CoreListItemsManager< protected pageRouteLocator?: unknown | ActivatedRoute; protected splitView?: CoreSplitViewComponent; protected splitViewOutletSubscription?: Subscription; + protected fetchSuccess = false; // Whether a fetch was finished successfully. constructor(source: Source, pageRouteLocator: unknown | ActivatedRoute) { super(source); @@ -71,9 +72,6 @@ export class CoreListItemsManager< // Calculate current selected item. this.updateSelectedItem(); - - // Log activity. - await CoreUtils.ignoreErrors(this.logActivity()); } /** @@ -145,6 +143,8 @@ export class CoreListItemsManager< */ async reload(): Promise { await this.getSource().reload(); + + this.finishSuccessfulFetch(); } /** @@ -152,6 +152,21 @@ export class CoreListItemsManager< */ async load(): Promise { await this.getSource().load(); + + this.finishSuccessfulFetch(); + } + + /** + * Finish a successful fetch. + */ + protected async finishSuccessfulFetch(): Promise { + if (this.fetchSuccess) { + return; // Already treated. + } + + // Log activity. + this.fetchSuccess = true; + await CoreUtils.ignoreErrors(this.logActivity()); } /** diff --git a/src/core/classes/site.ts b/src/core/classes/site.ts index eaefeb159..dc2644d60 100644 --- a/src/core/classes/site.ts +++ b/src/core/classes/site.ts @@ -160,7 +160,7 @@ export class CoreSite { this.lastViewedTable = asyncInstance(() => CoreSites.getSiteTable(CoreSite.LAST_VIEWED_TABLE, { siteId: this.getId(), database: this.getDb(), - config: { cachingStrategy: CoreDatabaseCachingStrategy.None }, + config: { cachingStrategy: CoreDatabaseCachingStrategy.Eager }, primaryKeyColumns: ['component', 'id'], })); this.setInfo(infos); @@ -2000,21 +2000,55 @@ export class CoreSite { } } + /** + * Get several last viewed for a certain component. + * + * @param component The component. + * @param ids IDs. If not provided or empty, return all last viewed for a component. + * @return Resolves with last viewed records, undefined if error. + */ + async getComponentLastViewed(component: string, ids: number[] = []): Promise { + try { + if (!ids.length) { + return await this.lastViewedTable.getMany({ component }); + } + + const whereAndParams = SQLiteDB.getInOrEqual(ids); + + whereAndParams.sql = 'id ' + whereAndParams.sql + ' AND component = ?'; + whereAndParams.params.push(component); + + return await this.lastViewedTable.getManyWhere({ + sql: whereAndParams.sql, + sqlParams: whereAndParams.params, + js: (record) => record.component === component && ids.includes(record.id), + }); + } catch (error) { + // Not found. + } + } + /** * Store a last viewed record. * * @param component The component. * @param id ID. * @param value Last viewed item value. - * @param data Other data. + * @param options Options. * @return Promise resolved when done. */ - async storeLastViewed(component: string, id: number, value: string | number, data?: string): Promise { + async storeLastViewed( + component: string, + id: number, + value: string | number, + options: CoreSiteStoreLastViewedOptions = {}, + ): Promise { await this.lastViewedTable.insert({ component, id, value: String(value), - data, + data: options.data, + timeaccess: options.timeaccess ?? Date.now(), }); } @@ -2349,5 +2383,14 @@ export type CoreSiteLastViewedDBRecord = { component: string; id: number; value: string; + timeaccess: number; data?: string; }; + +/** + * Options for storeLastViewed. + */ +export type CoreSiteStoreLastViewedOptions = { + data?: string; // Other data. + timeaccess?: number; // Accessed time. If not set, current time. +}; diff --git a/src/core/features/course/classes/main-activity-component.ts b/src/core/features/course/classes/main-activity-component.ts index e41b66bfb..adcd02751 100644 --- a/src/core/features/course/classes/main-activity-component.ts +++ b/src/core/features/course/classes/main-activity-component.ts @@ -169,6 +169,8 @@ export class CoreCourseModuleMainActivityComponent extends CoreCourseModuleMainR } await this.fetchContent(refresh, sync, showErrors); + + this.finishSuccessfulFetch(); } catch (error) { if (!refresh && !CoreSites.getCurrentSite()?.isOfflineDisabled() && this.isNotFoundError(error)) { // Module not found, retry without using cache. diff --git a/src/core/features/course/classes/main-resource-component.ts b/src/core/features/course/classes/main-resource-component.ts index 2a0eed662..a9600520c 100644 --- a/src/core/features/course/classes/main-resource-component.ts +++ b/src/core/features/course/classes/main-resource-component.ts @@ -72,6 +72,8 @@ export class CoreCourseModuleMainResourceComponent implements OnInit, OnDestroy, protected showCompletion = false; // Whether to show completion inside the activity. protected displayDescription = true; // Wether to show Module description on module page, and not on summary or the contrary. protected isDestroyed = false; // Whether the component is destroyed. + protected fetchSuccess = false; // Whether a fetch was finished successfully. + protected checkCompletionAfterLog = true; // Whether to check if completion has changed after calling logActivity. constructor( @Optional() @Inject('') loggerName: string = 'CoreCourseModuleMainResourceComponent', @@ -191,6 +193,8 @@ export class CoreCourseModuleMainResourceComponent implements OnInit, OnDestroy, try { await this.fetchContent(refresh); + + this.finishSuccessfulFetch(); } catch (error) { if (!refresh && !CoreSites.getCurrentSite()?.isOfflineDisabled() && this.isNotFoundError(error)) { // Module not found, retry without using cache. @@ -427,6 +431,47 @@ export class CoreCourseModuleMainResourceComponent implements OnInit, OnDestroy, } } + /** + * Finish a successful fetch. + * + * @return Promise resolved when done. + */ + protected async finishSuccessfulFetch(): Promise { + if (this.fetchSuccess) { + return; // Already treated. + } + + this.fetchSuccess = true; + CoreCourse.storeModuleViewed(this.courseId, this.module.id, { sectionId: this.module.section }); + + // Log activity now. + try { + await this.logActivity(); + + if (this.checkCompletionAfterLog) { + this.checkCompletion(); + } + } catch { + // Ignore errors. + } + } + + /** + * Log activity. + * + * @return Promise resolved when done. + */ + protected async logActivity(): Promise { + // To be overridden. + } + + /** + * Check the module completion. + */ + protected checkCompletion(): void { + CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); + } + /** * Component being destroyed. */ diff --git a/src/core/features/course/components/course-format/course-format.html b/src/core/features/course/components/course-format/course-format.html index 14cc14669..73416f1c5 100644 --- a/src/core/features/course/components/course-format/course-format.html +++ b/src/core/features/course/components/course-format/course-format.html @@ -97,7 +97,9 @@ + [showActivityDates]="course.showactivitydates" [showCompletionConditions]="course.showcompletionconditions" + [isLastViewed]="lastModuleViewed && lastModuleViewed.cmId === module.id" [class.core-course-module-not-viewed]=" + !viewedModules[module.id] && (!module.completiondata || module.completiondata.state === completionStatusIncomplete)"> diff --git a/src/core/features/course/components/course-format/course-format.ts b/src/core/features/course/components/course-format/course-format.ts index 5b6797f98..207d2dc8e 100644 --- a/src/core/features/course/components/course-format/course-format.ts +++ b/src/core/features/course/components/course-format/course-format.ts @@ -29,6 +29,7 @@ import { CoreDynamicComponent } from '@components/dynamic-component/dynamic-comp import { CoreCourseAnyCourseData } from '@features/courses/services/courses'; import { CoreCourse, + CoreCourseModuleCompletionStatus, CoreCourseProvider, } from '@features/course/services/course'; import { @@ -42,6 +43,7 @@ import { CoreCourseCourseIndexComponent, CoreCourseIndexSectionWithModule } from import { CoreBlockHelper } from '@features/block/services/block-helper'; import { CoreNavigator } from '@services/navigator'; import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate'; +import { CoreCourseViewedModulesDBRecord } from '@features/course/services/database/course'; /** * Component to display course contents using a certain format. If the format isn't found, use default one. @@ -90,9 +92,14 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { stealthModulesSectionId: number = CoreCourseProvider.STEALTH_MODULES_SECTION_ID; loaded = false; highlighted?: string; + lastModuleViewed?: CoreCourseViewedModulesDBRecord; + viewedModules: Record = {}; + completionStatusIncomplete = CoreCourseModuleCompletionStatus.COMPLETION_INCOMPLETE; protected selectTabObserver?: CoreEventObserver; + protected modViewedObserver?: CoreEventObserver; protected lastCourseFormat?: string; + protected viewedModulesInitialized = false; constructor( protected content: IonContent, @@ -133,6 +140,24 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { } }); + this.modViewedObserver = CoreEvents.on(CoreEvents.COURSE_MODULE_VIEWED, (data) => { + if (data.courseId !== this.course.id) { + return; + } + + this.viewedModules[data.cmId] = true; + if (!this.lastModuleViewed || data.timeaccess > this.lastModuleViewed.timeaccess) { + this.lastModuleViewed = data; + + if (this.selectedSection) { + // Change section to display the one with the last viewed module + const lastViewedSection = this.getViewedModuleSection(this.sections, data); + if (lastViewedSection && lastViewedSection.id !== this.selectedSection?.id) { + this.sectionChanged(lastViewedSection, data.cmId); + } + } + } + }); } /** @@ -179,8 +204,8 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { this.lastCourseFormat = this.course.format; this.highlighted = CoreCourseFormatDelegate.getSectionHightlightedName(this.course); - const currentSection = await CoreCourseFormatDelegate.getCurrentSection(this.course, this.sections); - currentSection.highlighted = true; + const currentSectionData = await CoreCourseFormatDelegate.getCurrentSection(this.course, this.sections); + currentSectionData.section.highlighted = true; await Promise.all([ this.loadCourseFormatComponent(), @@ -236,6 +261,8 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { const hasAllSections = sections[0].id == CoreCourseProvider.ALL_SECTIONS_ID; const hasSeveralSections = sections.length > 2 || (sections.length == 2 && !hasAllSections); + await this.initializeViewedModules(); + if (this.selectedSection) { const selectedSection = this.selectedSection; // We have a selected section, but the list has changed. Search the section in the list. @@ -243,7 +270,8 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { if (!newSection) { // Section not found, calculate which one to use. - newSection = await CoreCourseFormatDelegate.getCurrentSection(this.course, sections); + const currentSectionData = await CoreCourseFormatDelegate.getCurrentSection(this.course, sections); + newSection = currentSectionData.section; } this.sectionChanged(newSection); @@ -269,16 +297,75 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { } if (!this.loaded) { - // No section specified, not found or not visible, get current section. - const section = await CoreCourseFormatDelegate.getCurrentSection(this.course, sections); + // No section specified, not found or not visible, load current section or the section with last module viewed. + const currentSectionData = await CoreCourseFormatDelegate.getCurrentSection(this.course, sections); + + const lastModuleViewed = this.lastModuleViewed; + let section = currentSectionData.section; + let moduleId: number | undefined; + + if (!currentSectionData.forceSelected && lastModuleViewed) { + // Search the section with the last module viewed. + const lastModuleSection = this.getViewedModuleSection(sections, lastModuleViewed); + + section = lastModuleSection || section; + moduleId = lastModuleSection ? lastModuleViewed?.cmId : undefined; + } else if (lastModuleViewed && currentSectionData.section.modules.some(module => module.id === lastModuleViewed.cmId)) { + // Last module viewed is inside the highlighted section. + moduleId = lastModuleViewed.cmId; + } this.loaded = true; - this.sectionChanged(section); + this.sectionChanged(section, moduleId); } return; } + /** + * Initialize viewed modules. + * + * @return Promise resolved when done. + */ + protected async initializeViewedModules(): Promise { + if (this.viewedModulesInitialized) { + return; + } + + const viewedModules = await CoreCourse.getViewedModules(this.course.id); + + this.viewedModulesInitialized = true; + this.lastModuleViewed = viewedModules[0]; + viewedModules.forEach(entry => { + this.viewedModules[entry.cmId] = true; + }); + } + + /** + * Get the section of a viewed module. + * + * @param sections List of sections. + * @param viewedModule Viewed module. + * @return Section, undefined if not found. + */ + protected getViewedModuleSection( + sections: CoreCourseSection[], + viewedModule: CoreCourseViewedModulesDBRecord, + ): CoreCourseSection | undefined { + if (viewedModule.sectionId) { + const lastModuleSection = sections.find(section => section.id === viewedModule.sectionId); + + if (lastModuleSection) { + return lastModuleSection; + } + } + + // No sectionId or section not found. Search the module. + return sections.find( + section => section.modules.some(module => module.id === viewedModule.cmId), + ); + } + /** * Display the course index modal. */ @@ -345,8 +432,9 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { * Function called when selected section changes. * * @param newSection The new selected section. + * @param moduleId The module to scroll to. */ - sectionChanged(newSection: CoreCourseSection): void { + sectionChanged(newSection: CoreCourseSection, moduleId?: number): void { const previousValue = this.selectedSection; this.selectedSection = newSection; this.data.section = this.selectedSection; @@ -377,13 +465,11 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { this.showMoreActivities(); } - if (this.moduleId && previousValue === undefined) { + // Scroll to module if needed. Give more priority to the input. + const moduleIdToScroll = this.moduleId && previousValue === undefined ? this.moduleId : moduleId; + if (moduleIdToScroll) { setTimeout(() => { - CoreDomUtils.scrollToElementBySelector( - this.elementRef.nativeElement, - this.content, - '#core-course-module-' + this.moduleId, - ); + this.scrollToModule(moduleIdToScroll); }, 200); } else { this.content.scrollToTop(0); @@ -399,6 +485,19 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { this.invalidateSectionButtons(); } + /** + * Scroll to a certain module. + * + * @param moduleId Module ID. + */ + protected scrollToModule(moduleId: number): void { + CoreDomUtils.scrollToElementBySelector( + this.elementRef.nativeElement, + this.content, + '#core-course-module-' + moduleId, + ); + } + /** * Compare if two sections are equal. * @@ -502,7 +601,8 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { * @inheritdoc */ ngOnDestroy(): void { - this.selectTabObserver && this.selectTabObserver.off(); + this.selectTabObserver?.off(); + this.modViewedObserver?.off(); } /** diff --git a/src/core/features/course/components/course-index/course-index.ts b/src/core/features/course/components/course-index/course-index.ts index a36cee588..e530867f0 100644 --- a/src/core/features/course/components/course-index/course-index.ts +++ b/src/core/features/course/components/course-index/course-index.ts @@ -68,11 +68,11 @@ export class CoreCourseCourseIndexComponent implements OnInit { completionEnabled = this.course.showcompletionconditions; } - const currentSection = await CoreCourseFormatDelegate.getCurrentSection(this.course, this.sections); + const currentSectionData = await CoreCourseFormatDelegate.getCurrentSection(this.course, this.sections); if (this.selectedId === undefined) { // Highlight current section if none is selected. - this.selectedId = currentSection.id; + this.selectedId = currentSectionData.section.id; } // Clone sections to add information. @@ -104,7 +104,7 @@ export class CoreCourseCourseIndexComponent implements OnInit { name: section.name, availabilityinfo: !!section.availabilityinfo, expanded: section.id === this.selectedId, - highlighted: currentSection?.id === section.id, + highlighted: currentSectionData.section.id === section.id, hasVisibleModules: modules.length > 0, modules: modules, }; diff --git a/src/core/features/course/components/module-summary/module-summary.ts b/src/core/features/course/components/module-summary/module-summary.ts index f480c8469..61e5695cf 100644 --- a/src/core/features/course/components/module-summary/module-summary.ts +++ b/src/core/features/course/components/module-summary/module-summary.ts @@ -271,6 +271,10 @@ export class CoreCourseModuleSummaryComponent implements OnInit, OnDestroy { { replace: true, animationDirection: 'back', + params: { + module: this.module, + openModule: false, + }, }, ); } diff --git a/src/core/features/course/components/module/core-course-module.html b/src/core/features/course/components/module/core-course-module.html index b9449db96..437f2f075 100644 --- a/src/core/features/course/components/module/core-course-module.html +++ b/src/core/features/course/components/module/core-course-module.html @@ -99,6 +99,11 @@ + +
+ + {{ 'core.course.lastaccessedactivity' | translate }} +
diff --git a/src/core/features/course/components/module/module.scss b/src/core/features/course/components/module/module.scss index 4c06e9ca0..be50d8664 100644 --- a/src/core/features/course/components/module/module.scss +++ b/src/core/features/course/components/module/module.scss @@ -3,6 +3,7 @@ :host { --horizontal-margin: 10px; --vertical-margin: 10px; + --core-course-module-not-viewed-border-color: var(--gray-500); ion-card { margin: var(--vertical-margin) var(--horizontal-margin); @@ -91,4 +92,18 @@ .core-course-module-info ::ng-deep core-course-module-completion .core-module-automatic-completion-conditions .completioninfo.completion_complete { display: none; } + + &.core-course-module-not-viewed { + --ion-card-border-color: var(--core-course-module-not-viewed-border-color); + } + + .core-course-last-module-viewed { + padding: 8px 12px; + color: var(--subdued-text-color); + border-top: 1px solid var(--stroke); + + ion-icon { + margin-right: 4px; + } + } } diff --git a/src/core/features/course/components/module/module.ts b/src/core/features/course/components/module/module.ts index 29676e4ca..471e7e7d9 100644 --- a/src/core/features/course/components/module/module.ts +++ b/src/core/features/course/components/module/module.ts @@ -48,6 +48,7 @@ export class CoreCourseModuleComponent implements OnInit, OnDestroy { @Input() showActivityDates = false; // Whether to show activity dates. @Input() showCompletionConditions = false; // Whether to show activity completion conditions. @Input() showLegacyCompletion?: boolean; // Whether to show module completion in the old format. + @Input() isLastViewed = false; // Whether it's the last module viewed in a course. @Output() completionChanged = new EventEmitter(); // Notify when module completion changes. modNameTranslated = ''; diff --git a/src/core/features/course/format/weeks/services/handlers/weeks-format.ts b/src/core/features/course/format/weeks/services/handlers/weeks-format.ts index 3af676006..2d05d2700 100644 --- a/src/core/features/course/format/weeks/services/handlers/weeks-format.ts +++ b/src/core/features/course/format/weeks/services/handlers/weeks-format.ts @@ -15,7 +15,7 @@ import { Injectable } from '@angular/core'; import { CoreTimeUtils } from '@services/utils/time'; -import { CoreCourseFormatHandler } from '@features/course/services/format-delegate'; +import { CoreCourseFormatCurrentSectionData, CoreCourseFormatHandler } from '@features/course/services/format-delegate'; import { makeSingleton, Translate } from '@singletons'; import { CoreCourseAnyCourseData } from '@features/courses/services/courses'; import { CoreCourseWSSection } from '@features/course/services/course'; @@ -41,12 +41,18 @@ export class CoreCourseFormatWeeksHandlerService implements CoreCourseFormatHand /** * @inheritdoc */ - async getCurrentSection(course: CoreCourseAnyCourseData, sections: CoreCourseSection[]): Promise { + async getCurrentSection( + course: CoreCourseAnyCourseData, + sections: CoreCourseSection[], + ): Promise> { const now = CoreTimeUtils.timestamp(); if ((course.startdate && now < course.startdate) || (course.enddate && now > course.enddate)) { // Course hasn't started yet or it has ended already. Return all sections. - return sections[0]; + return { + section: sections[0], + forceSelected: false, + }; } for (let i = 0; i < sections.length; i++) { @@ -57,12 +63,18 @@ export class CoreCourseFormatWeeksHandlerService implements CoreCourseFormatHand const dates = this.getSectionDates(section, course.startdate || 0); if (now >= dates.start && now < dates.end) { - return section; + return { + section, + forceSelected: false, + }; } } // The section wasn't found, return all sections. - return sections[0]; + return { + section: sections[0], + forceSelected: false, + }; } /** diff --git a/src/core/features/course/lang.json b/src/core/features/course/lang.json index 1b1ae4835..4c1e75de6 100644 --- a/src/core/features/course/lang.json +++ b/src/core/features/course/lang.json @@ -44,6 +44,7 @@ "highlighted": "Highlighted", "insufficientavailablespace": "You are trying to download {{size}}. This will leave your device with insufficient space to operate normally. Please clear some storage space first.", "insufficientavailablequota": "Your device could not allocate space to save this download. It may be reserving space for app and system updates. Please clear some storage space first.", + "lastaccessedactivity": "Last accessed activity", "manualcompletionnotsynced": "Manual completion not synchronised.", "modulenotfound": "Resource or activity not found, please make sure you're online and it's still available.", "nocontentavailable": "No content available at the moment.", diff --git a/src/core/features/course/pages/index/index.ts b/src/core/features/course/pages/index/index.ts index 3a7679baf..3377bde84 100644 --- a/src/core/features/course/pages/index/index.ts +++ b/src/core/features/course/pages/index/index.ts @@ -59,6 +59,7 @@ export class CoreCourseIndexPage implements OnInit, OnDestroy { protected module?: CoreCourseModuleData; protected modNavOptions?: CoreNavigationOptions; protected isGuest = false; + protected openModule = true; protected contentsTab: CoreTabsOutletTab & { pageParams: Params } = { page: CONTENTS_PAGE_NAME, title: 'core.course', @@ -138,6 +139,7 @@ export class CoreCourseIndexPage implements OnInit, OnDestroy { this.module = CoreNavigator.getRouteParam('module'); this.isGuest = !!CoreNavigator.getRouteBooleanParam('isGuest'); this.modNavOptions = CoreNavigator.getRouteParam('modNavOptions'); + this.openModule = CoreNavigator.getRouteBooleanParam('openModule') ?? true; // If false, just scroll to module. if (!this.modNavOptions) { // Fallback to old way of passing params. @deprecated since 4.0. const modParams = CoreNavigator.getRouteParam('modParams'); @@ -157,6 +159,10 @@ export class CoreCourseIndexPage implements OnInit, OnDestroy { if (this.module) { this.contentsTab.pageParams.moduleId = this.module.id; + if (!this.contentsTab.pageParams.sectionId && !this.contentsTab.pageParams.sectionNumber) { + // No section specified, use module section. + this.contentsTab.pageParams.sectionId = this.module.section; + } } this.tabs.push(this.contentsTab); @@ -172,7 +178,7 @@ export class CoreCourseIndexPage implements OnInit, OnDestroy { * A tab was selected. */ tabSelected(): void { - if (!this.module || !this.course) { + if (!this.module || !this.course || !this.openModule) { return; } // Now that the first tab has been selected we can load the module. diff --git a/src/core/features/course/services/course-helper.ts b/src/core/features/course/services/course-helper.ts index e14cae7f0..7dc88fd7b 100644 --- a/src/core/features/course/services/course-helper.ts +++ b/src/core/features/course/services/course-helper.ts @@ -2022,6 +2022,7 @@ export type CoreCourseModuleData = Omit>>; + protected viewedModulesTables: LazyMap>>; constructor() { this.logger = CoreLogger.getInstance('CoreCourseProvider'); @@ -152,6 +156,17 @@ export class CoreCourseProvider { }), ), ); + + this.viewedModulesTables = lazyMap( + siteId => asyncInstance( + () => CoreSites.getSiteTable(COURSE_VIEWED_MODULES_TABLE, { + siteId, + config: { cachingStrategy: CoreDatabaseCachingStrategy.None }, + primaryKeyColumns: ['courseId', 'cmId'], + onDestroy: () => delete this.viewedModulesTables[siteId], + }), + ), + ); } /** @@ -340,6 +355,31 @@ export class CoreCourseProvider { return ROOT_CACHE_KEY + 'activitiescompletion:' + courseId + ':' + userId; } + /** + * Get certain module viewed records in the app. + * + * @param ids Module IDs. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with map of last module viewed data. + */ + async getCertainModulesViewed(ids: number[] = [], siteId?: string): Promise> { + if (!ids.length) { + return {}; + } + + const site = await CoreSites.getSite(siteId); + + const whereAndParams = SQLiteDB.getInOrEqual(ids); + + const entries = await this.viewedModulesTables[site.getId()].getManyWhere({ + sql: 'cmId ' + whereAndParams.sql, + sqlParams: whereAndParams.params, + js: (record) => ids.includes(record.cmId), + }); + + return CoreUtils.arrayToObject(entries, 'cmId'); + } + /** * Get course blocks. * @@ -425,6 +465,19 @@ export class CoreCourseProvider { return entries.map((entry) => entry.id); } + /** + * Get last module viewed in the app for a course. + * + * @param id Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with last module viewed data, undefined if none. + */ + async getLastModuleViewed(id: number, siteId?: string): Promise { + const viewedModules = await this.getViewedModules(id, siteId); + + return viewedModules[0]; + } + /** * Get a module from Moodle. * @@ -548,7 +601,7 @@ export class CoreCourseProvider { let foundModule: CoreCourseGetContentsWSModule | undefined; - const foundSection = sections.some((section) => { + const foundSection = sections.find((section) => { if (section.id != CoreCourseProvider.STEALTH_MODULES_SECTION_ID && sectionId !== undefined && sectionId != section.id @@ -562,7 +615,7 @@ export class CoreCourseProvider { }); if (foundSection && foundModule) { - return this.addAdditionalModuleData(foundModule, courseId); + return this.addAdditionalModuleData(foundModule, courseId, foundSection.id); } throw new CoreError(Translate.instant('core.course.modulenotfound')); @@ -573,11 +626,13 @@ export class CoreCourseProvider { * * @param module Module. * @param courseId Course ID of the module. + * @param sectionId Section ID of the module. * @return Module with additional info. */ - protected addAdditionalModuleData( + protected addAdditionalModuleData( module: CoreCourseGetContentsWSModule, courseId: number, + sectionId: number, ): CoreCourseModuleData { let completionData: CoreCourseModuleCompletionData | undefined = undefined; @@ -590,13 +645,12 @@ export class CoreCourseProvider { }; } - const moduleWithCourse: CoreCourseModuleData = { + return { ...module, course: courseId, + section: sectionId, completiondata: completionData, }; - - return moduleWithCourse; } /** @@ -873,7 +927,7 @@ export class CoreCourseProvider { // Add course to all modules. return sections.map((section) => ({ ...section, - modules: section.modules.map((module) => this.addAdditionalModuleData(module, courseId)), + modules: section.modules.map((module) => this.addAdditionalModuleData(module, courseId, section.id)), })); } @@ -901,6 +955,23 @@ export class CoreCourseProvider { return sections.reduce((previous: CoreCourseModuleData[], section) => previous.concat(section.modules || []), []); } + /** + * Get all viewed modules in a course, ordered by timeaccess in descending order. + * + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the list of viewed modules. + */ + async getViewedModules(courseId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + return await this.viewedModulesTables[site.getId()].getMany({ courseId }, { + sorting: [ + { timeaccess: 'desc' }, + ], + }); + } + /** * Invalidates course blocks WS call. * @@ -1348,6 +1419,34 @@ export class CoreCourseProvider { this.triggerCourseStatusChanged(courseId, status, siteId); } + /** + * Store activity as viewed. + * + * @param courseId Chapter ID. + * @param cmId Module ID. + * @param options Other options. + * @return Promise resolved with last chapter viewed, undefined if none. + */ + async storeModuleViewed(courseId: number, cmId: number, options: CoreCourseStoreModuleViewedOptions = {}): Promise { + const site = await CoreSites.getSite(options.siteId); + + const timeaccess = options.timeaccess ?? Date.now(); + + await this.viewedModulesTables[site.getId()].insert({ + courseId, + cmId, + sectionId: options.sectionId, + timeaccess, + }); + + CoreEvents.trigger(CoreEvents.COURSE_MODULE_VIEWED, { + courseId, + cmId, + timeaccess, + sectionId: options.sectionId, + }, site.getId()); + } + /** * Translate a module name to current language. * @@ -1770,3 +1869,12 @@ type CoreCompletionUpdateActivityCompletionStatusManuallyWSParams = { export type CoreCourseAnyModuleData = CoreCourseModuleData | CoreCourseModuleBasicInfo & { contents?: CoreCourseModuleContentFile[]; // If needed, calculated in the app in loadModuleContents. }; + +/** + * Options for storeModuleViewed. + */ +export type CoreCourseStoreModuleViewedOptions = { + sectionId?: number; + timeaccess?: number; + siteId?: string; +}; diff --git a/src/core/features/course/services/database/course.ts b/src/core/features/course/services/database/course.ts index 7869b8a55..47fa10b87 100644 --- a/src/core/features/course/services/database/course.ts +++ b/src/core/features/course/services/database/course.ts @@ -18,9 +18,10 @@ import { CoreSiteSchema } from '@services/sites'; * Database variables for CoreCourse service. */ export const COURSE_STATUS_TABLE = 'course_status'; +export const COURSE_VIEWED_MODULES_TABLE = 'course_viewed_modules'; export const SITE_SCHEMA: CoreSiteSchema = { name: 'CoreCourseProvider', - version: 1, + version: 2, tables: [ { name: COURSE_STATUS_TABLE, @@ -53,6 +54,29 @@ export const SITE_SCHEMA: CoreSiteSchema = { }, ], }, + { + name: COURSE_VIEWED_MODULES_TABLE, + columns: [ + { + name: 'courseId', + type: 'INTEGER', + }, + { + name: 'cmId', + type: 'INTEGER', + }, + { + name: 'timeaccess', + type: 'INTEGER', + notNull: true, + }, + { + name: 'sectionId', + type: 'INTEGER', + }, + ], + primaryKeys: ['courseId', 'cmId'], + }, ], }; @@ -85,7 +109,7 @@ export const OFFLINE_SITE_SCHEMA: CoreSiteSchema = { type: 'TEXT', }, { - name: 'timecompleted', + name: 'timeaccess', type: 'INTEGER', }, ], @@ -102,6 +126,13 @@ export type CoreCourseStatusDBRecord = { previousDownloadTime: number; }; +export type CoreCourseViewedModulesDBRecord = { + courseId: number; + cmId: number; + timeaccess: number; + sectionId?: number; +}; + export type CoreCourseManualCompletionDBRecord = { cmid: number; completed: number; diff --git a/src/core/features/course/services/format-delegate.ts b/src/core/features/course/services/format-delegate.ts index 94a4bcc4c..12f87c94d 100644 --- a/src/core/features/course/services/format-delegate.ts +++ b/src/core/features/course/services/format-delegate.ts @@ -98,9 +98,13 @@ export interface CoreCourseFormatHandler extends CoreDelegateHandler { * * @param course The course to get the title. * @param sections List of sections. - * @return Promise resolved with current section. + * @return Promise resolved with current section and whether the section should be selected. If only the section is returned, + * forceSelected will default to false. */ - getCurrentSection?(course: CoreCourseAnyCourseData, sections: CoreCourseSection[]): Promise; + getCurrentSection?( + course: CoreCourseAnyCourseData, + sections: CoreCourseSection[], + ): Promise | CoreCourseSection>; /** * Returns the name for the highlighted section. @@ -299,21 +303,37 @@ export class CoreCourseFormatDelegateService extends CoreDelegate(course: CoreCourseAnyCourseData, sections: T[]): Promise { + async getCurrentSection( + course: CoreCourseAnyCourseData, + sections: T[], + ): Promise> { try { - const section = await this.executeFunctionOnEnabled( + const sectionData = await this.executeFunctionOnEnabled | T>( course.format || '', 'getCurrentSection', [course, sections], ); - return section || sections[0]; + if (sectionData && 'forceSelected' in sectionData) { + return sectionData; + } else if (sectionData) { + // Function just returned the section, don't force selecting it. + return { + section: sectionData, + forceSelected: false, + }; + } } catch { - // This function should never fail. Just return the first section (usually, "All sections"). - return sections[0]; + // This function should never fail. } + + // Return the first section (usually, "All sections"). + return { + section: sections[0], + forceSelected: false, + }; } /** @@ -380,3 +400,8 @@ export class CoreCourseFormatDelegateService extends CoreDelegate = { + section: T; // Current section. + forceSelected: boolean; // If true, the app will force selecting the section when opening the course. +}; diff --git a/src/core/features/course/services/handlers/default-format.ts b/src/core/features/course/services/handlers/default-format.ts index ff60f6c39..383a645fc 100644 --- a/src/core/features/course/services/handlers/default-format.ts +++ b/src/core/features/course/services/handlers/default-format.ts @@ -18,7 +18,7 @@ import { CoreNavigationOptions, CoreNavigator } from '@services/navigator'; import { CoreUtils } from '@services/utils/utils'; import { Translate } from '@singletons'; import { CoreCourseSection } from '../course-helper'; -import { CoreCourseFormatHandler } from '../format-delegate'; +import { CoreCourseFormatCurrentSectionData, CoreCourseFormatHandler } from '../format-delegate'; /** * Default handler used when the course format doesn't have a specific implementation. @@ -74,7 +74,10 @@ export class CoreCourseFormatDefaultHandler implements CoreCourseFormatHandler { /** * @inheritdoc */ - async getCurrentSection(course: CoreCourseAnyCourseData, sections: CoreCourseSection[]): Promise { + async getCurrentSection( + course: CoreCourseAnyCourseData, + sections: CoreCourseSection[], + ): Promise> { let marker: number | undefined; // We need the "marker" to determine the current section. @@ -93,12 +96,18 @@ export class CoreCourseFormatDefaultHandler implements CoreCourseFormatHandler { const section = sections.find((sect) => sect.section == marker); if (section) { - return section; + return { + section, + forceSelected: true, + }; } } // Marked section not found or we couldn't retrieve the marker. Return all sections. - return sections[0]; + return { + section: sections[0], + forceSelected: false, + }; } /** diff --git a/src/core/features/grades/pages/course/course.page.ts b/src/core/features/grades/pages/course/course.page.ts index 22fadfa71..f877aba9c 100644 --- a/src/core/features/grades/pages/course/course.page.ts +++ b/src/core/features/grades/pages/course/course.page.ts @@ -54,6 +54,8 @@ export class CoreGradesCoursePage implements AfterViewInit, OnDestroy { totalColumnsSpan?: number; withinSplitView?: boolean; + protected fetchSuccess = false; + constructor( protected route: ActivatedRoute, protected element: ElementRef, @@ -93,7 +95,6 @@ export class CoreGradesCoursePage implements AfterViewInit, OnDestroy { await this.courses?.start(); await this.fetchInitialGrades(); - await CoreGrades.logCourseGradesView(this.courseId, this.userId); } /** @@ -198,6 +199,11 @@ export class CoreGradesCoursePage implements AfterViewInit, OnDestroy { this.columns = formattedTable.columns; this.rows = formattedTable.rows; this.totalColumnsSpan = formattedTable.columns.reduce((total, column) => total + column.colspan, 0); + + if (!this.fetchSuccess) { + this.fetchSuccess = true; + await CoreGrades.logCourseGradesView(this.courseId, this.userId); + } } } diff --git a/src/core/features/sitehome/pages/index/index.ts b/src/core/features/sitehome/pages/index/index.ts index 087589a5d..dbfc682e8 100644 --- a/src/core/features/sitehome/pages/index/index.ts +++ b/src/core/features/sitehome/pages/index/index.ts @@ -54,6 +54,7 @@ export class CoreSiteHomeIndexPage implements OnInit, OnDestroy { newsForumModule?: CoreCourseModuleData; protected updateSiteObserver: CoreEventObserver; + protected fetchSuccess = false; constructor() { // Refresh the enabled flags if site is updated. @@ -137,13 +138,15 @@ export class CoreSiteHomeIndexPage implements OnInit, OnDestroy { this.hasContent = result.hasContent || this.hasContent; } - // Add log in Moodle. - CoreUtils.ignoreErrors(CoreCourse.logView( - this.siteHomeId, - undefined, - undefined, - this.currentSite.getInfo()?.sitename, - )); + if (!this.fetchSuccess) { + this.fetchSuccess = true; + CoreUtils.ignoreErrors(CoreCourse.logView( + this.siteHomeId, + undefined, + undefined, + this.currentSite.getInfo()?.sitename, + )); + } } catch (error) { CoreDomUtils.showErrorModalDefault(error, 'core.course.couldnotloadsectioncontent', true); } diff --git a/src/core/features/user/pages/profile/profile.page.ts b/src/core/features/user/pages/profile/profile.page.ts index ba3061238..b22146d00 100644 --- a/src/core/features/user/pages/profile/profile.page.ts +++ b/src/core/features/user/pages/profile/profile.page.ts @@ -48,6 +48,7 @@ export class CoreUserProfilePage implements OnInit, OnDestroy { protected site!: CoreSite; protected obsProfileRefreshed: CoreEventObserver; protected subscription?: Subscription; + protected fetchSuccess = false; userLoaded = false; isLoadingHandlers = false; @@ -106,18 +107,6 @@ export class CoreUserProfilePage implements OnInit, OnDestroy { try { await this.fetchUser(); - - if (!this.user) { - return; - } - - try { - await CoreUser.logView(this.userId, this.courseId, this.user.fullname); - } catch (error) { - this.isDeleted = error?.errorcode === 'userdeleted' || error?.errorcode === 'wsaccessuserdeleted'; - this.isSuspended = error?.errorcode === 'wsaccessusersuspended'; - this.isEnrolled = error?.errorcode !== 'notenrolledprofile'; - } } finally { this.userLoaded = true; } @@ -162,6 +151,18 @@ export class CoreUserProfilePage implements OnInit, OnDestroy { this.isLoadingHandlers = !CoreUserDelegate.areHandlersLoaded(user.id, context, this.courseId); }); + if (!this.fetchSuccess) { + this.fetchSuccess = true; + + try { + await CoreUser.logView(this.userId, this.courseId, this.user.fullname); + } catch (error) { + this.isDeleted = error?.errorcode === 'userdeleted' || error?.errorcode === 'wsaccessuserdeleted'; + this.isSuspended = error?.errorcode === 'wsaccessusersuspended'; + this.isEnrolled = error?.errorcode !== 'notenrolledprofile'; + } + } + } catch (error) { // Error is null for deleted users, do not show the modal. CoreDomUtils.showErrorModal(error); diff --git a/src/core/services/database/sites.ts b/src/core/services/database/sites.ts index 8d2083e1d..80aadcd48 100644 --- a/src/core/services/database/sites.ts +++ b/src/core/services/database/sites.ts @@ -145,6 +145,10 @@ export const SITE_SCHEMA: CoreSiteSchema = { name: 'data', type: 'TEXT', }, + { + name: 'timeaccess', + type: 'INTEGER', + }, ], primaryKeys: ['component', 'id'], }, diff --git a/src/core/singletons/events.ts b/src/core/singletons/events.ts index 57d112fd8..4045c8212 100644 --- a/src/core/singletons/events.ts +++ b/src/core/singletons/events.ts @@ -60,6 +60,7 @@ export interface CoreEventsData { [CoreEvents.FILE_SHARED]: CoreEventFileSharedData; [CoreEvents.APP_LAUNCHED_URL]: CoreEventAppLaunchedData; [CoreEvents.ORIENTATION_CHANGE]: CoreEventOrientationData; + [CoreEvents.COURSE_MODULE_VIEWED]: CoreEventCourseModuleViewed; } /* @@ -110,6 +111,7 @@ export class CoreEvents { static readonly FORM_ACTION = 'form_action'; static readonly ACTIVITY_DATA_SENT = 'activity_data_sent'; static readonly DEVICE_REGISTERED_IN_MOODLE = 'device_registered_in_moodle'; + static readonly COURSE_MODULE_VIEWED = 'course_module_viewed'; protected static logger = CoreLogger.getInstance('CoreEvents'); protected static observables: { [eventName: string]: Subject } = {}; @@ -427,3 +429,13 @@ export type CoreEventAppLaunchedData = { export type CoreEventOrientationData = { orientation: CoreScreenOrientation; }; + +/** + * Data passed to COURSE_MODULE_VIEWED event. + */ +export type CoreEventCourseModuleViewed = { + courseId: number; + cmId: number; + timeaccess: number; + sectionId?: number; +}; diff --git a/upgrade.txt b/upgrade.txt index e01fe0058..5bb5fdf59 100644 --- a/upgrade.txt +++ b/upgrade.txt @@ -22,7 +22,8 @@ information provided here is intended especially for developers. - The parameters of the following functions in CoreCourseHelper have changed: navigateToModuleByInstance, navigateToModule, openModule. - fillContextMenu, expandDescription, gotoBlog, prefetch and removeFiles functions have been removed from CoreCourseModuleMainResourceComponent. - contextMenuPrefetch and fillContextMenu have been removed from CoreCourseHelper. --The variable "loaded" in CoreCourseModuleMainResourceComponent has been changed to "showLoading" to reflect its purpose better. +- The variable "loaded" in CoreCourseModuleMainResourceComponent has been changed to "showLoading" to reflect its purpose better. +- The function getCurrentSection of course formats can now return a forceSelected boolean along with the section (defaults to false if not returned).