MOBILE-3629 coursecompletion: Implement coursecompletion feature

main
Pau Ferrer Ocaña 2021-03-09 16:52:56 +01:00
parent 211e15d59c
commit 70809d79ec
12 changed files with 498 additions and 44 deletions

View File

@ -20,6 +20,7 @@ import { AddonFilterModule } from './filter/filter.module';
import { AddonUserProfileFieldModule } from './userprofilefield/userprofilefield.module';
import { AddonBadgesModule } from './badges/badges.module';
import { AddonCalendarModule } from './calendar/calendar.module';
import { AddonCourseCompletionModule } from './coursecompletion/coursecompletion.module';
import { AddonNotificationsModule } from './notifications/notifications.module';
import { AddonMessageOutputModule } from './messageoutput/messageoutput.module';
import { AddonMessagesModule } from './messages/messages.module';
@ -35,6 +36,7 @@ import { AddonRemoteThemesModule } from './remotethemes/remotethemes.module';
AddonBadgesModule,
AddonBlogModule,
AddonCalendarModule,
AddonCourseCompletionModule,
AddonMessagesModule,
AddonPrivateFilesModule,
AddonFilterModule,

View File

@ -29,21 +29,14 @@ export class AddonBlockCompletionStatusHandlerService extends CoreBlockBaseHandl
blockName = 'completionstatus';
/**
* Returns the data needed to render the block.
*
* @param block The block to render.
* @param contextLevel The context where the block will be used.
* @param instanceId The instance ID associated with the context level.
* @return Data or promise resolved with the data.
* @inheritdoc
*/
getDisplayData(block: CoreCourseBlock, contextLevel: string, instanceId: number): CoreBlockHandlerData {
// @todo
return {
title: 'addon.block_completionstatus.pluginname',
class: 'addon-block-completion-status',
component: CoreBlockOnlyTitleComponent,
link: 'AddonCourseCompletionReportPage',
link: 'coursecompletion',
linkParams: {
courseId: instanceId,
},

View File

@ -29,22 +29,17 @@ export class AddonBlockSelfCompletionHandlerService extends CoreBlockBaseHandler
blockName = 'selfcompletion';
/**
* Returns the data needed to render the block.
*
* @param block The block to render.
* @param contextLevel The context where the block will be used.
* @param instanceId The instance ID associated with the context level.
* @return Data or promise resolved with the data.
* @inheritdoc
*/
getDisplayData(block: CoreCourseBlock, contextLevel: string, instanceId: number): CoreBlockHandlerData {
// @todo
return {
title: 'addon.block_selfcompletion.pluginname',
class: 'addon-block-self-completion',
component: CoreBlockOnlyTitleComponent,
link: 'AddonCourseCompletionReportPage',
linkParams: { courseId: instanceId },
link: 'coursecompletion',
linkParams: {
courseId: instanceId,
},
};
}

View File

@ -0,0 +1,41 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { CoreSharedModule } from '@/core/shared.module';
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { CoreCommentsComponentsModule } from '@features/comments/components/components.module';
import { CoreTagComponentsModule } from '@features/tag/components/components.module';
import { AddonCourseCompletionReportPage } from './pages/report/report';
const routes: Routes = [
{
path: '',
component: AddonCourseCompletionReportPage,
},
];
@NgModule({
imports: [
RouterModule.forChild(routes),
CoreSharedModule,
CoreCommentsComponentsModule,
CoreTagComponentsModule,
],
exports: [RouterModule],
declarations: [
AddonCourseCompletionReportPage,
],
})
export class AddonCourseCompletionLazyModule {}

View File

@ -12,33 +12,42 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule } from '@angular/core';
// @todo import { AddonCourseCompletionCourseOptionHandler } from './services/course-option-handler';
// @todo import { AddonCourseCompletionUserHandler } from './services/user-handler';
// @todo import { AddonCourseCompletionComponentsModule } from './components/components.module';
// @todo import { CoreCourseOptionsDelegate } from '@features/course/services/options-delegate';
// @todo import { CoreUserDelegate } from '@features/user/services/user-delegate';
import { APP_INITIALIZER, NgModule, Type } from '@angular/core';
import { Routes } from '@angular/router';
import { CoreCourseIndexRoutingModule } from '@features/course/pages/index/index-routing.module';
import { CoreCourseOptionsDelegate } from '@features/course/services/course-options-delegate';
import { CoreMainMenuTabRoutingModule } from '@features/mainmenu/mainmenu-tab-routing.module';
import { CoreUserDelegate } from '@features/user/services/user-delegate';
import { AddonCourseCompletionProvider } from './services/coursecompletion';
import { AddonCourseCompletionCourseOptionHandler } from './services/handlers/course-option';
import { AddonCourseCompletionUserHandler } from './services/handlers/user';
export const ADDON_COURSECOMPLETION_SERVICES: Type<unknown>[] = [
AddonCourseCompletionProvider,
];
const routes: Routes = [
{
path: 'coursecompletion',
loadChildren: () => import('./coursecompletion-lazy.module').then(m => m.AddonCourseCompletionLazyModule),
},
];
@NgModule({
imports: [
// AddonCourseCompletionComponentsModule,
CoreMainMenuTabRoutingModule.forChild(routes),
CoreCourseIndexRoutingModule.forChild({ children: routes }),
],
providers: [
// AddonCourseCompletionCourseOptionHandler,
// AddonCourseCompletionUserHandler,
{
provide: APP_INITIALIZER,
multi: true,
deps: [],
useFactory: () => async () => {
CoreUserDelegate.registerHandler(AddonCourseCompletionUserHandler.instance);
CoreCourseOptionsDelegate.registerHandler(AddonCourseCompletionCourseOptionHandler.instance);
},
},
],
})
export class AddonCourseCompletionModule {
/* @todo constructor(
courseOptionsDelegate: CoreCourseOptionsDelegate,
courseOptionHandler: AddonCourseCompletionCourseOptionHandler,
userDelegate: CoreUserDelegate,
userHandler: AddonCourseCompletionUserHandler,
) {
// Register handlers.
courseOptionsDelegate.registerHandler(courseOptionHandler);
userDelegate.registerHandler(userHandler);
}*/
}
export class AddonCourseCompletionModule {}

View File

@ -0,0 +1,92 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
</ion-buttons>
<ion-title>{{ 'addon.coursecompletion.coursecompletion' | translate }}</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-refresher slot="fixed" [disabled]="!completionLoaded" (ionRefresh)="refreshCompletion($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-loading [hideUntil]="completionLoaded">
<ion-card *ngIf="completion && tracked">
<ion-item class="ion-text-wrap">
<ion-label>
<h2>{{ 'addon.coursecompletion.status' | translate }}</h2>
<p>{{ statusText! | translate }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label>
<h2>{{ 'addon.coursecompletion.required' | translate }}</h2>
<p *ngIf="completion.aggregation === 1">{{ 'addon.coursecompletion.criteriarequiredall' | translate }}</p>
<p *ngIf="completion.aggregation === 2">{{ 'addon.coursecompletion.criteriarequiredany' | translate }}</p>
</ion-label>
</ion-item>
</ion-card>
<ion-card *ngIf="completion && tracked">
<ion-item-divider>
<ion-label>{{ 'addon.coursecompletion.requiredcriteria' | translate }}</ion-label>
</ion-item-divider>
<ion-item class="ion-hide-md-up ion-text-wrap" *ngFor="let criteria of completion.completions">
<ion-label>
<h2><core-format-text clean="true" [text]="criteria.details.criteria" [filter]="false"></core-format-text></h2>
<p><core-format-text clean="true" [text]="criteria.details.requirement" [filter]="false"></core-format-text></p>
</ion-label>
<strong slot="end">{{ criteria.status }}</strong>
</ion-item>
<ion-item class="ion-hide-md-down ion-text-wrap">
<ion-label>
<ion-row>
<ion-col><strong>{{ 'addon.coursecompletion.criteriagroup' | translate }}</strong></ion-col>
<ion-col><strong>{{ 'addon.coursecompletion.criteria' | translate }}</strong></ion-col>
<ion-col><strong>{{ 'addon.coursecompletion.requirement' | translate }}</strong></ion-col>
<ion-col><strong>{{ 'addon.coursecompletion.status' | translate }}</strong></ion-col>
<ion-col><strong>{{ 'addon.coursecompletion.complete' | translate }}</strong></ion-col>
<ion-col><strong>{{ 'addon.coursecompletion.completiondate' | translate }}</strong></ion-col>
</ion-row>
<ion-row *ngFor="let criteria of completion.completions">
<ion-col>
<core-format-text clean="true" [text]="criteria.title" [filter]="false"></core-format-text>
</ion-col>
<ion-col>
<core-format-text clean="true" [text]="criteria.details.criteria" [filter]="false"></core-format-text>
</ion-col>
<ion-col>
<core-format-text clean="true" [text]="criteria.details.requirement" [filter]="false"></core-format-text>
</ion-col>
<ion-col>
<core-format-text [text]="criteria.details.status" [filter]="false"></core-format-text>
</ion-col>
<ion-col>{{ criteria.status }}</ion-col>
<ion-col *ngIf="criteria.timecompleted">
{{ criteria.timecompleted * 1000 | coreFormatDate :'strftimedatetimeshort' }}
</ion-col>
<ion-col *ngIf="!criteria.timecompleted"></ion-col>
</ion-row>
</ion-label>
</ion-item>
</ion-card>
<ion-card *ngIf="showSelfComplete && tracked">
<ion-item-divider>
<ion-label>{{ 'addon.coursecompletion.manualselfcompletion' | translate }}</ion-label>
</ion-item-divider>
<ion-item>
<ion-label>
<ion-button expand="block" (click)="completeCourse()">
{{ 'addon.coursecompletion.completecourse' | translate }}
</ion-button>
</ion-label>
</ion-item>
</ion-card>
<ion-card class="core-warning-card" *ngIf="!tracked">
<ion-item>
<ion-icon name="fas-exclamation-triangle" slot="start"></ion-icon>
<ion-label>{{ 'addon.coursecompletion.nottracked' | translate }}</ion-label>
</ion-item>
</ion-card>
</core-loading>
</ion-content>

View File

@ -0,0 +1,112 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import {
AddonCourseCompletion,
AddonCourseCompletionCourseCompletionStatus,
} from '@addons/coursecompletion/services/coursecompletion';
import { Component, OnInit } from '@angular/core';
import { IonRefresher } from '@ionic/angular';
import { CoreNavigator } from '@services/navigator';
import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
/**
* Page that displays the course completion report.
*/
@Component({
selector: 'page-addon-course-completion-report',
templateUrl: 'report.html',
})
export class AddonCourseCompletionReportPage implements OnInit {
protected courseId!: number;
protected userId!: number;
completionLoaded = false;
completion?: AddonCourseCompletionCourseCompletionStatus;
showSelfComplete = false;
tracked = true; // Whether completion is tracked.
statusText?: string;
/**
* @inheritdoc
*/
ngOnInit(): void {
this.courseId = CoreNavigator.getRouteNumberParam('courseId')!;
this.userId = CoreNavigator.getRouteNumberParam('userId') || CoreSites.getCurrentSiteUserId();
if (!this.userId) {
this.userId = CoreSites.getCurrentSiteUserId();
}
this.fetchCompletion().finally(() => {
this.completionLoaded = true;
});
}
/**
* Fetch compleiton data.
*
* @return Promise resolved when done.
*/
protected async fetchCompletion(): Promise<void> {
try {
this.completion = await AddonCourseCompletion.getCompletion(this.courseId, this.userId);
this.statusText = AddonCourseCompletion.getCompletedStatusText(this.completion);
this.showSelfComplete = AddonCourseCompletion.canMarkSelfCompleted(this.userId, this.completion);
this.tracked = true;
} catch (error) {
if (error && error.errorcode == 'notenroled') {
// Not enrolled error, probably a teacher.
this.tracked = false;
} else {
CoreDomUtils.showErrorModalDefault(error, 'addon.coursecompletion.couldnotloadreport', true);
}
}
}
/**
* Refresh completion data on PTR.
*
* @param refresher Refresher instance.
*/
async refreshCompletion(refresher?: IonRefresher): Promise<void> {
await AddonCourseCompletion.invalidateCourseCompletion(this.courseId, this.userId).finally(() => {
this.fetchCompletion().finally(() => {
refresher?.complete();
});
});
}
/**
* Mark course as completed.
*/
async completeCourse(): Promise<void> {
const modal = await CoreDomUtils.showModalLoading('core.sending', true);
try {
await AddonCourseCompletion.markCourseAsSelfCompleted(this.courseId);
await this.refreshCompletion();
} catch (error) {
CoreDomUtils.showErrorModal(error);
} finally {
modal.dismiss();
}
}
}

View File

@ -0,0 +1,96 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { CoreCourseProvider } from '@features/course/services/course';
import {
CoreCourseAccess,
CoreCourseOptionsHandler,
CoreCourseOptionsHandlerData,
} from '@features/course/services/course-options-delegate';
import { CoreEnrolledCourseDataWithExtraInfoAndOptions } from '@features/courses/services/courses-helper';
import { makeSingleton } from '@singletons';
import { AddonCourseCompletion } from '../coursecompletion';
/**
* Handler to inject an option into the course main menu.
*/
@Injectable({ providedIn: 'root' })
export class AddonCourseCompletionCourseOptionHandlerService implements CoreCourseOptionsHandler {
name = 'AddonCourseCompletion';
priority = 200;
/**
* @inheritdoc
*/
async isEnabled(): Promise<boolean> {
return AddonCourseCompletion.isPluginViewEnabled();
}
/**
* @inheritdoc
*/
async isEnabledForCourse(courseId: number, accessData: CoreCourseAccess): Promise<boolean> {
if (accessData && accessData.type == CoreCourseProvider.ACCESS_GUEST) {
return false; // Not enabled for guests.
}
const courseEnabled = await AddonCourseCompletion.isPluginViewEnabledForCourse(courseId);
// If is not enabled in the course, is not enabled for the user.
if (!courseEnabled) {
return false;
}
return AddonCourseCompletion.isPluginViewEnabledForUser(courseId);
}
/**
* @inheritdoc
*/
getDisplayData(): CoreCourseOptionsHandlerData | Promise<CoreCourseOptionsHandlerData> {
return {
title: 'addon.coursecompletion.completionmenuitem',
class: 'addon-coursecompletion-course-handler',
page: 'coursecompletion',
};
}
/**
* @inheritdoc
*/
async invalidateEnabledForCourse(courseId: number): Promise<void> {
await AddonCourseCompletion.invalidateCourseCompletion(courseId);
}
/**
* @inheritdoc
*/
async prefetch(course: CoreEnrolledCourseDataWithExtraInfoAndOptions): Promise<void> {
try {
await AddonCourseCompletion.getCompletion(course.id, undefined, {
getFromCache: false,
emergencyCache: false,
});
} catch (error) {
if (error && error.errorcode == 'notenroled') {
// Not enrolled error, probably a teacher. Ignore error.
} else {
throw error;
}
}
}
}
export const AddonCourseCompletionCourseOptionHandler = makeSingleton(AddonCourseCompletionCourseOptionHandlerService);

View File

@ -0,0 +1,98 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { CoreUserProfile, CoreUserProvider } from '@features/user/services/user';
import { CoreUserProfileHandler, CoreUserDelegateService, CoreUserProfileHandlerData } from '@features/user/services/user-delegate';
import { CoreNavigator } from '@services/navigator';
import { makeSingleton } from '@singletons';
import { CoreEvents } from '@singletons/events';
import { AddonCourseCompletion } from '../coursecompletion';
/**
* Profile course completion handler.
*/
@Injectable({ providedIn: 'root' })
export class AddonCourseCompletionUserHandlerService implements CoreUserProfileHandler {
name = 'AddonCourseCompletion';
type = CoreUserDelegateService.TYPE_NEW_PAGE;
priority = 200;
protected enabledCache = {};
constructor() {
CoreEvents.on(CoreEvents.LOGOUT, () => {
this.enabledCache = {};
});
CoreEvents.on(CoreUserProvider.PROFILE_REFRESHED, (data) => {
const cacheKey = data.userId + '-' + data.courseId;
delete this.enabledCache[cacheKey];
});
}
/**
* @inheritdoc
*/
async isEnabled(): Promise<boolean> {
return AddonCourseCompletion.isPluginViewEnabled();
}
/**
* @inheritdoc
*/
async isEnabledForUser(user: CoreUserProfile, courseId?: number): Promise<boolean> {
if (!courseId) {
return false;
}
const courseEnabled = await AddonCourseCompletion.isPluginViewEnabledForCourse(courseId);
// If is not enabled in the course, is not enabled for the user.
if (!courseEnabled) {
return false;
}
const cacheKey = user.id + '-' + courseId;
if (typeof this.enabledCache[cacheKey] !== 'undefined') {
return this.enabledCache[cacheKey];
}
const enabled = await AddonCourseCompletion.isPluginViewEnabledForUser(courseId, user.id);
this.enabledCache[cacheKey] = enabled;
return enabled;
}
/**
* @inheritdoc
*/
getDisplayData(): CoreUserProfileHandlerData {
return {
icon: 'fas-tasks',
title: 'addon.coursecompletion.coursecompletion',
class: 'addon-coursecompletion-handler',
action: (event, user, courseId): void => {
event.preventDefault();
event.stopPropagation();
CoreNavigator.navigateToSitePath('/coursecompletion', {
params: { courseId, userId: user.id },
});
},
};
}
}
export const AddonCourseCompletionUserHandler = makeSingleton(AddonCourseCompletionUserHandlerService);

View File

@ -117,6 +117,7 @@ import { CoreSitePluginsAssignSubmissionComponent } from '@features/siteplugins/
// Import addon providers. Do not import database module because it causes circular dependencies.
import { ADDON_BADGES_SERVICES } from '@addons/badges/badges.module';
import { ADDON_CALENDAR_SERVICES } from '@addons/calendar/calendar.module';
import { ADDON_COURSECOMPLETION_SERVICES } from '@addons/coursecompletion/coursecompletion.module';
// @todo import { ADDON_COMPETENCY_SERVICES } from '@addons/competency/competency.module';
import { ADDON_MESSAGEOUTPUT_SERVICES } from '@addons/messageoutput/messageoutput.module';
import { ADDON_MESSAGES_SERVICES } from '@addons/messages/messages.module';
@ -281,6 +282,7 @@ export class CoreCompileProvider {
...extraProviders,
...ADDON_BADGES_SERVICES,
...ADDON_CALENDAR_SERVICES,
...ADDON_COURSECOMPLETION_SERVICES,
// @todo ...ADDON_COMPETENCY_SERVICES,
...ADDON_MESSAGEOUTPUT_SERVICES,
...ADDON_MESSAGES_SERVICES,

View File

@ -820,9 +820,22 @@ export class CoreUserProvider {
}
}
export const CoreUser = makeSingleton(CoreUserProvider);
declare module '@singletons/events' {
/**
* Augment CoreEventsData interface with events specific to this service.
*
* @see https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation
*/
export interface CoreEventsData {
[CoreUserProvider.PROFILE_REFRESHED]: CoreUserProfileRefreshedData;
[CoreUserProvider.PROFILE_PICTURE_UPDATED]: CoreUserProfilePictureUpdatedData;
}
}
/**
* Data passed to PROFILE_REFRESHED event.
*/

View File

@ -309,7 +309,8 @@ img[alt] {
// Activity modules
.core-module-icon {
--size: 24px;
width: auto;
width: var(--size);
height: var(--size);
max-width: var(--size);
max-height: var(--size);
}