Merge pull request #2680 from crazyserver/MOBILE-3636

Mobile 3636
main
Dani Palou 2021-02-24 09:59:34 +01:00 committed by GitHub
commit fc39c3e30e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
112 changed files with 13018 additions and 367 deletions

View File

@ -51,8 +51,7 @@ export class AddonBadgesUserBadgesPage implements AfterViewInit, OnDestroy {
async ngAfterViewInit(): Promise<void> { async ngAfterViewInit(): Promise<void> {
await this.fetchInitialBadges(); await this.fetchInitialBadges();
this.badges.watchSplitViewOutlet(this.splitView); this.badges.start(this.splitView);
this.badges.start();
} }
/** /**

View File

@ -19,11 +19,13 @@ import { CoreSharedModule } from '@/core/shared.module';
import { CoreEditorComponentsModule } from '@features/editor/components/components.module'; import { CoreEditorComponentsModule } from '@features/editor/components/components.module';
import { AddonCalendarEditEventPage } from './edit-event.page'; import { AddonCalendarEditEventPage } from './edit-event.page';
import { CanLeaveGuard } from '@guards/can-leave';
const routes: Routes = [ const routes: Routes = [
{ {
path: '', path: '',
component: AddonCalendarEditEventPage, component: AddonCalendarEditEventPage,
canDeactivate: [CanLeaveGuard],
}, },
]; ];

View File

@ -13,7 +13,7 @@
// limitations under the License. // limitations under the License.
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { CoreSyncBaseProvider } from '@classes/base-sync'; import { CoreSyncBaseProvider, CoreSyncBlockedError } from '@classes/base-sync';
import { CoreApp } from '@services/app'; import { CoreApp } from '@services/app';
import { CoreEvents } from '@singletons/events'; import { CoreEvents } from '@singletons/events';
import { CoreSites } from '@services/sites'; import { CoreSites } from '@services/sites';
@ -27,9 +27,9 @@ import {
import { AddonCalendarOffline } from './calendar-offline'; import { AddonCalendarOffline } from './calendar-offline';
import { AddonCalendarHelper } from './calendar-helper'; import { AddonCalendarHelper } from './calendar-helper';
import { makeSingleton, Translate } from '@singletons'; import { makeSingleton, Translate } from '@singletons';
import { CoreError } from '@classes/errors/error';
import { CoreSync } from '@services/sync'; import { CoreSync } from '@services/sync';
import { CoreTextUtils } from '@services/utils/text'; import { CoreTextUtils } from '@services/utils/text';
import { CoreNetworkError } from '@classes/errors/network-error';
/** /**
* Service to sync calendar. * Service to sync calendar.
@ -52,21 +52,23 @@ export class AddonCalendarSyncProvider extends CoreSyncBaseProvider<AddonCalenda
* @param force Wether to force sync not depending on last execution. * @param force Wether to force sync not depending on last execution.
* @return Promise resolved if sync is successful, rejected if sync fails. * @return Promise resolved if sync is successful, rejected if sync fails.
*/ */
async syncAllEvents(siteId?: string, force?: boolean): Promise<void> { async syncAllEvents(siteId?: string, force = false): Promise<void> {
await this.syncOnSites('all calendar events', this.syncAllEventsFunc.bind(this, [force]), siteId); await this.syncOnSites('all calendar events', this.syncAllEventsFunc.bind(this, [force]), siteId);
} }
/** /**
* Sync all events on a site. * Sync all events on a site.
* *
* @param siteId Site ID to sync.
* @param force Wether to force sync not depending on last execution. * @param force Wether to force sync not depending on last execution.
* @param siteId Site ID to sync.
* @return Promise resolved if sync is successful, rejected if sync fails. * @return Promise resolved if sync is successful, rejected if sync fails.
*/ */
protected async syncAllEventsFunc(siteId: string, force?: boolean): Promise<void> { protected async syncAllEventsFunc(force = false, siteId?: string): Promise<void> {
const result = await (force ? this.syncEvents(siteId) : this.syncEventsIfNeeded(siteId)); const result = force
? await this.syncEvents(siteId)
: await this.syncEventsIfNeeded(siteId);
if (result && result.updated) { if (result?.updated) {
// Sync successful, send event. // Sync successful, send event.
CoreEvents.trigger<AddonCalendarSyncEvents>(AddonCalendarSyncProvider.AUTO_SYNCED, result, siteId); CoreEvents.trigger<AddonCalendarSyncEvents>(AddonCalendarSyncProvider.AUTO_SYNCED, result, siteId);
} }
@ -78,13 +80,13 @@ export class AddonCalendarSyncProvider extends CoreSyncBaseProvider<AddonCalenda
* @param siteId Site ID. If not defined, current site. * @param siteId Site ID. If not defined, current site.
* @return Promise resolved when the events are synced or if it doesn't need to be synced. * @return Promise resolved when the events are synced or if it doesn't need to be synced.
*/ */
async syncEventsIfNeeded(siteId?: string): Promise<void> { async syncEventsIfNeeded(siteId?: string): Promise<AddonCalendarSyncEvents | undefined> {
siteId = siteId || CoreSites.instance.getCurrentSiteId(); siteId = siteId || CoreSites.instance.getCurrentSiteId();
const needed = await this.isSyncNeeded(AddonCalendarSyncProvider.SYNC_ID, siteId); const needed = await this.isSyncNeeded(AddonCalendarSyncProvider.SYNC_ID, siteId);
if (needed) { if (needed) {
await this.syncEvents(siteId); return this.syncEvents(siteId);
} }
} }
@ -125,17 +127,12 @@ export class AddonCalendarSyncProvider extends CoreSyncBaseProvider<AddonCalenda
updated: false, updated: false,
}; };
let eventIds: number[] = []; const eventIds: number[] = await CoreUtils.instance.ignoreErrors(AddonCalendarOffline.instance.getAllEventsIds(siteId), []);
try {
eventIds = await AddonCalendarOffline.instance.getAllEventsIds(siteId);
} catch {
// No offline data found.
}
if (eventIds.length > 0) { if (eventIds.length > 0) {
if (!CoreApp.instance.isOnline()) { if (!CoreApp.instance.isOnline()) {
// Cannot sync in offline. // Cannot sync in offline.
throw new CoreError('Cannot sync while offline'); throw new CoreNetworkError();
} }
const promises = eventIds.map((eventId) => this.syncOfflineEvent(eventId, result, siteId)); const promises = eventIds.map((eventId) => this.syncOfflineEvent(eventId, result, siteId));
@ -175,10 +172,10 @@ export class AddonCalendarSyncProvider extends CoreSyncBaseProvider<AddonCalenda
if (CoreSync.instance.isBlocked(AddonCalendarProvider.COMPONENT, eventId, siteId)) { if (CoreSync.instance.isBlocked(AddonCalendarProvider.COMPONENT, eventId, siteId)) {
this.logger.debug('Cannot sync event ' + eventId + ' because it is blocked.'); this.logger.debug('Cannot sync event ' + eventId + ' because it is blocked.');
throw Translate.instance.instant( throw new CoreSyncBlockedError(Translate.instance.instant(
'core.errorsyncblocked', 'core.errorsyncblocked',
{ $a: Translate.instance.instant('addon.calendar.calendarevent') }, { $a: Translate.instance.instant('addon.calendar.calendarevent') },
); ));
} }
// First of all, check if the event has been deleted. // First of all, check if the event has been deleted.

View File

@ -34,6 +34,7 @@ import { CoreUserDelegate } from '@features/user/services/user-delegate';
import { AddonMessagesSendMessageUserHandler } from './services/handlers/user-send-message'; import { AddonMessagesSendMessageUserHandler } from './services/handlers/user-send-message';
import { Network, NgZone } from '@singletons'; import { Network, NgZone } from '@singletons';
import { AddonMessagesSync } from './services/messages-sync'; import { AddonMessagesSync } from './services/messages-sync';
import { AddonMessagesSyncCronHandler } from './services/handlers/sync-cron';
const mainMenuChildrenRoutes: Routes = [ const mainMenuChildrenRoutes: Routes = [
{ {
@ -61,7 +62,7 @@ const mainMenuChildrenRoutes: Routes = [
// Register handlers. // Register handlers.
CoreMainMenuDelegate.instance.registerHandler(AddonMessagesMainMenuHandler.instance); CoreMainMenuDelegate.instance.registerHandler(AddonMessagesMainMenuHandler.instance);
CoreCronDelegate.instance.register(AddonMessagesMainMenuHandler.instance); CoreCronDelegate.instance.register(AddonMessagesMainMenuHandler.instance);
CoreCronDelegate.instance.register(AddonMessagesPushClickHandler.instance); CoreCronDelegate.instance.register(AddonMessagesSyncCronHandler.instance);
CoreSettingsDelegate.instance.registerHandler(AddonMessagesSettingsHandler.instance); CoreSettingsDelegate.instance.registerHandler(AddonMessagesSettingsHandler.instance);
CoreContentLinksDelegate.instance.registerHandler(AddonMessagesIndexLinkHandler.instance); CoreContentLinksDelegate.instance.registerHandler(AddonMessagesIndexLinkHandler.instance);
CoreContentLinksDelegate.instance.registerHandler(AddonMessagesDiscussionLinkHandler.instance); CoreContentLinksDelegate.instance.registerHandler(AddonMessagesDiscussionLinkHandler.instance);

View File

@ -0,0 +1,85 @@
// (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 { conditionalRoutes } from '@/app/app-routing.module';
import { CoreSharedModule } from '@/core/shared.module';
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { CanLeaveGuard } from '@guards/can-leave';
import { CoreScreen } from '@services/screen';
import { AddonModAssignComponentsModule } from './components/components.module';
import { AddonModAssignEditPage } from './pages/edit/edit';
import { AddonModAssignIndexPage } from './pages/index/index.page';
import { AddonModAssignSubmissionListPage } from './pages/submission-list/submission-list.page';
import { AddonModAssignSubmissionReviewPage } from './pages/submission-review/submission-review';
const commonRoutes: Routes = [
{
path: ':courseId/:cmId',
component: AddonModAssignIndexPage,
},
{
path: ':courseId/:cmId/edit',
component: AddonModAssignEditPage,
canDeactivate: [CanLeaveGuard],
},
];
const mobileRoutes: Routes = [
...commonRoutes,
{
path: ':courseId/:cmId/submission',
component: AddonModAssignSubmissionListPage,
},
{
path: ':courseId/:cmId/submission/:submitId',
component: AddonModAssignSubmissionReviewPage,
canDeactivate: [CanLeaveGuard],
},
];
const tabletRoutes: Routes = [
...commonRoutes,
{
path: ':courseId/:cmId/submission',
component: AddonModAssignSubmissionListPage,
children: [
{
path: ':submitId',
component: AddonModAssignSubmissionReviewPage,
canDeactivate: [CanLeaveGuard],
},
],
},
];
const routes: Routes = [
...conditionalRoutes(mobileRoutes, () => CoreScreen.instance.isMobile),
...conditionalRoutes(tabletRoutes, () => CoreScreen.instance.isTablet),
];
@NgModule({
imports: [
RouterModule.forChild(routes),
CoreSharedModule,
AddonModAssignComponentsModule,
],
declarations: [
AddonModAssignIndexPage,
AddonModAssignSubmissionListPage,
AddonModAssignSubmissionReviewPage,
AddonModAssignEditPage,
],
})
export class AddonModAssignLazyModule {}

View File

@ -0,0 +1,70 @@
// (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 { APP_INITIALIZER, NgModule } from '@angular/core';
import { Routes } from '@angular/router';
import { CoreContentLinksDelegate } from '@features/contentlinks/services/contentlinks-delegate';
import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate';
import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate';
import { CoreMainMenuTabRoutingModule } from '@features/mainmenu/mainmenu-tab-routing.module';
import { CorePushNotificationsDelegate } from '@features/pushnotifications/services/push-delegate';
import { CoreCronDelegate } from '@services/cron';
import { CORE_SITE_SCHEMAS } from '@services/sites';
import { AddonModAssignComponentsModule } from './components/components.module';
import { AddonModAssignFeedbackModule } from './feedback/feedback.module';
import { OFFLINE_SITE_SCHEMA } from './services/database/assign';
import { AddonModAssignIndexLinkHandler } from './services/handlers/index-link';
import { AddonModAssignListLinkHandler } from './services/handlers/list-link';
import { AddonModAssignModuleHandler, AddonModAssignModuleHandlerService } from './services/handlers/module';
import { AddonModAssignPrefetchHandler } from './services/handlers/prefetch';
import { AddonModAssignPushClickHandler } from './services/handlers/push-click';
import { AddonModAssignSyncCronHandler } from './services/handlers/sync-cron';
import { AddonModAssignSubmissionModule } from './submission/submission.module';
const routes: Routes = [
{
path: AddonModAssignModuleHandlerService.PAGE_NAME,
loadChildren: () => import('./assign-lazy.module').then(m => m.AddonModAssignLazyModule),
},
];
@NgModule({
imports: [
CoreMainMenuTabRoutingModule.forChild(routes),
AddonModAssignComponentsModule,
AddonModAssignSubmissionModule,
AddonModAssignFeedbackModule,
],
providers: [
{
provide: CORE_SITE_SCHEMAS,
useValue: [OFFLINE_SITE_SCHEMA],
multi: true,
},
{
provide: APP_INITIALIZER,
multi: true,
deps: [],
useFactory: () => () => {
CoreCourseModuleDelegate.instance.registerHandler(AddonModAssignModuleHandler.instance);
CoreContentLinksDelegate.instance.registerHandler(AddonModAssignIndexLinkHandler.instance);
CoreContentLinksDelegate.instance.registerHandler(AddonModAssignListLinkHandler.instance);
CoreCourseModulePrefetchDelegate.instance.registerHandler(AddonModAssignPrefetchHandler.instance);
CoreCronDelegate.instance.register(AddonModAssignSyncCronHandler.instance);
CorePushNotificationsDelegate.instance.registerClickHandler(AddonModAssignPushClickHandler.instance);
},
},
],
})
export class AddonModAssignModule {}

View File

@ -0,0 +1,45 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule } from '@angular/core';
import { CoreSharedModule } from '@/core/shared.module';
import { CoreCourseComponentsModule } from '@features/course/components/components.module';
import { AddonModAssignIndexComponent } from './index/index';
import { AddonModAssignSubmissionComponent } from './submission/submission';
import { AddonModAssignSubmissionPluginComponent } from './submission-plugin/submission-plugin';
import { AddonModAssignFeedbackPluginComponent } from './feedback-plugin/feedback-plugin';
import { AddonModAssignEditFeedbackModalComponent } from './edit-feedback-modal/edit-feedback-modal';
@NgModule({
declarations: [
AddonModAssignIndexComponent,
AddonModAssignSubmissionComponent,
AddonModAssignSubmissionPluginComponent,
AddonModAssignFeedbackPluginComponent,
AddonModAssignEditFeedbackModalComponent,
],
imports: [
CoreSharedModule,
CoreCourseComponentsModule,
],
exports: [
AddonModAssignIndexComponent,
AddonModAssignSubmissionComponent,
AddonModAssignSubmissionPluginComponent,
AddonModAssignFeedbackPluginComponent,
AddonModAssignEditFeedbackModalComponent,
],
})
export class AddonModAssignComponentsModule {}

View File

@ -0,0 +1,21 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
</ion-buttons>
<ion-title>{{ plugin.name }}</ion-title>
<ion-buttons slot="end">
<ion-button (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
<ion-icon slot="icon-only" name="fas-times"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<form name="addon-mod_assign-edit-feedback-form" *ngIf="userId && plugin" #editFeedbackForm>
<addon-mod-assign-feedback-plugin [assign]="assign" [submission]="submission" [userId]="userId"
[plugin]="plugin" [edit]="true">
</addon-mod-assign-feedback-plugin>
<ion-button expand="block" (click)="done($event)">{{ 'core.done' | translate }}</ion-button>
</form>
</ion-content>

View File

@ -0,0 +1,100 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, Input, ViewChild, ElementRef } from '@angular/core';
import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils';
import { ModalController, Translate } from '@singletons';
import { AddonModAssignAssign, AddonModAssignPlugin, AddonModAssignSubmission } from '../../services/assign';
import { AddonModAssignFeedbackDelegate } from '../../services/feedback-delegate';
/**
* Modal that allows editing a feedback plugin.
*/
@Component({
selector: 'addon-mod-assign-edit-feedback-modal',
templateUrl: 'edit-feedback-modal.html',
})
export class AddonModAssignEditFeedbackModalComponent {
@Input() assign!: AddonModAssignAssign; // The assignment.
@Input() submission!: AddonModAssignSubmission; // The submission.
@Input() plugin!: AddonModAssignPlugin; // The plugin object.
@Input() userId!: number; // The user ID of the submission.
@ViewChild('editFeedbackForm') formElement?: ElementRef;
/**
* Close modal checking if there are changes first.
*
* @param data Data to return to the page.
*/
async closeModal(): Promise<void> {
const changed = await this.hasDataChanged();
if (changed) {
await CoreDomUtils.instance.showConfirm(Translate.instance.instant('core.confirmcanceledit'));
}
CoreDomUtils.instance.triggerFormCancelledEvent(this.formElement, CoreSites.instance.getCurrentSiteId());
ModalController.instance.dismiss();
}
/**
* Done editing.
*
* @param e Click event.
*/
done(e: Event): void {
e.preventDefault();
e.stopPropagation();
CoreDomUtils.instance.triggerFormSubmittedEvent(this.formElement, false, CoreSites.instance.getCurrentSiteId());
// Close the modal, sending the input data.
ModalController.instance.dismiss(this.getInputData());
}
/**
* Get the input data.
*
* @return Object with the data.
*/
protected getInputData(): Record<string, unknown> {
return CoreDomUtils.instance.getDataFromForm(document.forms['addon-mod_assign-edit-feedback-form']);
}
/**
* Check if data has changed.
*
* @return Promise resolved with boolean: whether the data has changed.
*/
protected async hasDataChanged(): Promise<boolean> {
const changed = await CoreUtils.instance.ignoreErrors(
AddonModAssignFeedbackDelegate.instance.hasPluginDataChanged(
this.assign,
this.submission,
this.plugin,
this.getInputData(),
this.userId,
),
true,
);
return !!changed;
}
}

View File

@ -0,0 +1,23 @@
<core-dynamic-component [component]="pluginComponent" [data]="data">
<!-- This content will be replaced by the component if found. -->
<core-loading [hideUntil]="pluginLoaded">
<ion-item class="ion-text-wrap" *ngIf="text.length > 0 || files.length > 0">
<ion-label>
<h2>{{ plugin.name }}</h2>
<ion-badge *ngIf="notSupported" color="primary">
{{ 'addon.mod_assign.feedbacknotsupported' | translate }}
</ion-badge>
<p *ngIf="text">
<core-format-text [component]="component" [componentId]="assign.cmid" [maxHeight]="80" [fullOnClick]="true"
[fullTitle]="plugin.name" [text]="text" contextLevel="module" [contextInstanceId]="assign.cmid"
[courseId]="assign.course">
</core-format-text>
</p>
<core-file *ngFor="let file of files" [file]="file" [component]="component" [componentId]="assign.cmid"
[alwaysDownload]="true">
</core-file>
</ion-label>
</ion-item>
</core-loading>
</core-dynamic-component>

View File

@ -0,0 +1,153 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, Input, OnInit, ViewChild, Type } from '@angular/core';
import { CoreError } from '@classes/errors/error';
import { CoreDynamicComponent } from '@components/dynamic-component/dynamic-component';
import { CoreWSExternalFile } from '@services/ws';
import { ModalController } from '@singletons';
import { AddonModAssignFeedbackCommentsTextData } from '../../feedback/comments/services/handler';
import {
AddonModAssignAssign,
AddonModAssignSubmission,
AddonModAssignPlugin,
AddonModAssignProvider,
AddonModAssign,
} from '../../services/assign';
import { AddonModAssignHelper, AddonModAssignPluginConfig } from '../../services/assign-helper';
import { AddonModAssignFeedbackDelegate } from '../../services/feedback-delegate';
import { AddonModAssignEditFeedbackModalComponent } from '../edit-feedback-modal/edit-feedback-modal';
/**
* Component that displays an assignment feedback plugin.
*/
@Component({
selector: 'addon-mod-assign-feedback-plugin',
templateUrl: 'addon-mod-assign-feedback-plugin.html',
})
export class AddonModAssignFeedbackPluginComponent implements OnInit {
@ViewChild(CoreDynamicComponent) dynamicComponent!: CoreDynamicComponent;
@Input() assign!: AddonModAssignAssign; // The assignment.
@Input() submission!: AddonModAssignSubmission; // The submission.
@Input() plugin!: AddonModAssignPlugin; // The plugin object.
@Input() userId!: number; // The user ID of the submission.
@Input() canEdit = false; // Whether the user can edit.
@Input() edit = false; // Whether the user is editing.
pluginComponent?: Type<unknown>; // Component to render the plugin.
data?: AddonModAssignFeedbackPluginData; // Data to pass to the component.
// Data to render the plugin if it isn't supported.
component = AddonModAssignProvider.COMPONENT;
text = '';
files: CoreWSExternalFile[] = [];
notSupported = false;
pluginLoaded = false;
/**
* Component being initialized.
*/
async ngOnInit(): Promise<void> {
if (!this.plugin) {
this.pluginLoaded = true;
return;
}
const name = AddonModAssignFeedbackDelegate.instance.getPluginName(this.plugin);
if (!name) {
this.pluginLoaded = true;
return;
}
this.plugin.name = name;
// Check if the plugin has defined its own component to render itself.
this.pluginComponent = await AddonModAssignFeedbackDelegate.instance.getComponentForPlugin(this.plugin);
if (this.pluginComponent) {
// Prepare the data to pass to the component.
this.data = {
assign: this.assign,
submission: this.submission,
plugin: this.plugin,
userId: this.userId,
configs: AddonModAssignHelper.instance.getPluginConfig(this.assign, 'assignfeedback', this.plugin.type),
edit: this.edit,
canEdit: this.canEdit,
};
} else {
// Data to render the plugin.
this.text = AddonModAssign.instance.getSubmissionPluginText(this.plugin);
this.files = AddonModAssign.instance.getSubmissionPluginAttachments(this.plugin);
this.notSupported = AddonModAssignFeedbackDelegate.instance.isPluginSupported(this.plugin.type);
this.pluginLoaded = true;
}
}
/**
* Open a modal to edit the feedback plugin.
*
* @return Promise resolved with the input data, rejected if cancelled.
*/
async editFeedback(): Promise<AddonModAssignFeedbackCommentsTextData> {
if (!this.canEdit) {
throw new CoreError('Cannot edit feedback');
}
// Create the navigation modal.
const modal = await ModalController.instance.create({
component: AddonModAssignEditFeedbackModalComponent,
componentProps: {
assign: this.assign,
submission: this.submission,
plugin: this.plugin,
userId: this.userId,
},
});
await modal.present();
const result = await modal.onDidDismiss();
if (typeof result.data == 'undefined') {
throw null; // User cancelled.
} else {
return result.data;
}
}
/**
* Invalidate the plugin data.
*
* @return Promise resolved when done.
*/
async invalidate(): Promise<void> {
await this.dynamicComponent.callComponentFunction('invalidate', []);
}
}
export type AddonModAssignFeedbackPluginData = {
assign: AddonModAssignAssign;
submission: AddonModAssignSubmission;
plugin: AddonModAssignPlugin;
configs: AddonModAssignPluginConfig;
edit: boolean;
canEdit: boolean;
userId: number;
};

View File

@ -0,0 +1,142 @@
<!-- Buttons to add to the header. -->
<core-navbar-buttons slot="end">
<core-context-menu>
<core-context-menu-item *ngIf="externalUrl" [priority]="900" [content]="'core.openinbrowser' | translate"
[href]="externalUrl" iconAction="fas-external-link-alt">
</core-context-menu-item>
<core-context-menu-item *ngIf="assign && (description || (assign.introattachments && assign.introattachments.length))"
[priority]="800" [content]="'core.moduleintro' | translate" (action)="expandDescription()"
iconAction="fas-arrow-right">
</core-context-menu-item>
<core-context-menu-item *ngIf="blog" [priority]="750" content="{{'addon.blog.blog' | translate}}"
iconAction="far-newspaper" (action)="gotoBlog()">
</core-context-menu-item>
<core-context-menu-item *ngIf="loaded && !hasOffline && isOnline" [priority]="700" [content]="'core.refresh' | translate"
(action)="doRefresh(null, $event)" [iconAction]="refreshIcon" [closeOnClick]="false">
</core-context-menu-item>
<core-context-menu-item *ngIf="loaded && hasOffline && isOnline" [priority]="600"
[content]="'core.settings.synchronizenow' | translate" (action)="doRefresh(null, $event, true)" [iconAction]="syncIcon"
[closeOnClick]="false">
</core-context-menu-item>
<core-context-menu-item *ngIf="prefetchStatusIcon" [priority]="500" [content]="prefetchText" (action)="prefetch($event)"
[iconAction]="prefetchStatusIcon" [closeOnClick]="false">
</core-context-menu-item>
<core-context-menu-item *ngIf="size" [priority]="400" [content]="'core.clearstoreddata' | translate:{$a: size}"
iconDescription="fas-archive" (action)="removeFiles($event)" iconAction="fas-trash" [closeOnClick]="false">
</core-context-menu-item>
</core-context-menu>
</core-navbar-buttons>
<!-- Content. -->
<core-loading [hideUntil]="loaded" class="core-loading-center">
<!-- Description and intro attachments. -->
<ion-card *ngIf="description" (click)="expandDescription($event)" class="core-clickable">
<ion-item class="ion-text-wrap">
<ion-label>
<core-format-text [text]="description" [component]="component" [componentId]="componentId" maxHeight="120"
contextLevel="module" [contextInstanceId]="module!.id" [courseId]="courseId" (click)="expandDescription($event)">
</core-format-text>
</ion-label>
</ion-item>
</ion-card>
<ion-card *ngIf="assign && assign.introattachments && assign.introattachments.length">
<core-file *ngFor="let file of assign.introattachments" [file]="file" [component]="component" [componentId]="componentId">
</core-file>
</ion-card>
<!-- Assign has something offline. -->
<ion-card class="core-warning-card" *ngIf="hasOffline">
<ion-item>
<ion-icon name="fas-exclamation-triangle" slot="start"></ion-icon>
<ion-label>{{ 'core.hasdatatosync' | translate: {$a: moduleName} }}</ion-label>
</ion-item>
</ion-card>
<!-- User can view all submissions (teacher). -->
<ng-container *ngIf="assign && canViewAllSubmissions">
<ion-list class="core-list-align-detail-right with-borders">
<ion-item class="ion-text-wrap" *ngIf="(groupInfo.separateGroups || groupInfo.visibleGroups)">
<ion-label id="addon-assign-groupslabel">
<ng-container *ngIf="groupInfo.separateGroups">{{'core.groupsseparate' | translate }}</ng-container>
<ng-container *ngIf="groupInfo.visibleGroups">{{'core.groupsvisible' | translate }}</ng-container>
</ion-label>
<ion-select [(ngModel)]="group" (ionChange)="setGroup(group)" aria-labelledby="addon-assign-groupslabel"
interface="action-sheet">
<ion-select-option *ngFor="let groupOpt of groupInfo.groups" [value]="groupOpt.id">
{{groupOpt.name}}
</ion-select-option>
</ion-select>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="timeRemaining">
<ion-label>
<h2>{{ 'addon.mod_assign.timeremaining' | translate }}</h2>
<p>{{ timeRemaining }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="lateSubmissions">
<ion-label>
<h2>{{ 'addon.mod_assign.latesubmissions' | translate }}</h2>
<p>{{ lateSubmissions }}</p>
</ion-label>
</ion-item>
<!-- Summary of all submissions. -->
<ion-item class="ion-text-wrap" *ngIf="summary && summary.participantcount" (click)="goToSubmissionList()" detail>
<ion-label>
<h2 *ngIf="assign.teamsubmission">{{ 'addon.mod_assign.numberofteams' | translate }}</h2>
<h2 *ngIf="!assign.teamsubmission">{{ 'addon.mod_assign.numberofparticipants' | translate }}</h2>
</ion-label>
<ion-badge slot="end" *ngIf="showNumbers" color="primary">
{{ summary.participantcount }}
</ion-badge>
</ion-item>
<!-- Summary of submissions with draft status. -->
<ion-item class="ion-text-wrap" *ngIf="assign.submissiondrafts && summary && summary.submissionsenabled"
[detail]="!showNumbers || summary.submissiondraftscount"
(click)="goToSubmissionList(submissionStatusDraft, !!summary.submissiondraftscount)">
<ion-label><h2>{{ 'addon.mod_assign.numberofdraftsubmissions' | translate }}</h2></ion-label>
<ion-badge slot="end" *ngIf="showNumbers" color="primary">
{{ summary.submissiondraftscount }}
</ion-badge>
</ion-item>
<!-- Summary of submissions with submitted status. -->
<ion-item class="ion-text-wrap" *ngIf="summary && summary.submissionsenabled"
[detail]="!showNumbers || summary.submissionssubmittedcount"
(click)="goToSubmissionList(submissionStatusSubmitted, !!summary.submissionssubmittedcount)">
<ion-label><h2>{{ 'addon.mod_assign.numberofsubmittedassignments' | translate }}</h2></ion-label>
<ion-badge slot="end" *ngIf="showNumbers" color="primary">
{{ summary.submissionssubmittedcount }}
</ion-badge>
</ion-item>
<!-- Summary of submissions that need grading. -->
<ion-item class="ion-text-wrap" *ngIf="summary && summary.submissionsenabled && !assign.teamsubmission && showNumbers"
[detail]="needsGradingAvalaible"
(click)="goToSubmissionList(needGrading, needsGradingAvalaible)">
<ion-label><h2>{{ 'addon.mod_assign.numberofsubmissionsneedgrading' | translate }}</h2></ion-label>
<ion-badge slot="end" color="primary">
{{ summary.submissionsneedgradingcount }}
</ion-badge>
</ion-item>
</ion-list>
<!-- Ungrouped users. -->
<ion-card *ngIf="assign.teamsubmission && summary && summary.warnofungroupedusers" class="core-info-card">
<ion-item>
<ion-icon name="fas-question-circle" slot="start"></ion-icon>
<ion-label>{{ 'addon.mod_assign.'+summary.warnofungroupedusers | translate }}</ion-label>
</ion-item>
</ion-card>
</ng-container>
<!-- If it's a student, display his submission. -->
<addon-mod-assign-submission *ngIf="loaded && !canViewAllSubmissions && canViewOwnSubmission" [courseId]="courseId"
[moduleId]="module!.id">
</addon-mod-assign-submission>
</core-loading>

View File

@ -0,0 +1,424 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, Optional, OnDestroy, OnInit, ViewChild } from '@angular/core';
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';
import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreTextUtils } from '@services/utils/text';
import { CoreTimeUtils } from '@services/utils/time';
import { CoreUtils } from '@services/utils/utils';
import { Translate } from '@singletons';
import { CoreEventObserver, CoreEvents } from '@singletons/events';
import {
AddonModAssign,
AddonModAssignAssign,
AddonModAssignGradedEventData,
AddonModAssignProvider,
AddonModAssignSubmissionGradingSummary,
AddonModAssignSubmissionSavedEventData,
AddonModAssignSubmittedForGradingEventData,
} from '../../services/assign';
import { AddonModAssignOffline } from '../../services/assign-offline';
import {
AddonModAssignAutoSyncData,
AddonModAssignSync,
AddonModAssignSyncProvider,
AddonModAssignSyncResult,
} from '../../services/assign-sync';
import { AddonModAssignSubmissionComponent } from '../submission/submission';
/**
* Component that displays an assignment.
*/
@Component({
selector: 'addon-mod-assign-index',
templateUrl: 'addon-mod-assign-index.html',
})
export class AddonModAssignIndexComponent extends CoreCourseModuleMainActivityComponent implements OnInit, OnDestroy {
@ViewChild(AddonModAssignSubmissionComponent) submissionComponent?: AddonModAssignSubmissionComponent;
component = AddonModAssignProvider.COMPONENT;
moduleName = 'assign';
assign?: AddonModAssignAssign; // The assign object.
canViewAllSubmissions = false; // Whether the user can view all submissions.
canViewOwnSubmission = false; // Whether the user can view their own submission.
timeRemaining?: string; // Message about time remaining to submit.
lateSubmissions?: string; // Message about late submissions.
showNumbers = true; // Whether to show number of submissions with each status.
summary?: AddonModAssignSubmissionGradingSummary; // The grading summary.
needsGradingAvalaible = false; // Whether we can see the submissions that need grading.
groupInfo: CoreGroupInfo = {
groups: [],
separateGroups: false,
visibleGroups: false,
defaultGroupId: 0,
};
// Status.
submissionStatusSubmitted = AddonModAssignProvider.SUBMISSION_STATUS_SUBMITTED;
submissionStatusDraft = AddonModAssignProvider.SUBMISSION_STATUS_DRAFT;
needGrading = AddonModAssignProvider.NEED_GRADING;
protected currentUserId?: number; // Current user ID.
protected currentSite?: CoreSite; // Current user ID.
protected syncEventName = AddonModAssignSyncProvider.AUTO_SYNCED;
// Observers.
protected savedObserver?: CoreEventObserver;
protected submittedObserver?: CoreEventObserver;
protected gradedObserver?: CoreEventObserver;
constructor(
protected content?: IonContent,
@Optional() courseContentsPage?: CoreCourseContentsPage,
) {
super('AddonModLessonIndexComponent', content, courseContentsPage);
}
/**
* Component being initialized.
*/
async ngOnInit(): Promise<void> {
super.ngOnInit();
this.currentUserId = CoreSites.instance.getCurrentSiteUserId();
this.currentSite = CoreSites.instance.getCurrentSite();
// Listen to events.
this.savedObserver = CoreEvents.on<AddonModAssignSubmissionSavedEventData>(
AddonModAssignProvider.SUBMISSION_SAVED_EVENT,
(data) => {
if (this.assign && data.assignmentId == this.assign.id && data.userId == this.currentUserId) {
// Assignment submission saved, refresh data.
this.showLoadingAndRefresh(true, false);
}
},
this.siteId,
);
this.submittedObserver = CoreEvents.on<AddonModAssignSubmittedForGradingEventData>(
AddonModAssignProvider.SUBMITTED_FOR_GRADING_EVENT,
(data) => {
if (this.assign && data.assignmentId == this.assign.id && data.userId == this.currentUserId) {
// Assignment submitted, check completion.
CoreCourse.instance.checkModuleCompletion(this.courseId!, this.module!.completiondata);
// Reload data since it can have offline data now.
this.showLoadingAndRefresh(true, false);
}
},
this.siteId,
);
this.gradedObserver = CoreEvents.on<AddonModAssignGradedEventData>(AddonModAssignProvider.GRADED_EVENT, (data) => {
if (this.assign && data.assignmentId == this.assign.id && data.userId == this.currentUserId) {
// Assignment graded, refresh data.
this.showLoadingAndRefresh(true, false);
}
}, this.siteId);
await this.loadContent(false, true);
try {
await AddonModAssign.instance.logView(this.assign!.id, this.assign!.name);
CoreCourse.instance.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.instance.ignoreErrors(AddonModAssign.instance.logGradingView(this.assign!.id, this.assign!.name));
} else if (this.canViewOwnSubmission) {
// User can only see their own submission, log view the user submission.
CoreUtils.instance.ignoreErrors(AddonModAssign.instance.logSubmissionView(this.assign!.id, this.assign!.name));
}
}
/**
* Expand the description.
*/
expandDescription(ev?: Event): void {
ev?.preventDefault();
ev?.stopPropagation();
if (this.assign && (this.description || this.assign.introattachments)) {
CoreTextUtils.instance.viewText(Translate.instance.instant('core.description'), this.description || '', {
component: this.component,
componentId: this.module!.id,
files: this.assign.introattachments,
filter: true,
contextLevel: 'module',
instanceId: this.module!.id,
courseId: this.courseId,
});
}
}
/**
* Get assignment data.
*
* @param refresh If it's refreshing content.
* @param sync If it should try to sync.
* @param showErrors If show errors to the user of hide them.
* @return Promise resolved when done.
*/
protected async fetchContent(refresh = false, sync = false, showErrors = false): Promise<void> {
// Get assignment data.
try {
this.assign = await AddonModAssign.instance.getAssignment(this.courseId!, this.module!.id);
this.dataRetrieved.emit(this.assign);
this.description = this.assign.intro;
if (sync) {
// Try to synchronize the assign.
await CoreUtils.instance.ignoreErrors(this.syncActivity(showErrors));
}
// Check if there's any offline data for this assign.
this.hasOffline = await AddonModAssignOffline.instance.hasAssignOfflineData(this.assign.id);
// Get assignment submissions.
const submissions = await AddonModAssign.instance.getSubmissions(this.assign.id, { cmId: this.module!.id });
const time = CoreTimeUtils.instance.timestamp();
this.canViewAllSubmissions = submissions.canviewsubmissions;
if (submissions.canviewsubmissions) {
// Calculate the messages to display about time remaining and late submissions.
if (this.assign.duedate > 0) {
if (this.assign.duedate - time <= 0) {
this.timeRemaining = Translate.instance.instant('addon.mod_assign.assignmentisdue');
} else {
this.timeRemaining = CoreTimeUtils.instance.formatDuration(this.assign.duedate - time, 3);
if (this.assign.cutoffdate) {
if (this.assign.cutoffdate > time) {
this.lateSubmissions = Translate.instance.instant(
'addon.mod_assign.latesubmissionsaccepted',
{ $a: CoreTimeUtils.instance.userDate(this.assign.cutoffdate * 1000) },
);
} else {
this.lateSubmissions = Translate.instance.instant('addon.mod_assign.nomoresubmissionsaccepted');
}
} else {
this.lateSubmissions = '';
}
}
} else {
this.timeRemaining = '';
this.lateSubmissions = '';
}
// Check if groupmode is enabled to avoid showing wrong numbers.
this.groupInfo = await CoreGroups.instance.getActivityGroupInfo(this.assign.cmid, false);
this.showNumbers = (this.groupInfo.groups && this.groupInfo.groups.length == 0) ||
this.currentSite!.isVersionGreaterEqualThan('3.5');
await this.setGroup(CoreGroups.instance.validateGroupId(this.group, this.groupInfo));
return;
}
try {
// Check if the user can view their own submission.
await AddonModAssign.instance.getSubmissionStatus(this.assign.id, { cmId: this.module!.id });
this.canViewOwnSubmission = true;
} catch (error) {
this.canViewOwnSubmission = false;
if (error.errorcode !== 'nopermission') {
throw error;
}
}
} finally {
this.fillContextMenu(refresh);
}
}
/**
* Set group to see the summary.
*
* @param groupId Group ID.
* @return Resolved when done.
*/
async setGroup(groupId = 0): Promise<void> {
this.group = groupId;
const submissionStatus = await AddonModAssign.instance.getSubmissionStatus(this.assign!.id, {
groupId: this.group,
cmId: this.module!.id,
});
this.summary = submissionStatus.gradingsummary;
if (!this.summary) {
this.needsGradingAvalaible = false;
return;
}
if (this.summary?.warnofungroupedusers === true) {
this.summary.warnofungroupedusers = 'ungroupedusers';
} else {
switch (this.summary?.warnofungroupedusers) {
case AddonModAssignProvider.WARN_GROUPS_REQUIRED:
this.summary.warnofungroupedusers = 'ungroupedusers';
break;
case AddonModAssignProvider.WARN_GROUPS_OPTIONAL:
this.summary.warnofungroupedusers = 'ungroupedusersoptional';
break;
default:
this.summary.warnofungroupedusers = '';
break;
}
}
this.needsGradingAvalaible =
(submissionStatus.gradingsummary?.submissionsneedgradingcount || 0) > 0 &&
this.currentSite!.isVersionGreaterEqualThan('3.2');
}
/**
* Go to view a list of submissions.
*
* @param status Status to see.
* @param hasSubmissions If the status has any submission.
*/
goToSubmissionList(status?: string, hasSubmissions = false): void {
if (typeof status != 'undefined' && !hasSubmissions && this.showNumbers) {
return;
}
const params: Params = {
groupId: this.group || 0,
moduleName: this.moduleName,
};
if (typeof status != 'undefined') {
params.status = status;
}
CoreNavigator.instance.navigate('submission', {
params,
});
}
/**
* Checks if sync has succeed from result sync data.
*
* @param result Data returned by the sync function.
* @return If succeed or not.
*/
protected hasSyncSucceed(result: AddonModAssignSyncResult): boolean {
if (result.updated) {
this.submissionComponent?.invalidateAndRefresh(false);
}
return result.updated;
}
/**
* Perform the invalidate content function.
*
* @return Resolved when done.
*/
protected async invalidateContent(): Promise<void> {
const promises: Promise<void>[] = [];
promises.push(AddonModAssign.instance.invalidateAssignmentData(this.courseId!));
if (this.assign) {
promises.push(AddonModAssign.instance.invalidateAllSubmissionData(this.assign.id));
if (this.canViewAllSubmissions) {
promises.push(AddonModAssign.instance.invalidateSubmissionStatusData(this.assign.id, undefined, this.group));
}
}
await Promise.all(promises).finally(() => {
this.submissionComponent?.invalidateAndRefresh(true);
});
}
/**
* User entered the page that contains the component.
*/
ionViewDidEnter(): void {
super.ionViewDidEnter();
this.submissionComponent?.ionViewDidEnter();
}
/**
* User left the page that contains the component.
*/
ionViewDidLeave(): void {
super.ionViewDidLeave();
this.submissionComponent?.ionViewDidLeave();
}
/**
* Compares sync event data with current data to check if refresh content is needed.
*
* @param syncEventData Data receiven on sync observer.
* @return True if refresh is needed, false otherwise.
*/
protected isRefreshSyncNeeded(syncEventData: AddonModAssignAutoSyncData): boolean {
if (this.assign && syncEventData.assignId == this.assign.id) {
if (syncEventData.warnings && syncEventData.warnings.length) {
// Show warnings.
CoreDomUtils.instance.showErrorModal(syncEventData.warnings[0]);
}
return true;
}
return false;
}
/**
* Performs the sync of the activity.
*
* @return Promise resolved when done.
*/
protected async sync(): Promise<void> {
await AddonModAssignSync.instance.syncAssign(this.assign!.id);
}
/**
* Component being destroyed.
*/
ngOnDestroy(): void {
super.ngOnDestroy();
this.savedObserver?.off();
this.submittedObserver?.off();
this.gradedObserver?.off();
}
}

View File

@ -0,0 +1,23 @@
<core-dynamic-component [component]="pluginComponent" [data]="data">
<!-- This content will be replaced by the component if found. -->
<core-loading [hideUntil]="pluginLoaded">
<ion-item class="ion-text-wrap" *ngIf="text.length > 0 || files.length > 0">
<ion-label>
<h2>{{ plugin.name }}</h2>
<ion-badge *ngIf="notSupported" color="primary">
{{ 'addon.mod_assign.submissionnotsupported' | translate }}
</ion-badge>
<p *ngIf="text">
<core-format-text [component]="component" [componentId]="assign.cmid" [maxHeight]="80" [fullOnClick]="true"
[fullTitle]="plugin.name" [text]="text" contextLevel="module" [contextInstanceId]="assign.cmid"
[courseId]="assign.course">
</core-format-text>
</p>
<core-file *ngFor="let file of files" [file]="file" [component]="component" [componentId]="assign.cmid"
[alwaysDownload]="true">
</core-file>
</ion-label>
</ion-item>
</core-loading>
</core-dynamic-component>

View File

@ -0,0 +1,115 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, Input, OnInit, Type, ViewChild } from '@angular/core';
import { CoreDynamicComponent } from '@components/dynamic-component/dynamic-component';
import { CoreWSExternalFile } from '@services/ws';
import {
AddonModAssignAssign,
AddonModAssignSubmission,
AddonModAssignPlugin,
AddonModAssignProvider,
AddonModAssign,
} from '../../services/assign';
import { AddonModAssignHelper, AddonModAssignPluginConfig } from '../../services/assign-helper';
import { AddonModAssignSubmissionDelegate } from '../../services/submission-delegate';
import { FileEntry } from '@ionic-native/file/ngx';
/**
* Component that displays an assignment submission plugin.
*/
@Component({
selector: 'addon-mod-assign-submission-plugin',
templateUrl: 'addon-mod-assign-submission-plugin.html',
})
export class AddonModAssignSubmissionPluginComponent implements OnInit {
@ViewChild(CoreDynamicComponent) dynamicComponent!: CoreDynamicComponent;
@Input() assign!: AddonModAssignAssign; // The assignment.
@Input() submission!: AddonModAssignSubmission; // The submission.
@Input() plugin!: AddonModAssignPlugin; // The plugin object.
@Input() edit = false; // Whether the user is editing.
@Input() allowOffline = false; // Whether to allow offline.
pluginComponent?: Type<unknown>; // Component to render the plugin.
data?: AddonModAssignSubmissionPluginData; // Data to pass to the component.
// Data to render the plugin if it isn't supported.
component = AddonModAssignProvider.COMPONENT;
text = '';
files: (FileEntry | CoreWSExternalFile)[] = [];
notSupported = false;
pluginLoaded = false;
/**
* Component being initialized.
*/
async ngOnInit(): Promise<void> {
if (!this.plugin) {
this.pluginLoaded = true;
return;
}
const name = AddonModAssignSubmissionDelegate.instance.getPluginName(this.plugin);
if (!name) {
this.pluginLoaded = true;
return;
}
this.plugin.name = name;
// Check if the plugin has defined its own component to render itself.
this.pluginComponent = await AddonModAssignSubmissionDelegate.instance.getComponentForPlugin(this.plugin, this.edit);
if (this.pluginComponent) {
// Prepare the data to pass to the component.
this.data = {
assign: this.assign,
submission: this.submission,
plugin: this.plugin,
configs: AddonModAssignHelper.instance.getPluginConfig(this.assign, 'assignsubmission', this.plugin.type),
edit: this.edit,
allowOffline: this.allowOffline,
};
} else {
// Data to render the plugin.
this.text = AddonModAssign.instance.getSubmissionPluginText(this.plugin);
this.files = AddonModAssign.instance.getSubmissionPluginAttachments(this.plugin);
this.notSupported = AddonModAssignSubmissionDelegate.instance.isPluginSupported(this.plugin.type);
this.pluginLoaded = true;
}
}
/**
* Invalidate the plugin data.
*
* @return Promise resolved when done.
*/
async invalidate(): Promise<void> {
await this.dynamicComponent.callComponentFunction('invalidate', []);
}
}
export type AddonModAssignSubmissionPluginData = {
assign: AddonModAssignAssign;
submission: AddonModAssignSubmission;
plugin: AddonModAssignPlugin;
configs: AddonModAssignPluginConfig;
edit: boolean;
allowOffline: boolean;
};

View File

@ -0,0 +1,395 @@
<core-loading [hideUntil]="loaded" class="core-loading-center">
<!-- User and status of the submission. -->
<ion-item class="ion-text-wrap" *ngIf="!blindMarking && user" core-user-link [userId]="submitId" [courseId]="courseId"
[title]="user!.fullname">
<core-user-avatar [user]="user" slot="start"></core-user-avatar>
<ion-label>
<h2>{{ user!.fullname }}</h2>
<ng-container *ngTemplateOutlet="submissionStatus"></ng-container>
</ion-label>
<ng-container *ngTemplateOutlet="submissionStatusBadges"></ng-container>
</ion-item>
<!-- Status of the submission if user is blinded. -->
<ion-item class="ion-text-wrap" *ngIf="blindMarking && !user">
<ion-label>
<h2>{{ 'addon.mod_assign.hiddenuser' | translate }} {{blindId}}</h2>
<ng-container *ngTemplateOutlet="submissionStatus"></ng-container>
</ion-label>
<ng-container *ngTemplateOutlet="submissionStatusBadges"></ng-container>
</ion-item>
<!-- Status of the submission in the rest of cases. -->
<ion-item class="ion-text-wrap" *ngIf="(blindMarking && user) || (!blindMarking && !user)">
<ion-label>
<h2>{{ 'addon.mod_assign.submissionstatus' | translate }}</h2>
<ng-container *ngTemplateOutlet="submissionStatus"></ng-container>
</ion-label>
<ng-container *ngTemplateOutlet="submissionStatusBadges"></ng-container>
</ion-item>
<!-- Tabs: see the submission or grade it. -->
<core-tabs [selectedIndex]="selectedTab" [hideUntil]="loaded" parentScrollable="true" (ionChange)="tabSelected($event)">
<!-- View the submission tab. -->
<core-tab [title]="'addon.mod_assign.submission' | translate" id="submission">
<ng-template>
<addon-mod-assign-submission-plugin *ngFor="let plugin of submissionPlugins"
[assign]="assign" [submission]="userSubmission" [plugin]="plugin">
</addon-mod-assign-submission-plugin>
<!-- Render some data about the submission. -->
<ion-item class="ion-text-wrap"
*ngIf="userSubmission && userSubmission!.status != statusNew && userSubmission!.timemodified">
<ion-label>
<h2>{{ 'addon.mod_assign.timemodified' | translate }}</h2>
<p>{{ userSubmission!.timemodified * 1000 | coreFormatDate }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="timeRemaining" [ngClass]="[timeRemainingClass]">
<ion-label>
<h2>{{ 'addon.mod_assign.timeremaining' | translate }}</h2>
<p [innerHTML]="timeRemaining"></p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="fromDate && !isSubmittedForGrading">
<ion-label>
<p *ngIf="assign!.intro"
[innerHTML]="'addon.mod_assign.allowsubmissionsfromdatesummary' | translate: {'$a': fromDate}">
</p>
<p *ngIf="!assign!.intro"
[innerHTML]="'addon.mod_assign.allowsubmissionsanddescriptionfromdatesummary' | translate:
{'$a': fromDate}">
</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="assign!.duedate && !isSubmittedForGrading">
<ion-label>
<h2>{{ 'addon.mod_assign.duedate' | translate }}</h2>
<p *ngIf="assign!.duedate" >{{ assign!.duedate * 1000 | coreFormatDate }}</p>
<p *ngIf="!assign!.duedate" >{{ 'addon.mod_assign.duedateno' | translate }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="assign!.duedate && assign!.cutoffdate && isSubmittedForGrading">
<ion-label>
<h2>{{ 'addon.mod_assign.cutoffdate' | translate }}</h2>
<p>{{ assign!.cutoffdate * 1000 | coreFormatDate }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap"
*ngIf="assign!.duedate && lastAttempt?.extensionduedate && !isSubmittedForGrading">
<ion-label>
<h2>{{ 'addon.mod_assign.extensionduedate' | translate }}</h2>
<p>{{ lastAttempt!.extensionduedate * 1000 | coreFormatDate }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="currentAttempt && !isGrading">
<ion-label>
<h2>{{ 'addon.mod_assign.attemptnumber' | translate }}</h2>
<p *ngIf="assign!.maxattempts == unlimitedAttempts">
{{ 'addon.mod_assign.outof' | translate :
{'$a': {'current': currentAttempt, 'total': maxAttemptsText} } }}
</p>
<p *ngIf="assign!.maxattempts != unlimitedAttempts">
{{ 'addon.mod_assign.outof' | translate :
{'$a': {'current': currentAttempt, 'total': assign!.maxattempts} } }}
</p>
</ion-label>
</ion-item>
<!-- Add or edit submission. -->
<ion-item class="ion-text-wrap" *ngIf="canEdit">
<ion-label>
<div *ngIf="!unsupportedEditPlugins.length && !showErrorStatementEdit">
<!-- If has offline data, show edit. -->
<ion-button expand="block" class="ion-text-wrap" color="primary" *ngIf="hasOffline"
(click)="goToEdit()">
{{ 'addon.mod_assign.editsubmission' | translate }}
</ion-button>
<!-- If no submission or is new, show add submission. -->
<ion-button expand="block" class="ion-text-wrap" color="primary"
*ngIf="!hasOffline &&
(!userSubmission || !userSubmission!.status || userSubmission!.status == statusNew)"
(click)="goToEdit()">
{{ 'addon.mod_assign.addsubmission' | translate }}
</ion-button>
<!-- If reopened, show addfromprevious and addnewattempt. -->
<ng-container *ngIf="!hasOffline && userSubmission?.status == statusReopened">
<ion-button *ngIf="!isPreviousAttemptEmpty" expand="block" class="ion-text-wrap" color="primary"
(click)="copyPrevious()">
{{ 'addon.mod_assign.addnewattemptfromprevious' | translate }}
</ion-button>
<ion-button expand="block" class="ion-text-wrap" color="primary" (click)="goToEdit()">
{{ 'addon.mod_assign.addnewattempt' | translate }}
</ion-button>
</ng-container>
<!-- Else show editsubmission. -->
<ion-button expand="block" class="ion-text-wrap" color="primary"
*ngIf="!hasOffline && userSubmission && userSubmission!.status &&
userSubmission!.status != statusNew &&
userSubmission!.status != statusReopened" (click)="goToEdit()">
{{ 'addon.mod_assign.editsubmission' | translate }}
</ion-button>
</div>
<div *ngIf="unsupportedEditPlugins && unsupportedEditPlugins.length && !showErrorStatementEdit">
<p class="core-danger-item">{{ 'addon.mod_assign.erroreditpluginsnotsupported' | translate }}</p>
<p class="core-danger-item" *ngFor="let name of unsupportedEditPlugins">{{ name }}</p>
</div>
<div *ngIf="showErrorStatementEdit">
<p class="core-danger-item">{{ 'addon.mod_assign.cannoteditduetostatementsubmission' | translate }}</p>
</div>
</ion-label>
</ion-item>
<!-- Submit for grading form. -->
<ng-container *ngIf="canSubmit">
<ion-item class="ion-text-wrap" *ngIf="submissionStatement">
<ion-label>
<core-format-text [text]="submissionStatement" [filter]="false"></core-format-text>
</ion-label>
<ion-checkbox slot="end" name="submissionstatement" [(ngModel)]="acceptStatement">
</ion-checkbox>
</ion-item>
<!-- Submit button. -->
<ion-item class="ion-text-wrap" *ngIf="!showErrorStatementSubmit">
<ion-label>
<ion-button expand="block" class="ion-text-wrap"
(click)="submitForGrading(acceptStatement)">
{{ 'addon.mod_assign.submitassignment' | translate }}
</ion-button>
<p>{{ 'addon.mod_assign.submitassignment_help' | translate }}</p>
</ion-label>
</ion-item>
<!-- Error because we lack submissions statement. -->
<ion-item class="ion-text-wrap" *ngIf="showErrorStatementSubmit">
<ion-label>
<p class="core-danger-item">
{{ 'addon.mod_assign.cannotsubmitduetostatementsubmission' | translate }}
</p>
</ion-label>
</ion-item>
</ng-container>
<!-- Team members that need to submit it too. -->
<ion-item-divider class="ion-text-wrap" *ngIf="membersToSubmit && membersToSubmit.length > 0">
<ion-label><h2>{{ 'addon.mod_assign.userswhoneedtosubmit' | translate: {$a: ''} }}</h2></ion-label>
</ion-item-divider>
<ng-container *ngIf="membersToSubmit && membersToSubmit.length > 0 && !blindMarking">
<ng-container *ngFor="let user of membersToSubmit">
<ion-item class="ion-text-wrap" core-user-link [userId]="user.id"
[courseId]="courseId" [title]="user.fullname">
<core-user-avatar [user]="user" slot="start"></core-user-avatar>
<ion-label><h2>{{ user.fullname }}</h2></ion-label>
</ion-item>
</ng-container>
</ng-container>
<ng-container *ngIf="membersToSubmit && membersToSubmit.length > 0 && blindMarking">
<ng-container *ngFor="let blindId of membersToSubmitBlind">
<ion-item class="ion-text-wrap">
<ion-label>{{ 'addon.mod_assign.hiddenuser' | translate }} {{ blindId }}</ion-label>
</ion-item>
</ng-container>
</ng-container>
<!-- Submission is locked. -->
<ion-item class="ion-text-wrap" *ngIf="lastAttempt?.locked">
<ion-label><h2>{{ 'addon.mod_assign.submissionslocked' | translate }}</h2></ion-label>
</ion-item>
<!-- Editing status. -->
<ion-item class="ion-text-wrap"
*ngIf="lastAttempt && isSubmittedForGrading && lastAttempt!.caneditowner !== undefined"
[ngClass]="{submissioneditable: lastAttempt!.caneditowner, submissionnoteditable: !lastAttempt!.caneditowner}">
<ion-label>
<h2>{{ 'addon.mod_assign.editingstatus' | translate }}</h2>
<p *ngIf="lastAttempt!.caneditowner">{{ 'addon.mod_assign.submissioneditable' | translate }}</p>
<p *ngIf="!lastAttempt!.caneditowner">{{ 'addon.mod_assign.submissionnoteditable' | translate }}</p>
</ion-label>
</ion-item>
</ng-template>
</core-tab>
<!-- Grade the submission tab. -->
<core-tab [title]="'addon.mod_assign.grade' | translate" *ngIf="feedback || isGrading" id="grade">
<ng-template>
<!-- Current grade if method is advanced. -->
<ion-item class="ion-text-wrap core-grading-summary"
*ngIf="feedback?.gradefordisplay && (!isGrading || grade.method != 'simple')">
<ion-label>
<h2>{{ 'addon.mod_assign.currentgrade' | translate }}</h2>
<p><core-format-text [text]="feedback!.gradefordisplay" [filter]="false"></core-format-text></p>
</ion-label>
<ion-button slot="end" *ngIf="feedback!.advancedgrade" (click)="showAdvancedGrade()">
<ion-icon name="fas-search" slot="icon-only"></ion-icon>
</ion-button>
</ion-item>
<ng-container *ngIf="isGrading">
<!-- Numeric grade.
Use a text input because otherwise we cannot readthe value if it has an invalid character. -->
<ion-item class="ion-text-wrap" *ngIf="grade.method == 'simple' && !grade.scale">
<ion-label position="stacked">
<h2>{{ 'addon.mod_assign.gradeoutof' | translate: {$a: gradeInfo!.grade} }}</h2>
</ion-label>
<ion-input *ngIf="!grade.disabled" type="text" [(ngModel)]="grade.grade" min="0" [max]="gradeInfo!.grade"
[lang]="grade.lang">
</ion-input>
<p item-content *ngIf="grade.disabled">{{ 'addon.mod_assign.gradelocked' | translate }}</p>
</ion-item>
<!-- Grade using a scale. -->
<ion-item class="ion-text-wrap" *ngIf="grade.method == 'simple' && grade.scale">
<ion-label><h2>{{ 'addon.mod_assign.grade' | translate }}</h2></ion-label>
<ion-select [(ngModel)]="grade.grade" interface="action-sheet" [disabled]="grade.disabled">
<ion-select-option *ngFor="let grade of grade.scale" [value]="grade.value">
{{grade.label}}
</ion-select-option>
</ion-select>
</ion-item>
<!-- Outcomes. -->
<ion-item class="ion-text-wrap" *ngFor="let outcome of gradeInfo!.outcomes">
<ion-label><h2>{{ outcome.name }}</h2></ion-label>
<ion-select *ngIf="canSaveGrades && outcome.itemNumber" [(ngModel)]="outcome.selectedId"
interface="action-sheet" [disabled]="gradeInfo!.disabled">
<ion-select-option *ngFor="let grade of outcome.options" [value]="grade.value">
{{grade.label}}
</ion-select-option>
</ion-select>
<p item-content *ngIf="!canSaveGrades || !outcome.itemNumber">{{ outcome.selected }}</p>
</ion-item>
<!-- Gradebook grade for simple grading. -->
<ion-item class="ion-text-wrap" *ngIf="grade.method == 'simple'">
<ion-label>
<h2>{{ 'addon.mod_assign.currentgrade' | translate }}</h2>
<p *ngIf="grade.gradebookGrade && !grade.scale">
{{ grade.gradebookGrade }}
</p>
<p *ngIf="grade.gradebookGrade && grade.scale">
{{ grade.scale[grade.gradebookGrade].label }}
</p>
<p *ngIf="!grade.gradebookGrade">-</p>
</ion-label>
</ion-item>
</ng-container>
<addon-mod-assign-feedback-plugin *ngFor="let plugin of feedback!.plugins" [assign]="assign"
[submission]="userSubmission" [userId]="submitId" [plugin]="plugin" [canEdit]="canSaveGrades">
</addon-mod-assign-feedback-plugin>
<!-- Workflow status. -->
<ion-item class="ion-text-wrap" *ngIf="workflowStatusTranslationId">
<ion-label>
<h2>{{ 'addon.mod_assign.markingworkflowstate' | translate }}</h2>
<p>{{ workflowStatusTranslationId | translate }}</p>
</ion-label>
</ion-item>
<!--- Apply grade to all team members. -->
<ion-item class="ion-text-wrap" *ngIf="assign!.teamsubmission && canSaveGrades">
<ion-label>
<h2>{{ 'addon.mod_assign.groupsubmissionsettings' | translate }}</h2>
<p>{{ 'addon.mod_assign.applytoteam' | translate }}</p>
</ion-label>
<ion-toggle [(ngModel)]="grade.applyToAll"></ion-toggle>
</ion-item>
<!-- Attempt status. -->
<ng-container *ngIf="isGrading && assign!.attemptreopenmethod != attemptReopenMethodNone">
<ion-item class="ion-text-wrap">
<ion-label>
<h2>{{ 'addon.mod_assign.attemptsettings' | translate }}</h2>
<p *ngIf="assign!.maxattempts == unlimitedAttempts">
{{ 'addon.mod_assign.outof' | translate :
{'$a': {'current': currentAttempt, 'total': maxAttemptsText} } }}
</p>
<p *ngIf="assign!.maxattempts != unlimitedAttempts">
{{ 'addon.mod_assign.outof' | translate :
{'$a': {'current': currentAttempt, 'total': assign!.maxattempts} } }}
</p>
<p>
{{ 'addon.mod_assign.attemptreopenmethod' | translate }}:
{{ 'addon.mod_assign.attemptreopenmethod_' + assign!.attemptreopenmethod | translate }}
</p>
</ion-label>
</ion-item>
<ion-item *ngIf="canSaveGrades && allowAddAttempt" >
<ion-label>{{ 'addon.mod_assign.addattempt' | translate }}</ion-label>
<ion-toggle [(ngModel)]="grade.addAttempt"></ion-toggle>
</ion-item>
</ng-container>
<!-- Data about the grader (teacher who graded). -->
<ion-item class="ion-text-wrap" *ngIf="grader" core-user-link [userId]="grader!.id" [courseId]="courseId"
[title]="grader!.fullname" detail="true">
<core-user-avatar [user]="grader" slot="start"></core-user-avatar>
<ion-label>
<h2>{{ 'addon.mod_assign.gradedby' | translate }}</h2>
<h2>{{ grader!.fullname }}</h2>
<p *ngIf="feedback!.gradeddate">{{ feedback!.gradeddate * 1000 | coreFormatDate }}</p>
</ion-label>
</ion-item>
<!-- Grader is hidden, display only the grade date. -->
<ion-item class="ion-text-wrap" *ngIf="!grader && feedback!.gradeddate">
<ion-label>
<h2>{{ 'addon.mod_assign.gradedon' | translate }}</h2>
<p>{{ feedback!.gradeddate * 1000 | coreFormatDate }}</p>
</ion-label>
</ion-item>
<!-- Warning message if cannot save grades. -->
<ion-card *ngIf="isGrading && !canSaveGrades" class="core-warning-card">
<ion-item>
<ion-icon name="fas-exclamation-triangle" slot="start"></ion-icon>
<ion-label>
<p>{{ 'addon.mod_assign.cannotgradefromapp' | translate }}</p>
<ion-button expand="block" *ngIf="gradeUrl" [href]="gradeUrl" core-link >
{{ 'core.openinbrowser' | translate }}
<ion-icon name="fas-external-link-alt" slot="end"></ion-icon>
</ion-button>
</ion-label>
</ion-item>
</ion-card>
</ng-template>
</core-tab>
</core-tabs>
</core-loading>
<!-- Template to render some data regarding the submission status. -->
<ng-template #submissionStatus>
<ng-container *ngIf="assign && assign!.teamsubmission && lastAttempt">
<p *ngIf="lastAttempt!.submissiongroup && lastAttempt!.submissiongroupname">{{lastAttempt!.submissiongroupname}}</p>
<ng-container *ngIf="assign!.preventsubmissionnotingroup &&
!lastAttempt!.submissiongroup &&
(!lastAttempt!.usergroups || lastAttempt!.usergroups.length <= 0)">
<p class="text-danger"><strong>{{ 'addon.mod_assign.noteam' | translate }}</strong></p>
<p class="text-danger">{{ 'addon.mod_assign.noteam_desc' | translate }}</p>
</ng-container>
<ng-container *ngIf="assign!.preventsubmissionnotingroup &&
!lastAttempt!.submissiongroup &&
lastAttempt!.usergroups &&
lastAttempt!.usergroups.length > 1">
<p class="text-danger"><strong>{{ 'addon.mod_assign.multipleteams' | translate }}</strong></p>
<p class="text-danger">{{ 'addon.mod_assign.multipleteams_desc' | translate }}</p>
</ng-container>
<p *ngIf="!assign!.preventsubmissionnotingroup && !lastAttempt!.submissiongroup">
{{ 'addon.mod_assign.defaultteam' | translate }}
</p>
</ng-container>
</ng-template>
<ng-template #submissionStatusBadges>
<ion-badge slot="end" *ngIf="statusTranslated" [color]="statusColor">
{{ statusTranslated }}
</ion-badge>
<ion-badge slot="end" *ngIf="gradingStatusTranslationId" [color]="gradingColor">
{{ gradingStatusTranslationId | translate }}
</ion-badge>
</ng-template>

View File

@ -0,0 +1,30 @@
:host ::ng-deep {
div.latesubmission,
div.overdue {
border-bottom: 3px solid var(--danger) !important;
ion-icon {
color: var(--danger);
}
}
div.earlysubmission {
border-bottom: 3px solid var(--success) !important;
ion-icon {
color: var(--success);
}
}
div.submissioneditable p {
color: var(--red);
}
.core-grading-summary .advancedgrade {
display: none;
}
}
:host-context(body.dark) ::ng-deep {
div.submissioneditable p {
color: var(--red-light);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,47 @@
// (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 { APP_INITIALIZER, NgModule } from '@angular/core';
import { AddonModAssignFeedbackCommentsHandler } from './services/handler';
import { AddonModAssignFeedbackCommentsComponent } from './component/comments';
import { CoreSharedModule } from '@/core/shared.module';
import { CoreEditorComponentsModule } from '@features/editor/components/components.module';
import { AddonModAssignFeedbackDelegate } from '../../services/feedback-delegate';
@NgModule({
declarations: [
AddonModAssignFeedbackCommentsComponent,
],
imports: [
CoreSharedModule,
CoreEditorComponentsModule,
],
providers: [
{
provide: APP_INITIALIZER,
multi: true,
deps: [],
useFactory: () => () => {
AddonModAssignFeedbackDelegate.instance.registerHandler(AddonModAssignFeedbackCommentsHandler.instance);
},
},
],
exports: [
AddonModAssignFeedbackCommentsComponent,
],
entryComponents: [
AddonModAssignFeedbackCommentsComponent,
],
})
export class AddonModAssignFeedbackCommentsModule {}

View File

@ -0,0 +1,33 @@
<!-- Read only. -->
<ion-item class="ion-text-wrap" *ngIf="(text || canEdit) && !edit">
<ion-label>
<h2>{{ plugin.name }}</h2>
<p>
<core-format-text [component]="component" [componentId]="assign.cmid" [maxHeight]="80" [fullOnClick]="true"
[fullTitle]="plugin.name" [text]="text" contextLevel="module" [contextInstanceId]="assign.cmid"
[courseId]="assign.course">
</core-format-text>
</p>
</ion-label>
<div slot="end">
<div class="ion-text-end">
<ion-button fill="clear" *ngIf="canEdit" (click)="editComment()" color="dark">
<ion-icon name="fas-pen" slot="icon-only"></ion-icon>
</ion-button>
</div>
<ion-note *ngIf="!isSent" color="dark">
<ion-icon name="far-clock"></ion-icon>
{{ 'core.notsent' | translate }}
</ion-note>
</div>
</ion-item>
<!-- Edit -->
<ion-item class="ion-text-wrap" *ngIf="edit && loaded">
<ion-label></ion-label>
<core-rich-text-editor item-content [control]="control" [placeholder]="plugin.name"
name="assignfeedbackcomments_editor" [component]="component" [componentId]="assign.cmid" [autoSave]="true"
contextLevel="module" [contextInstanceId]="assign.cmid" elementId="assignfeedbackcomments_editor"
[draftExtraParams]="{userid: userId, action: 'grade'}">
</core-rich-text-editor>
</ion-item>

View File

@ -0,0 +1,161 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, OnInit, ElementRef } from '@angular/core';
import { FormBuilder, FormControl } from '@angular/forms';
import { AddonModAssignFeedbackPluginComponent } from '@addons/mod/assign/components/feedback-plugin/feedback-plugin';
import { AddonModAssign, AddonModAssignProvider } from '@addons/mod/assign/services/assign';
import { CoreTextUtils } from '@services/utils/text';
import {
AddonModAssignFeedbackCommentsDraftData,
AddonModAssignFeedbackCommentsHandler,
AddonModAssignFeedbackCommentsPluginData,
} from '../services/handler';
import { AddonModAssignFeedbackDelegate } from '@addons/mod/assign/services/feedback-delegate';
import { AddonModAssignOffline } from '@addons/mod/assign/services/assign-offline';
import { CoreUtils } from '@services/utils/utils';
/**
* Component to render a comments feedback plugin.
*/
@Component({
selector: 'addon-mod-assign-feedback-comments',
templateUrl: 'addon-mod-assign-feedback-comments.html',
})
export class AddonModAssignFeedbackCommentsComponent extends AddonModAssignFeedbackPluginComponent implements OnInit {
control?: FormControl;
component = AddonModAssignProvider.COMPONENT;
text = '';
isSent = false;
loaded = false;
protected element: HTMLElement;
constructor(
element: ElementRef,
protected fb: FormBuilder,
) {
super();
this.element = element.nativeElement;
}
/**
* Component being initialized.
*/
async ngOnInit(): Promise<void> {
try {
this.text = await this.getText();
if (!this.canEdit && !this.edit) {
// User cannot edit the comment. Show it full when clicked.
this.element.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
if (this.text) {
// Open a new state with the text.
CoreTextUtils.instance.viewText(this.plugin.name, this.text, {
component: this.component,
componentId: this.assign.cmid,
filter: true,
contextLevel: 'module',
instanceId: this.assign.cmid,
courseId: this.assign.course,
});
}
});
} else if (this.edit) {
this.control = this.fb.control(this.text);
}
} finally {
this.loaded = true;
}
}
/**
* Edit the comment.
*/
async editComment(): Promise<void> {
try {
const inputData = await this.editFeedback();
const text = AddonModAssignFeedbackCommentsHandler.instance.getTextFromInputData(this.plugin, inputData);
// Update the text and save it as draft.
this.isSent = false;
this.text = this.replacePluginfileUrls(text);
AddonModAssignFeedbackDelegate.instance.saveFeedbackDraft(this.assign.id, this.userId, this.plugin, {
text: text,
format: 1,
});
} catch {
// User cancelled, nothing to do.
}
}
/**
* Get the text for the plugin.
*
* @return Promise resolved with the text.
*/
protected async getText(): Promise<string> {
// Check if the user already modified the comment.
const draft: AddonModAssignFeedbackCommentsDraftData | undefined =
await AddonModAssignFeedbackDelegate.instance.getPluginDraftData(this.assign.id, this.userId, this.plugin);
if (draft) {
this.isSent = false;
return this.replacePluginfileUrls(draft.text);
}
// There is no draft saved. Check if we have anything offline.
const offlineData = await CoreUtils.instance.ignoreErrors(
AddonModAssignOffline.instance.getSubmissionGrade(this.assign.id, this.userId),
undefined,
);
if (offlineData && offlineData.plugindata && offlineData.plugindata.assignfeedbackcomments_editor) {
const pluginData = <AddonModAssignFeedbackCommentsPluginData>offlineData.plugindata;
// Save offline as draft.
this.isSent = false;
AddonModAssignFeedbackDelegate.instance.saveFeedbackDraft(
this.assign.id,
this.userId,
this.plugin,
pluginData.assignfeedbackcomments_editor,
);
return this.replacePluginfileUrls(pluginData.assignfeedbackcomments_editor.text);
}
// No offline data found, return online text.
this.isSent = true;
return AddonModAssign.instance.getSubmissionPluginText(this.plugin);
}
/**
* Replace @@PLUGINFILE@@ wildcards with the real URL of embedded files.
*
* @param Text to treat.
* @return Treated text.
*/
replacePluginfileUrls(text: string): string {
const files = this.plugin.fileareas && this.plugin.fileareas[0] && this.plugin.fileareas[0].files;
return CoreTextUtils.instance.replacePluginfileUrls(text, files || []);
}
}

View File

@ -0,0 +1,3 @@
{
"pluginname": "Feedback comments"
}

View File

@ -0,0 +1,268 @@
// (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 {
AddonModAssignPlugin,
AddonModAssignAssign,
AddonModAssignSubmission,
AddonModAssign,
AddonModAssignSavePluginData,
} from '@addons/mod/assign/services/assign';
import { AddonModAssignOffline } from '@addons/mod/assign/services/assign-offline';
import { AddonModAssignFeedbackHandler } from '@addons/mod/assign/services/feedback-delegate';
import { Injectable, Type } from '@angular/core';
import { CoreSites } from '@services/sites';
import { CoreTextUtils } from '@services/utils/text';
import { CoreUtils } from '@services/utils/utils';
import { CoreWSExternalFile } from '@services/ws';
import { makeSingleton } from '@singletons';
import { AddonModAssignFeedbackCommentsComponent } from '../component/comments';
/**
* Handler for comments feedback plugin.
*/
@Injectable( { providedIn: 'root' })
export class AddonModAssignFeedbackCommentsHandlerService implements AddonModAssignFeedbackHandler {
name = 'AddonModAssignFeedbackCommentsHandler';
type = 'comments';
// Store the data in this service so it isn't lost if the user performs a PTR in the page.
protected drafts: { [draftId: string]: AddonModAssignFeedbackCommentsDraftData } = {};
/**
* Get the text to submit.
*
* @param textUtils Text utils instance.
* @param plugin Plugin.
* @param inputData Data entered in the feedback edit form.
* @return Text to submit.
*/
getTextFromInputData(plugin: AddonModAssignPlugin, inputData: AddonModAssignFeedbackCommentsTextData): string {
const files = plugin.fileareas && plugin.fileareas[0] ? plugin.fileareas[0].files : [];
// The input data can have a string or an object with text and format. Get the text.
const text = inputData.assignfeedbackcomments_editor || '';
return CoreTextUtils.instance.restorePluginfileUrls(text, files || []);
}
/**
* Discard the draft data of the feedback plugin.
*
* @param assignId The assignment ID.
* @param userId User ID.
* @param siteId Site ID. If not defined, current site.
* @return If the function is async, it should return a Promise resolved when done.
*/
discardDraft(assignId: number, userId: number, siteId?: string): void {
const id = this.getDraftId(assignId, userId, siteId);
if (typeof this.drafts[id] != 'undefined') {
delete this.drafts[id];
}
}
/**
* Return the Component to use to display the plugin data.
* It's recommended to return the class of the component, but you can also return an instance of the component.
*
* @return The component (or promise resolved with component) to use, undefined if not found.
*/
getComponent(): Type<unknown> {
return AddonModAssignFeedbackCommentsComponent;
}
/**
* Return the draft saved data of the feedback plugin.
*
* @param assignId The assignment ID.
* @param userId User ID.
* @param siteId Site ID. If not defined, current site.
* @return Data (or promise resolved with the data).
*/
getDraft(assignId: number, userId: number, siteId?: string): AddonModAssignFeedbackCommentsDraftData | undefined {
const id = this.getDraftId(assignId, userId, siteId);
if (typeof this.drafts[id] != 'undefined') {
return this.drafts[id];
}
}
/**
* Get a draft ID.
*
* @param assignId The assignment ID.
* @param userId User ID.
* @param siteId Site ID. If not defined, current site.
* @return Draft ID.
*/
protected getDraftId(assignId: number, userId: number, siteId?: string): string {
siteId = siteId || CoreSites.instance.getCurrentSiteId();
return siteId + '#' + assignId + '#' + userId;
}
/**
* Get files used by this plugin.
* The files returned by this function will be prefetched when the user prefetches the assign.
*
* @param assign The assignment.
* @param submission The submission.
* @param plugin The plugin object.
* @return The files (or promise resolved with the files).
*/
getPluginFiles(
assign: AddonModAssignAssign,
submission: AddonModAssignSubmission,
plugin: AddonModAssignPlugin,
): CoreWSExternalFile[] {
return AddonModAssign.instance.getSubmissionPluginAttachments(plugin);
}
/**
* Check if the feedback data has changed for this plugin.
*
* @param assign The assignment.
* @param submission The submission.
* @param plugin The plugin object.
* @param inputData Data entered by the user for the feedback.
* @param userId User ID of the submission.
* @return Boolean (or promise resolved with boolean): whether the data has changed.
*/
async hasDataChanged(
assign: AddonModAssignAssign,
submission: AddonModAssignSubmission,
plugin: AddonModAssignPlugin,
inputData: AddonModAssignFeedbackCommentsTextData,
userId: number,
): Promise<boolean> {
// Get it from plugin or offline.
const offlineData = await CoreUtils.instance.ignoreErrors(
AddonModAssignOffline.instance.getSubmissionGrade(assign.id, userId),
undefined,
);
if (offlineData?.plugindata?.assignfeedbackcomments_editor) {
const pluginData = <AddonModAssignFeedbackCommentsPluginData>offlineData.plugindata;
return !!pluginData.assignfeedbackcomments_editor.text;
}
// No offline data found, get text from plugin.
const initialText = AddonModAssign.instance.getSubmissionPluginText(plugin);
const newText = AddonModAssignFeedbackCommentsHandler.instance.getTextFromInputData(plugin, inputData);
if (typeof newText == 'undefined') {
return false;
}
// Check if text has changed.
return initialText != newText;
}
/**
* Check whether the plugin has draft data stored.
*
* @param assignId The assignment ID.
* @param userId User ID.
* @param siteId Site ID. If not defined, current site.
* @return Boolean or promise resolved with boolean: whether the plugin has draft data.
*/
hasDraftData(assignId: number, userId: number, siteId?: string): boolean | Promise<boolean> {
const draft = this.getDraft(assignId, userId, siteId);
return !!draft;
}
/**
* Whether or not the handler is enabled on a site level.
*
* @return True or promise resolved with true if enabled.
*/
async isEnabled(): Promise<boolean> {
// In here we should check if comments is not disabled in site.
// But due to this is not a common comments place and it can be disabled separately into Moodle (disabling the plugin).
// We are leaving it always enabled. It's also a teacher's feature.
return true;
}
/**
* Prepare and add to pluginData the data to send to the server based on the draft data saved.
*
* @param assignId The assignment ID.
* @param userId User ID.
* @param plugin The plugin object.
* @param pluginData Object where to store the data to send.
* @param siteId Site ID. If not defined, current site.
* @return If the function is async, it should return a Promise resolved when done.
*/
prepareFeedbackData(
assignId: number,
userId: number,
plugin: AddonModAssignPlugin,
pluginData: AddonModAssignSavePluginData,
siteId?: string,
): void {
const draft = this.getDraft(assignId, userId, siteId);
if (draft) {
// Add some HTML to the text if needed.
draft.text = CoreTextUtils.instance.formatHtmlLines(draft.text);
pluginData.assignfeedbackcomments_editor = draft;
}
}
/**
* Save draft data of the feedback plugin.
*
* @param assignId The assignment ID.
* @param userId User ID.
* @param plugin The plugin object.
* @param data The data to save.
* @param siteId Site ID. If not defined, current site.
* @return If the function is async, it should return a Promise resolved when done.
*/
saveDraft(
assignId: number,
userId: number,
plugin: AddonModAssignPlugin,
data: AddonModAssignFeedbackCommentsDraftData,
siteId?: string,
): void {
if (data) {
this.drafts[this.getDraftId(assignId, userId, siteId)] = data;
}
}
}
export const AddonModAssignFeedbackCommentsHandler = makeSingleton(AddonModAssignFeedbackCommentsHandlerService);
export type AddonModAssignFeedbackCommentsTextData = {
// The text for this submission.
assignfeedbackcomments_editor: string; // eslint-disable-line @typescript-eslint/naming-convention
};
export type AddonModAssignFeedbackCommentsDraftData = {
text: string; // The text for this feedback.
format: number; // The format for this feedback.
};
export type AddonModAssignFeedbackCommentsPluginData = {
// Editor structure.
// eslint-disable-next-line @typescript-eslint/naming-convention
assignfeedbackcomments_editor: AddonModAssignFeedbackCommentsDraftData;
};

View File

@ -0,0 +1,11 @@
<!-- Read only. -->
<ion-item class="ion-text-wrap" *ngIf="files && files.length">
<ion-label>
<h2>{{plugin.name}}</h2>
<ng-container>
<core-file *ngFor="let file of files" [file]="file" [component]="component" [componentId]="assign.cmid"
[alwaysDownload]="true">
</core-file>
</ng-container>
</ion-label>
</ion-item>

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 { AddonModAssignFeedbackPluginComponent } from '@addons/mod/assign/components/feedback-plugin/feedback-plugin';
import { AddonModAssignProvider, AddonModAssign } from '@addons/mod/assign/services/assign';
import { Component, OnInit } from '@angular/core';
import { CoreWSExternalFile } from '@services/ws';
/**
* Component to render a edit pdf feedback plugin.
*/
@Component({
selector: 'addon-mod-assign-feedback-edit-pdf',
templateUrl: 'addon-mod-assign-feedback-editpdf.html',
})
export class AddonModAssignFeedbackEditPdfComponent extends AddonModAssignFeedbackPluginComponent implements OnInit {
component = AddonModAssignProvider.COMPONENT;
files: CoreWSExternalFile[] = [];
/**
* Component being initialized.
*/
async ngOnInit(): Promise<void> {
if (this.plugin) {
this.files = AddonModAssign.instance.getSubmissionPluginAttachments(this.plugin);
}
}
}

View File

@ -0,0 +1,45 @@
// (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 { APP_INITIALIZER, NgModule } from '@angular/core';
import { AddonModAssignFeedbackEditPdfHandler } from './services/handler';
import { AddonModAssignFeedbackEditPdfComponent } from './component/editpdf';
import { CoreSharedModule } from '@/core/shared.module';
import { AddonModAssignFeedbackDelegate } from '../../services/feedback-delegate';
@NgModule({
declarations: [
AddonModAssignFeedbackEditPdfComponent,
],
imports: [
CoreSharedModule,
],
providers: [
{
provide: APP_INITIALIZER,
multi: true,
deps: [],
useFactory: () => () => {
AddonModAssignFeedbackDelegate.instance.registerHandler(AddonModAssignFeedbackEditPdfHandler.instance);
},
},
],
exports: [
AddonModAssignFeedbackEditPdfComponent,
],
entryComponents: [
AddonModAssignFeedbackEditPdfComponent,
],
})
export class AddonModAssignFeedbackEditPdfModule {}

View File

@ -0,0 +1,3 @@
{
"pluginname": "Annotate PDF"
}

View File

@ -0,0 +1,73 @@
// (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 {
AddonModAssignPlugin,
AddonModAssignAssign,
AddonModAssignSubmission,
AddonModAssign,
} from '@addons/mod/assign/services/assign';
import { AddonModAssignFeedbackHandler } from '@addons/mod/assign/services/feedback-delegate';
import { Injectable, Type } from '@angular/core';
import { CoreWSExternalFile } from '@services/ws';
import { makeSingleton } from '@singletons';
import { AddonModAssignFeedbackEditPdfComponent } from '../component/editpdf';
/**
* Handler for edit pdf feedback plugin.
*/
@Injectable( { providedIn: 'root' })
export class AddonModAssignFeedbackEditPdfHandlerService implements AddonModAssignFeedbackHandler {
name = 'AddonModAssignFeedbackEditPdfHandler';
type = 'editpdf';
/**
* Return the Component to use to display the plugin data.
* It's recommended to return the class of the component, but you can also return an instance of the component.
*
* @return The component (or promise resolved with component) to use, undefined if not found.
*/
getComponent(): Type<unknown> {
return AddonModAssignFeedbackEditPdfComponent;
}
/**
* Get files used by this plugin.
* The files returned by this function will be prefetched when the user prefetches the assign.
*
* @param assign The assignment.
* @param submission The submission.
* @param plugin The plugin object.
* @return The files (or promise resolved with the files).
*/
getPluginFiles(
assign: AddonModAssignAssign,
submission: AddonModAssignSubmission,
plugin: AddonModAssignPlugin,
): CoreWSExternalFile[] {
return AddonModAssign.instance.getSubmissionPluginAttachments(plugin);
}
/**
* Whether or not the handler is enabled on a site level.
*
* @return True or promise resolved with true if enabled.
*/
async isEnabled(): Promise<boolean> {
return true;
}
}
export const AddonModAssignFeedbackEditPdfHandler = makeSingleton(AddonModAssignFeedbackEditPdfHandlerService);

View File

@ -0,0 +1,27 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule } from '@angular/core';
import { AddonModAssignFeedbackCommentsModule } from './comments/comments.module';
import { AddonModAssignFeedbackEditPdfModule } from './editpdf/editpdf.module';
import { AddonModAssignFeedbackFileModule } from './file/file.module';
@NgModule({
imports: [
AddonModAssignFeedbackCommentsModule,
AddonModAssignFeedbackEditPdfModule,
AddonModAssignFeedbackFileModule,
],
})
export class AddonModAssignFeedbackModule { }

View File

@ -0,0 +1,11 @@
<!-- Read only. -->
<ion-item class="ion-text-wrap" *ngIf="files && files.length">
<ion-label>
<h2>{{plugin.name}}</h2>
<ng-container>
<core-file *ngFor="let file of files" [file]="file" [component]="component" [componentId]="assign.cmid"
[alwaysDownload]="true">
</core-file>
</ng-container>
</ion-label>
</ion-item>

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 { AddonModAssignFeedbackPluginComponent } from '@addons/mod/assign/components/feedback-plugin/feedback-plugin';
import { AddonModAssign, AddonModAssignProvider } from '@addons/mod/assign/services/assign';
import { Component, OnInit } from '@angular/core';
import { CoreWSExternalFile } from '@services/ws';
/**
* Component to render a file feedback plugin.
*/
@Component({
selector: 'addon-mod-assign-feedback-file',
templateUrl: 'addon-mod-assign-feedback-file.html',
})
export class AddonModAssignFeedbackFileComponent extends AddonModAssignFeedbackPluginComponent implements OnInit {
component = AddonModAssignProvider.COMPONENT;
files: CoreWSExternalFile[] = [];
/**
* Component being initialized.
*/
async ngOnInit(): Promise<void> {
if (this.plugin) {
this.files = AddonModAssign.instance.getSubmissionPluginAttachments(this.plugin);
}
}
}

View File

@ -0,0 +1,45 @@
// (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 { APP_INITIALIZER, NgModule } from '@angular/core';
import { AddonModAssignFeedbackFileHandler } from './services/handler';
import { AddonModAssignFeedbackFileComponent } from './component/file';
import { CoreSharedModule } from '@/core/shared.module';
import { AddonModAssignFeedbackDelegate } from '../../services/feedback-delegate';
@NgModule({
declarations: [
AddonModAssignFeedbackFileComponent,
],
imports: [
CoreSharedModule,
],
providers: [
{
provide: APP_INITIALIZER,
multi: true,
deps: [],
useFactory: () => () => {
AddonModAssignFeedbackDelegate.instance.registerHandler(AddonModAssignFeedbackFileHandler.instance);
},
},
],
exports: [
AddonModAssignFeedbackFileComponent,
],
entryComponents: [
AddonModAssignFeedbackFileComponent,
],
})
export class AddonModAssignFeedbackFileModule {}

View File

@ -0,0 +1,3 @@
{
"pluginname": "File feedback"
}

View File

@ -0,0 +1,73 @@
// (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 {
AddonModAssignPlugin,
AddonModAssignAssign,
AddonModAssignSubmission,
AddonModAssign,
} from '@addons/mod/assign/services/assign';
import { AddonModAssignFeedbackHandler } from '@addons/mod/assign/services/feedback-delegate';
import { Injectable, Type } from '@angular/core';
import { CoreWSExternalFile } from '@services/ws';
import { makeSingleton } from '@singletons';
import { AddonModAssignFeedbackFileComponent } from '../component/file';
/**
* Handler for file feedback plugin.
*/
@Injectable( { providedIn: 'root' })
export class AddonModAssignFeedbackFileHandlerService implements AddonModAssignFeedbackHandler {
name = 'AddonModAssignFeedbackFileHandler';
type = 'file';
/**
* Return the Component to use to display the plugin data.
* It's recommended to return the class of the component, but you can also return an instance of the component.
*
* @return The component (or promise resolved with component) to use, undefined if not found.
*/
getComponent(): Type<unknown> {
return AddonModAssignFeedbackFileComponent;
}
/**
* Get files used by this plugin.
* The files returned by this function will be prefetched when the user prefetches the assign.
*
* @param assign The assignment.
* @param submission The submission.
* @param plugin The plugin object.
* @return The files (or promise resolved with the files).
*/
getPluginFiles(
assign: AddonModAssignAssign,
submission: AddonModAssignSubmission,
plugin: AddonModAssignPlugin,
): CoreWSExternalFile[] {
return AddonModAssign.instance.getSubmissionPluginAttachments(plugin);
}
/**
* Whether or not the handler is enabled on a site level.
*
* @return True or promise resolved with true if enabled.
*/
async isEnabled(): Promise<boolean> {
return true;
}
}
export const AddonModAssignFeedbackFileHandler = makeSingleton(AddonModAssignFeedbackFileHandlerService);

View File

@ -0,0 +1,104 @@
{
"acceptsubmissionstatement": "Please accept the submission statement.",
"addattempt": "Allow another attempt",
"addnewattempt": "Add a new attempt",
"addnewattemptfromprevious": "Add a new attempt based on previous submission",
"addsubmission": "Add submission",
"allowsubmissionsfromdate": "Allow submissions from",
"allowsubmissionsfromdatesummary": "This assignment will accept submissions from <strong>{{$a}}</strong>",
"allowsubmissionsanddescriptionfromdatesummary": "The assignment details and submission form will be available from <strong>{{$a}}</strong>",
"applytoteam": "Apply grades and feedback to entire group",
"assignmentisdue": "Assignment is due",
"attemptnumber": "Attempt number",
"attemptreopenmethod": "Attempts reopened",
"attemptreopenmethod_manual": "Manually",
"attemptreopenmethod_untilpass": "Automatically until pass",
"attemptsettings": "Attempt settings",
"cannotgradefromapp": "Certain grading methods are not yet supported by the app and cannot be modified.",
"cannoteditduetostatementsubmission": "You can't add or edit a submission in the app because the submission statement could not be retrieved from the site.",
"cannotsubmitduetostatementsubmission": "You can't make a submission in the app because the submission statement could not be retrieved from the site.",
"confirmsubmission": "Are you sure you want to submit your work for grading? You will not be able to make any more changes.",
"currentgrade": "Current grade in gradebook",
"cutoffdate": "Cut-off date",
"currentattempt": "This is attempt {{$a}}.",
"currentattemptof": "This is attempt {{$a.attemptnumber}} ( {{$a.maxattempts}} attempts allowed ).",
"defaultteam": "Default group",
"duedate": "Due date",
"duedateno": "No due date",
"duedatereached": "The due date for this assignment has now passed",
"editingstatus": "Editing status",
"editsubmission": "Edit submission",
"erroreditpluginsnotsupported": "You can't add or edit a submission in the app because certain plugins are not yet supported for editing.",
"errorshowinginformation": "Submission information cannot be displayed.",
"extensionduedate": "Extension due date",
"feedbacknotsupported": "This feedback is not supported by the app and may not contain all the information.",
"grade": "Grade",
"graded": "Graded",
"gradedby": "Graded by",
"gradedfollowupsubmit": "Graded - follow up submission received",
"gradenotsynced": "Grade not synced",
"gradedon": "Graded on",
"gradelocked": "This grade is locked or overridden in the gradebook.",
"gradeoutof": "Grade out of {{$a}}",
"gradingstatus": "Grading status",
"groupsubmissionsettings": "Group submission settings",
"hiddenuser": "Participant",
"latesubmissions": "Late submissions",
"latesubmissionsaccepted": "Allowed until {{$a}}",
"markingworkflowstate": "Marking workflow state",
"markingworkflowstateinmarking": "In marking",
"markingworkflowstateinreview": "In review",
"markingworkflowstatenotmarked": "Not marked",
"markingworkflowstatereadyforreview": "Marking completed",
"markingworkflowstatereadyforrelease": "Ready for release",
"markingworkflowstatereleased": "Released",
"modulenameplural": "Assignments",
"multipleteams": "Member of more than one group",
"multipleteams_desc": "The assignment requires submission in groups. You are a member of more than one group. To be able to submit you must be a member of only one group. Please contact your teacher to change your group membership.",
"noattempt": "No attempt",
"nomoresubmissionsaccepted": "Only allowed for participants who have been granted an extension",
"noonlinesubmissions": "This assignment does not require you to submit anything online",
"nosubmission": "Nothing has been submitted for this assignment",
"notallparticipantsareshown": "Participants who have not made a submission are not shown.",
"noteam": "Not a member of any group",
"noteam_desc": "This assignment requires submission in groups. You are not a member of any group, so you cannot create a submission. Please contact your teacher to be added to a group.",
"notgraded": "Not graded",
"numberofdraftsubmissions": "Drafts",
"numberofparticipants": "Participants",
"numberofsubmittedassignments": "Submitted",
"numberofsubmissionsneedgrading": "Needs grading",
"numberofteams": "Groups",
"numwords": "{{$a}} words",
"outof": "{{$a.current}} out of {{$a.total}}",
"overdue": "<font color=\"red\">Assignment is overdue by: {{$a}}</font>",
"submissioneditable": "Student can edit this submission",
"submissionnoteditable": "Student cannot edit this submission",
"submissionnotsupported": "This submission is not supported by the app and may not contain all the information.",
"submission": "Submission",
"submissionslocked": "This assignment is not accepting submissions",
"submissionstatus_draft": "Draft (not submitted)",
"submissionstatusheading": "Submission status",
"submissionstatus_marked": "Graded",
"submissionstatus_new": "No submission",
"submissionstatus_reopened": "Reopened",
"submissionstatus_submitted": "Submitted for grading",
"submissionstatus_": "No submission",
"submissionstatus": "Submission status",
"submissionteam": "Group",
"submitassignment_help": "Once this assignment is submitted you will not be able to make any more changes.",
"submitassignment": "Submit assignment",
"submittedearly": "Assignment was submitted {{$a}} early",
"submittedlate": "Assignment was submitted {{$a}} late",
"syncblockedusercomponent": "user grade",
"timemodified": "Last modified",
"timeremaining": "Time remaining",
"ungroupedusers": "The setting 'Require group to make submission' is enabled and some users are either not a member of any group, or are a member of more than one group, so are unable to make submissions.",
"ungroupedusersoptional": "The setting 'Students submit in groups' is enabled and some users are either not a member of any group, or are a member of more than one group. Please be aware that these students will submit as members of the 'Default group'.",
"unlimitedattempts": "Unlimited",
"userwithid": "User with ID {{id}}",
"userswhoneedtosubmit": "Users who need to submit: {{$a}}",
"viewsubmission": "View submission",
"warningsubmissionmodified": "The user submission was modified on the site.",
"warningsubmissiongrademodified": "The submission grade was modified on the site.",
"wordlimit": "Word limit"
}

View File

@ -0,0 +1,39 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
</ion-buttons>
<ion-title>
<core-format-text [text]="title" contextLevel="module" [contextInstanceId]="moduleId" [courseId]="courseId">
</core-format-text>
</ion-title>
<ion-buttons slot="end">
<ion-button fill="clear" (click)="save()" [attr.aria-label]="'core.save' | translate">
{{ 'core.save' | translate }}
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<core-loading [hideUntil]="loaded">
<ion-list *ngIf="userSubmission && userSubmission.plugins && userSubmission.plugins.length">
<!-- @todo: plagiarism_print_disclosure -->
<form name="addon-mod_assign-edit-form" #editSubmissionForm>
<!-- Submission statement. -->
<ion-item class="ion-text-wrap" *ngIf="submissionStatement">
<ion-label>
<core-format-text [text]="submissionStatement" [filter]="false">
</core-format-text>
</ion-label>
<ion-checkbox slot="end" name="submissionstatement" [(ngModel)]="submissionStatementAccepted"></ion-checkbox>
<!-- ion-checkbox doesn't use an input. Create a hidden input to hold the value. -->
<input item-content type="hidden" [ngModel]="submissionStatementAccepted" name="submissionstatement">
</ion-item>
<addon-mod-assign-submission-plugin *ngFor="let plugin of userSubmission.plugins" [assign]="assign"
[submission]="userSubmission" [plugin]="plugin" [edit]="true" [allowOffline]="allowOffline">
</addon-mod-assign-submission-plugin>
</form>
</ion-list>
</core-loading>
</ion-content>

View File

@ -0,0 +1,396 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, OnInit, OnDestroy, ViewChild, ElementRef } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { CoreError } from '@classes/errors/error';
import { CoreFileUploaderHelper } from '@features/fileuploader/services/fileuploader-helper';
import { CoreNavigator } from '@services/navigator';
import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
import { CoreSync } from '@services/sync';
import { CoreDomUtils } from '@services/utils/dom';
import { Translate } from '@singletons';
import { CoreEventActivityDataSentData, CoreEvents } from '@singletons/events';
import {
AddonModAssignAssign,
AddonModAssignSubmission,
AddonModAssignProvider,
AddonModAssign,
AddonModAssignSubmissionStatusOptions,
AddonModAssignGetSubmissionStatusWSResponse,
AddonModAssignSavePluginData,
AddonModAssignSubmissionSavedEventData,
AddonModAssignSubmittedForGradingEventData,
} from '../../services/assign';
import { AddonModAssignHelper } from '../../services/assign-helper';
import { AddonModAssignOffline } from '../../services/assign-offline';
import { AddonModAssignSync } from '../../services/assign-sync';
/**
* Page that allows adding or editing an assigment submission.
*/
@Component({
selector: 'page-addon-mod-assign-edit',
templateUrl: 'edit.html',
})
export class AddonModAssignEditPage implements OnInit, OnDestroy {
@ViewChild('editSubmissionForm') formElement?: ElementRef;
title: string; // Title to display.
assign?: AddonModAssignAssign; // Assignment.
courseId!: number; // Course ID the assignment belongs to.
moduleId!: number; // Module ID the submission belongs to.
userSubmission?: AddonModAssignSubmission; // The user submission.
allowOffline = false; // Whether offline is allowed.
submissionStatement?: string; // The submission statement.
submissionStatementAccepted = false; // Whether submission statement is accepted.
loaded = false; // Whether data has been loaded.
protected userId: number; // User doing the submission.
protected isBlind = false; // Whether blind is used.
protected editText: string; // "Edit submission" translated text.
protected saveOffline = false; // Whether to save data in offline.
protected hasOffline = false; // Whether the assignment has offline data.
protected isDestroyed = false; // Whether the component has been destroyed.
protected forceLeave = false; // To allow leaving the page without checking for changes.
constructor(
protected route: ActivatedRoute,
) {
this.userId = CoreSites.instance.getCurrentSiteUserId(); // Right now we can only edit current user's submissions.
this.editText = Translate.instance.instant('addon.mod_assign.editsubmission');
this.title = this.editText;
}
/**
* Component being initialized.
*/
ngOnInit(): void {
this.moduleId = CoreNavigator.instance.getRouteNumberParam('cmId')!;
this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId')!;
this.isBlind = !!CoreNavigator.instance.getRouteNumberParam('blindId');
this.fetchAssignment().finally(() => {
this.loaded = true;
});
}
/**
* Check if we can leave the page or not.
*
* @return Resolved if we can leave it, rejected if not.
*/
async ionViewCanLeave(): Promise<void> {
if (this.forceLeave) {
return;
}
// Check if data has changed.
const changed = await this.hasDataChanged();
if (changed) {
await CoreDomUtils.instance.showConfirm(Translate.instance.instant('core.confirmcanceledit'));
}
// Nothing has changed or user confirmed to leave. Clear temporary data from plugins.
AddonModAssignHelper.instance.clearSubmissionPluginTmpData(this.assign!, this.userSubmission, this.getInputData());
CoreDomUtils.instance.triggerFormCancelledEvent(this.formElement, CoreSites.instance.getCurrentSiteId());
}
/**
* Fetch assignment data.
*
* @return Promise resolved when done.
*/
protected async fetchAssignment(): Promise<void> {
const currentUserId = CoreSites.instance.getCurrentSiteUserId();
try {
// Get assignment data.
this.assign = await AddonModAssign.instance.getAssignment(this.courseId, this.moduleId);
this.title = this.assign.name || this.title;
if (!this.isDestroyed) {
// Block the assignment.
CoreSync.instance.blockOperation(AddonModAssignProvider.COMPONENT, this.assign.id);
}
// Wait for sync to be over (if any).
await AddonModAssignSync.instance.waitForSync(this.assign.id);
// Get submission status. Ignore cache to get the latest data.
const options: AddonModAssignSubmissionStatusOptions = {
userId: this.userId,
isBlind: this.isBlind,
cmId: this.assign.cmid,
filter: false,
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
};
let submissionStatus: AddonModAssignGetSubmissionStatusWSResponse;
try {
submissionStatus = await AddonModAssign.instance.getSubmissionStatus(this.assign.id, options);
this.userSubmission =
AddonModAssign.instance.getSubmissionObjectFromAttempt(this.assign, submissionStatus.lastattempt);
} catch (error) {
// Cannot connect. Get cached data.
options.filter = true;
options.readingStrategy = CoreSitesReadingStrategy.PreferCache;
submissionStatus = await AddonModAssign.instance.getSubmissionStatus(this.assign.id, options);
this.userSubmission =
AddonModAssign.instance.getSubmissionObjectFromAttempt(this.assign, submissionStatus.lastattempt);
// Check if the user can edit it in offline.
const canEditOffline =
await AddonModAssignHelper.instance.canEditSubmissionOffline(this.assign, this.userSubmission);
if (!canEditOffline) {
// Submission cannot be edited in offline, reject.
this.allowOffline = false;
throw error;
}
}
if (!submissionStatus.lastattempt?.canedit) {
// Can't edit. Reject.
throw new CoreError(Translate.instance.instant('core.nopermissions', { $a: this.editText }));
}
this.allowOffline = true; // If offline isn't allowed we shouldn't have reached this point.
// Only show submission statement if we are editing our own submission.
if (this.assign.requiresubmissionstatement && !this.assign.submissiondrafts && this.userId == currentUserId) {
this.submissionStatement = this.assign.submissionstatement;
} else {
this.submissionStatement = undefined;
}
try {
// Check if there's any offline data for this submission.
const offlineData = await AddonModAssignOffline.instance.getSubmission(this.assign.id, this.userId);
this.hasOffline = offlineData?.plugindata && Object.keys(offlineData.plugindata).length > 0;
} catch {
// No offline data found.
this.hasOffline = false;
}
} catch (error) {
CoreDomUtils.instance.showErrorModalDefault(error, 'Error getting assigment data.');
// Leave the player.
this.leaveWithoutCheck();
}
}
/**
* Get the input data.
*
* @return Input data.
*/
protected getInputData(): Record<string, unknown> {
return CoreDomUtils.instance.getDataFromForm(document.forms['addon-mod_assign-edit-form']);
}
/**
* Check if data has changed.
*
* @return Promise resolved with boolean: whether data has changed.
*/
protected async hasDataChanged(): Promise<boolean> {
// Usually the hasSubmissionDataChanged call will be resolved inmediately, causing the modal to be shown just an instant.
// We'll wait a bit before showing it to prevent this "blink".
const modal = await CoreDomUtils.instance.showModalLoading();
const data = this.getInputData();
return AddonModAssignHelper.instance.hasSubmissionDataChanged(this.assign!, this.userSubmission, data).finally(() => {
modal.dismiss();
});
}
/**
* Leave the view without checking for changes.
*/
protected leaveWithoutCheck(): void {
this.forceLeave = true;
CoreNavigator.instance.back();
}
/**
* Get data to submit based on the input data.
*
* @param inputData The input data.
* @return Promise resolved with the data to submit.
*/
protected prepareSubmissionData(inputData: Record<string, unknown>): Promise<AddonModAssignSavePluginData> {
// If there's offline data, always save it in offline.
this.saveOffline = this.hasOffline;
try {
return AddonModAssignHelper.instance.prepareSubmissionPluginData(
this.assign!,
this.userSubmission,
inputData,
this.hasOffline,
);
} catch (error) {
if (this.allowOffline && !this.saveOffline) {
// Cannot submit in online, prepare for offline usage.
this.saveOffline = true;
return AddonModAssignHelper.instance.prepareSubmissionPluginData(
this.assign!,
this.userSubmission,
inputData,
true,
);
}
throw error;
}
}
/**
* Save the submission.
*/
async save(): Promise<void> {
// Check if data has changed.
const changed = await this.hasDataChanged();
if (!changed) {
// Nothing to save, just go back.
this.leaveWithoutCheck();
return;
}
try {
await this.saveSubmission();
this.leaveWithoutCheck();
} catch (error) {
CoreDomUtils.instance.showErrorModalDefault(error, 'Error saving submission.');
}
}
/**
* Save the submission.
*
* @return Promise resolved when done.
*/
protected async saveSubmission(): Promise<void> {
const inputData = this.getInputData();
if (this.submissionStatement && (!inputData.submissionstatement || inputData.submissionstatement === 'false')) {
throw Translate.instance.instant('addon.mod_assign.acceptsubmissionstatement');
}
let modal = await CoreDomUtils.instance.showModalLoading();
let size = -1;
// Get size to ask for confirmation.
try {
size = await AddonModAssignHelper.instance.getSubmissionSizeForEdit(this.assign!, this.userSubmission!, inputData);
} catch (error) {
// Error calculating size, return -1.
size = -1;
}
modal.dismiss();
try {
// Confirm action.
await CoreFileUploaderHelper.instance.confirmUploadFile(size, true, this.allowOffline);
modal = await CoreDomUtils.instance.showModalLoading('core.sending', true);
const pluginData = await this.prepareSubmissionData(inputData);
if (!Object.keys(pluginData).length) {
// Nothing to save.
return;
}
let sent: boolean;
if (this.saveOffline) {
// Save submission in offline.
sent = false;
await AddonModAssignOffline.instance.saveSubmission(
this.assign!.id,
this.courseId,
pluginData,
this.userSubmission!.timemodified,
!this.assign!.submissiondrafts,
this.userId,
);
} else {
// Try to send it to server.
sent = await AddonModAssign.instance.saveSubmission(
this.assign!.id,
this.courseId,
pluginData,
this.allowOffline,
this.userSubmission!.timemodified,
!!this.assign!.submissiondrafts,
this.userId,
);
}
// Clear temporary data from plugins.
AddonModAssignHelper.instance.clearSubmissionPluginTmpData(this.assign!, this.userSubmission, inputData);
if (sent) {
CoreEvents.trigger<CoreEventActivityDataSentData>(CoreEvents.ACTIVITY_DATA_SENT, { module: 'assign' });
}
// Submission saved, trigger events.
CoreDomUtils.instance.triggerFormSubmittedEvent(this.formElement, sent, CoreSites.instance.getCurrentSiteId());
CoreEvents.trigger<AddonModAssignSubmissionSavedEventData>(
AddonModAssignProvider.SUBMISSION_SAVED_EVENT,
{
assignmentId: this.assign!.id,
submissionId: this.userSubmission!.id,
userId: this.userId,
},
CoreSites.instance.getCurrentSiteId(),
);
if (!this.assign!.submissiondrafts) {
// No drafts allowed, so it was submitted. Trigger event.
CoreEvents.trigger<AddonModAssignSubmittedForGradingEventData>(
AddonModAssignProvider.SUBMITTED_FOR_GRADING_EVENT,
{
assignmentId: this.assign!.id,
submissionId: this.userSubmission!.id,
userId: this.userId,
},
CoreSites.instance.getCurrentSiteId(),
);
}
} finally {
modal.dismiss();
}
}
/**
* Component being destroyed.
*/
ngOnDestroy(): void {
this.isDestroyed = true;
// Unblock the assignment.
if (this.assign) {
CoreSync.instance.unblockOperation(AddonModAssignProvider.COMPONENT, this.assign.id);
}
}
}

View File

@ -0,0 +1,22 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
</ion-buttons>
<ion-title>
<core-format-text [text]="title" contextLevel="module" [contextInstanceId]="module?.id" [courseId]="courseId">
</core-format-text>
</ion-title>
<ion-buttons slot="end">
<!-- The buttons defined by the component will be added in here. -->
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-refresher slot="fixed" [disabled]="!assignComponent?.loaded" (ionRefresh)="assignComponent?.doRefresh($event)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<addon-mod-assign-index [module]="module" [courseId]="courseId" (dataRetrieved)="updateData($event)"></addon-mod-assign-index>
</ion-content>

View File

@ -0,0 +1,68 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, OnInit, ViewChild } from '@angular/core';
import { CoreCourseWSModule } from '@features/course/services/course';
import { CoreNavigator } from '@services/navigator';
import { AddonModAssignIndexComponent } from '../../components/index/index';
import { AddonModAssignAssign } from '../../services/assign';
/**
* Page that displays an assign.
*/
@Component({
selector: 'page-addon-mod-assign-index',
templateUrl: 'index.html',
})
export class AddonModAssignIndexPage implements OnInit {
@ViewChild(AddonModAssignIndexComponent) assignComponent?: AddonModAssignIndexComponent;
title?: string;
module?: CoreCourseWSModule;
courseId?: number;
/**
* Component being initialized.
*/
ngOnInit(): void {
this.module = CoreNavigator.instance.getRouteParam('module');
this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId');
this.title = this.module?.name;
}
/**
* Update some data based on the assign instance.
*
* @param assign Assign instance.
*/
updateData(assign: AddonModAssignAssign): void {
this.title = assign.name || this.title;
}
/**
* User entered the page.
*/
ionViewDidEnter(): void {
this.assignComponent?.ionViewDidEnter();
}
/**
* User left the page.
*/
ionViewDidLeave(): void {
this.assignComponent?.ionViewDidLeave();
}
}

View File

@ -0,0 +1,85 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
</ion-buttons>
<ion-title>
<core-format-text [text]="title" contextLevel="module" [contextInstanceId]="moduleId" [courseId]="courseId">
</core-format-text>
</ion-title>
<ion-buttons slot="end"></ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<core-split-view>
<ion-refresher slot="fixed" [disabled]="!loaded || !submissions.loaded" (ionRefresh)="refreshList($event)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-loading [hideUntil]="loaded && submissions.loaded">
<core-empty-box *ngIf="!submissions || submissions.empty" icon="fas-file-signature"
[message]="'addon.mod_assign.submissionstatus_' | translate">
</core-empty-box>
<ion-list>
<ion-item class="ion-text-wrap" *ngIf="(groupInfo.separateGroups || groupInfo.visibleGroups)">
<ion-label id="addon-assign-groupslabel" *ngIf="groupInfo.separateGroups">
{{ 'core.groupsseparate' | translate }}
</ion-label>
<ion-label id="addon-assign-groupslabel" *ngIf="groupInfo.visibleGroups">
{{ 'core.groupsvisible' | translate }}
</ion-label>
<ion-select [(ngModel)]="groupId" (ionChange)="setGroup(groupId)" aria-labelledby="addon-assign-groupslabel"
interface="action-sheet" slot="end">
<ion-select-option *ngFor="let groupOpt of groupInfo.groups" [value]="groupOpt.id">
{{groupOpt.name}}
</ion-select-option>
</ion-select>
</ion-item>
<!-- List of submissions. -->
<ng-container *ngFor="let submission of submissions.items">
<ion-item class="ion-text-wrap" (click)="submissions.select(submission)"
[class.core-selected-item]="submissions.isSelected(submission)">
<core-user-avatar [user]="submission" [linkProfile]="false" slot="start"></core-user-avatar>
<ion-label>
<h2 *ngIf="submission.userfullname">{{submission.userfullname}}</h2>
<h2 *ngIf="!submission.userfullname">
{{ 'addon.mod_assign.hiddenuser' | translate }}{{submission.blindid}}
</h2>
<p *ngIf="assign && assign!.teamsubmission">
<span *ngIf="submission.groupname">{{submission.groupname}}</span>
<span *ngIf="assign!.preventsubmissionnotingroup && !submission.groupname && submission.noGroups
&& !submission.blindid" class="text-danger">
{{ 'addon.mod_assign.noteam' | translate }}
</span>
<span *ngIf="assign!.preventsubmissionnotingroup && !submission.groupname && submission.manyGroups
&& !submission.blindid" class="text-danger">
{{ 'addon.mod_assign.multipleteams' | translate }}
</span>
<span *ngIf="!assign!.preventsubmissionnotingroup && !submission.groupname">
{{ 'addon.mod_assign.defaultteam' | translate }}
</span>
</p>
<ion-badge class="ion-text-center ion-text-wrap" [color]="submission.statusColor"
*ngIf="submission.statusTranslated">
{{ submission.statusTranslated }}
</ion-badge>
<ion-badge class="ion-text-center ion-text-wrap" [color]="submission.gradingColor"
*ngIf="submission.gradingStatusTranslationId">
{{ submission.gradingStatusTranslationId | translate }}
</ion-badge>
</ion-label>
</ion-item>
</ng-container>
<ion-card class="ion-text-wrap core-warning-card" *ngIf="!haveAllParticipants">
<ion-item>
<ion-icon name="fas-exclamation-triangle" slot="start"></ion-icon>
<ion-label>{{ 'addon.mod_assign.notallparticipantsareshown' | translate }}</ion-label>
</ion-item>
</ion-card>
</ion-list>
</core-loading>
</core-split-view>
</ion-content>

View File

@ -0,0 +1,393 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, OnDestroy, AfterViewInit, ViewChild } from '@angular/core';
import { ActivatedRoute, ActivatedRouteSnapshot, Params } from '@angular/router';
import { CorePageItemsListManager } from '@classes/page-items-list-manager';
import { CoreSplitViewComponent } from '@components/split-view/split-view';
import { IonRefresher } from '@ionic/angular';
import { CoreGroupInfo, CoreGroups } from '@services/groups';
import { CoreNavigator } from '@services/navigator';
import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils';
import { Translate } from '@singletons';
import { CoreEventObserver, CoreEvents } from '@singletons/events';
import { CoreObject } from '@singletons/object';
import {
AddonModAssignAssign,
AddonModAssignSubmission,
AddonModAssignProvider,
AddonModAssign,
AddonModAssignGradedEventData,
} from '../../services/assign';
import { AddonModAssignHelper, AddonModAssignSubmissionFormatted } from '../../services/assign-helper';
import { AddonModAssignOffline } from '../../services/assign-offline';
import {
AddonModAssignSyncProvider,
AddonModAssignSync,
AddonModAssignManualSyncData,
AddonModAssignAutoSyncData,
} from '../../services/assign-sync';
/**
* Page that displays a list of submissions of an assignment.
*/
@Component({
selector: 'page-addon-mod-assign-submission-list',
templateUrl: 'submission-list.html',
})
export class AddonModAssignSubmissionListPage implements AfterViewInit, OnDestroy {
@ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent;
title = ''; // Title to display.
assign?: AddonModAssignAssign; // Assignment.
submissions: AddonModAssignSubmissionListManager; // List of submissions
loaded = false; // Whether data has been loaded.
haveAllParticipants = true; // Whether all participants have been loaded.
groupId = 0; // Group ID to show.
courseId!: number; // Course ID the assignment belongs to.
moduleId!: number; // Module ID the submission belongs to.
groupInfo: CoreGroupInfo = {
groups: [],
separateGroups: false,
visibleGroups: false,
defaultGroupId: 0,
};
protected selectedStatus?: string; // The status to see.
protected gradedObserver: CoreEventObserver; // Observer to refresh data when a grade changes.
protected syncObserver: CoreEventObserver; // Observer to refresh data when the async is synchronized.
protected submissionsData: { canviewsubmissions: boolean; submissions?: AddonModAssignSubmission[] } = {
canviewsubmissions: false,
};
constructor(
protected route: ActivatedRoute,
) {
this.submissions = new AddonModAssignSubmissionListManager(AddonModAssignSubmissionListPage);
// Update data if some grade changes.
this.gradedObserver = CoreEvents.on<AddonModAssignGradedEventData>(
AddonModAssignProvider.GRADED_EVENT,
(data) => {
if (
this.loaded &&
this.assign &&
data.assignmentId == this.assign.id &&
data.userId == CoreSites.instance.getCurrentSiteUserId()
) {
// Grade changed, refresh the data.
this.loaded = false;
this.refreshAllData(true).finally(() => {
this.loaded = true;
});
}
},
CoreSites.instance.getCurrentSiteId(),
);
// Refresh data if this assign is synchronized.
const events = [AddonModAssignSyncProvider.AUTO_SYNCED, AddonModAssignSyncProvider.MANUAL_SYNCED];
this.syncObserver = CoreEvents.onMultiple<AddonModAssignAutoSyncData | AddonModAssignManualSyncData>(
events,
(data) => {
if (!this.loaded || ('context' in data && data.context == 'submission-list')) {
return;
}
this.loaded = false;
this.refreshAllData(false).finally(() => {
this.loaded = true;
});
},
CoreSites.instance.getCurrentSiteId(),
);
}
/**
* Component being initialized.
*/
ngAfterViewInit(): void {
this.moduleId = CoreNavigator.instance.getRouteNumberParam('cmId')!;
this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId')!;
this.groupId = CoreNavigator.instance.getRouteNumberParam('groupId') || 0;
this.selectedStatus = CoreNavigator.instance.getRouteParam('status');
if (this.selectedStatus) {
if (this.selectedStatus == AddonModAssignProvider.NEED_GRADING) {
this.title = Translate.instance.instant('addon.mod_assign.numberofsubmissionsneedgrading');
} else {
this.title = Translate.instance.instant('addon.mod_assign.submissionstatus_' + this.selectedStatus);
}
} else {
this.title = Translate.instance.instant('addon.mod_assign.numberofparticipants');
}
this.fetchAssignment(true).finally(() => {
this.loaded = true;
this.submissions.start(this.splitView);
});
}
/**
* Fetch assignment data.
*
* @param sync Whether to try to synchronize data.
* @return Promise resolved when done.
*/
protected async fetchAssignment(sync = false): Promise<void> {
try {
// Get assignment data.
this.assign = await AddonModAssign.instance.getAssignment(this.courseId, this.moduleId);
this.title = this.assign.name || this.title;
if (sync) {
try {
// Try to synchronize data.
const result = await AddonModAssignSync.instance.syncAssign(this.assign.id);
if (result && result.updated) {
CoreEvents.trigger<AddonModAssignManualSyncData>(
AddonModAssignSyncProvider.MANUAL_SYNCED,
{
assignId: this.assign.id,
warnings: result.warnings,
gradesBlocked: result.gradesBlocked,
context: 'submission-list',
},
CoreSites.instance.getCurrentSiteId(),
);
}
} catch (error) {
// Ignore errors, probably user is offline or sync is blocked.
}
}
// Get assignment submissions.
this.submissionsData = await AddonModAssign.instance.getSubmissions(this.assign.id, { cmId: this.assign.cmid });
if (!this.submissionsData.canviewsubmissions) {
// User shouldn't be able to reach here.
throw new Error('Cannot view submissions.');
}
// Check if groupmode is enabled to avoid showing wrong numbers.
this.groupInfo = await CoreGroups.instance.getActivityGroupInfo(this.assign.cmid, false);
await this.setGroup(CoreGroups.instance.validateGroupId(this.groupId, this.groupInfo));
} catch (error) {
CoreDomUtils.instance.showErrorModalDefault(error, 'Error getting assigment data.');
}
}
/**
* Set group to see the summary.
*
* @param groupId Group ID.
* @return Resolved when done.
*/
async setGroup(groupId: number): Promise<void> {
this.groupId = groupId;
this.haveAllParticipants = true;
if (!CoreSites.instance.getCurrentSite()?.wsAvailable('mod_assign_list_participants')) {
// Submissions are not displayed in Moodle 3.1 without the local plugin, see MOBILE-2968.
this.haveAllParticipants = false;
this.submissions.resetItems();
return;
}
// Fetch submissions and grades.
const submissions =
await AddonModAssignHelper.instance.getSubmissionsUserData(
this.assign!,
this.submissionsData.submissions,
this.groupId,
);
// Get assignment grades only if workflow is not enabled to check grading date.
const grades = !this.assign!.markingworkflow
? await AddonModAssign.instance.getAssignmentGrades(this.assign!.id, { cmId: this.assign!.cmid })
: [];
// Filter the submissions to get only the ones with the right status and add some extra data.
const getNeedGrading = this.selectedStatus == AddonModAssignProvider.NEED_GRADING;
const searchStatus = getNeedGrading ? AddonModAssignProvider.SUBMISSION_STATUS_SUBMITTED : this.selectedStatus;
const promises: Promise<void>[] = [];
const showSubmissions: AddonModAssignSubmissionForList[] = [];
submissions.forEach((submission: AddonModAssignSubmissionForList) => {
if (!searchStatus || searchStatus == submission.status) {
promises.push(
CoreUtils.instance.ignoreErrors(
AddonModAssignOffline.instance.getSubmissionGrade(this.assign!.id, submission.userid),
).then(async (data) => {
if (getNeedGrading) {
// Only show the submissions that need to be graded.
const add = await AddonModAssign.instance.needsSubmissionToBeGraded(submission, this.assign!.id);
if (!add) {
return;
}
}
// Load offline grades.
const notSynced = !!data && submission.timemodified < data.timemodified;
if (submission.gradingstatus == 'graded' && !this.assign!.markingworkflow) {
// Get the last grade of the submission.
const grade = grades
.filter((grade) => grade.userid == submission.userid)
.reduce((a, b) => (a.timemodified > b.timemodified ? a : b));
if (grade && grade.timemodified < submission.timemodified) {
submission.gradingstatus = AddonModAssignProvider.GRADED_FOLLOWUP_SUBMIT;
}
}
submission.statusColor = AddonModAssign.instance.getSubmissionStatusColor(submission.status);
submission.gradingColor = AddonModAssign.instance.getSubmissionGradingStatusColor(
submission.gradingstatus,
);
// Show submission status if not submitted for grading.
if (submission.statusColor != 'success' || !submission.gradingstatus) {
submission.statusTranslated = Translate.instance.instant(
'addon.mod_assign.submissionstatus_' + submission.status,
);
} else {
submission.statusTranslated = '';
}
if (notSynced) {
submission.gradingStatusTranslationId = 'addon.mod_assign.gradenotsynced';
submission.gradingColor = '';
} else if (submission.statusColor != 'danger' || submission.gradingColor != 'danger') {
// Show grading status if one of the statuses is not done.
submission.gradingStatusTranslationId = AddonModAssign.instance.getSubmissionGradingStatusTranslationId(
submission.gradingstatus,
);
} else {
submission.gradingStatusTranslationId = '';
}
showSubmissions.push(submission);
return;
}),
);
}
});
await Promise.all(promises);
this.submissions.setItems(showSubmissions);
}
/**
* Refresh all the data.
*
* @param sync Whether to try to synchronize data.
* @return Promise resolved when done.
*/
protected async refreshAllData(sync?: boolean): Promise<void> {
const promises: Promise<void>[] = [];
promises.push(AddonModAssign.instance.invalidateAssignmentData(this.courseId));
if (this.assign) {
promises.push(AddonModAssign.instance.invalidateAllSubmissionData(this.assign.id));
promises.push(AddonModAssign.instance.invalidateAssignmentUserMappingsData(this.assign.id));
promises.push(AddonModAssign.instance.invalidateAssignmentGradesData(this.assign.id));
promises.push(AddonModAssign.instance.invalidateListParticipantsData(this.assign.id));
}
try {
await Promise.all(promises);
} finally {
this.fetchAssignment(sync);
}
}
/**
* Refresh the list.
*
* @param refresher Refresher.
*/
refreshList(refresher?: CustomEvent<IonRefresher>): void {
this.refreshAllData(true).finally(() => {
refresher?.detail.complete();
});
}
/**
* Component being destroyed.
*/
ngOnDestroy(): void {
this.gradedObserver?.off();
this.syncObserver?.off();
this.submissions.destroy();
}
}
/**
* Helper class to manage submissions.
*/
class AddonModAssignSubmissionListManager extends CorePageItemsListManager<AddonModAssignSubmissionForList> {
constructor(pageComponent: unknown) {
super(pageComponent);
}
/**
* @inheritdoc
*/
protected getItemPath(submission: AddonModAssignSubmissionForList): string {
return String(submission.submitid);
}
/**
* @inheritdoc
*/
protected getItemQueryParams(submission: AddonModAssignSubmissionForList): Params {
return CoreObject.withoutEmpty({
blindId: submission.blindid,
});
}
/**
* @inheritdoc
*/
protected getSelectedItemPath(route: ActivatedRouteSnapshot): string | null {
return route.params.submitId ?? null;
}
}
/**
* Calculated data for an assign submission.
*/
type AddonModAssignSubmissionForList = AddonModAssignSubmissionFormatted & {
statusColor?: string; // Calculated in the app. Color of the submission status.
gradingColor?: string; // Calculated in the app. Color of the submission grading status.
statusTranslated?: string; // Calculated in the app. Translated text of the submission status.
gradingStatusTranslationId?: string; // Calculated in the app. Key of the text of the submission grading status.
};

View File

@ -0,0 +1,29 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
</ion-buttons>
<ion-title>
<core-format-text [text]="title" contextLevel="module" [contextInstanceId]="moduleId" [courseId]="courseId">
</core-format-text>
</ion-title>
<ion-buttons slot="end"></ion-buttons>
</ion-toolbar>
<core-navbar-buttons slot="end">
<ion-button [hidden]="!canSaveGrades" fill="clear" (click)="submitGrade()" [attr.aria-label]="'core.done' | translate">
{{ 'core.done' | translate }}
</ion-button>
</core-navbar-buttons>
</ion-header>
<ion-content>
<ion-refresher slot="fixed" [disabled]="!loaded" (ionRefresh)="refreshSubmission($event)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-loading [hideUntil]="loaded">
<addon-mod-assign-submission [courseId]="courseId" [moduleId]="moduleId" [submitId]="submitId" [blindId]="blindId">
</addon-mod-assign-submission>
</core-loading>
</ion-content>

View File

@ -0,0 +1,184 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, OnInit, ViewChild } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { CoreCourse } from '@features/course/services/course';
import { IonRefresher } from '@ionic/angular';
import { CoreNavigator } from '@services/navigator';
import { CoreScreen } from '@services/screen';
import { CoreDomUtils } from '@services/utils/dom';
import { AddonModAssignSubmissionComponent } from '../../components/submission/submission';
import { AddonModAssign, AddonModAssignAssign } from '../../services/assign';
/**
* Page that displays a submission.
*/
@Component({
selector: 'page-addon-mod-assign-submission-review',
templateUrl: 'submission-review.html',
})
export class AddonModAssignSubmissionReviewPage implements OnInit {
@ViewChild(AddonModAssignSubmissionComponent) submissionComponent?: AddonModAssignSubmissionComponent;
title = ''; // Title to display.
moduleId!: number; // Module ID the submission belongs to.
courseId!: number; // Course ID the assignment belongs to.
submitId!: number; // User that did the submission.
blindId?: number; // Blinded user ID (if it's blinded).
loaded = false; // Whether data has been loaded.
canSaveGrades = false; // Whether the user can save grades.
protected assign?: AddonModAssignAssign; // The assignment the submission belongs to.
protected blindMarking = false; // Whether it uses blind marking.
protected forceLeave = false; // To allow leaving the page without checking for changes.
constructor(
protected route: ActivatedRoute,
) { }
/**
* Component being initialized.
*/
ngOnInit(): void {
this.route.queryParams.subscribe((params) => {
this.moduleId = CoreNavigator.instance.getRouteNumberParam('cmId')!;
this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId')!;
this.submitId = CoreNavigator.instance.getRouteNumberParam('submitId') || 0;
this.blindId = CoreNavigator.instance.getRouteNumberParam('blindId', params);
this.fetchSubmission().finally(() => {
this.loaded = true;
});
});
}
/**
* Check if we can leave the page or not.
*
* @return Resolved if we can leave it, rejected if not.
*/
ionViewCanLeave(): boolean | Promise<void> {
if (!this.submissionComponent || this.forceLeave) {
return true;
}
// Check if data has changed.
return this.submissionComponent.canLeave();
}
/**
* User entered the page.
*/
ionViewDidEnter(): void {
this.submissionComponent?.ionViewDidEnter();
}
/**
* User left the page.
*/
ionViewDidLeave(): void {
this.submissionComponent?.ionViewDidLeave();
}
/**
* Get the submission.
*
* @return Promise resolved when done.
*/
protected async fetchSubmission(): Promise<void> {
this.assign = await AddonModAssign.instance.getAssignment(this.courseId, this.moduleId);
this.title = this.assign.name;
this.blindMarking = !!this.assign.blindmarking && !this.assign.revealidentities;
const gradeInfo = await CoreCourse.instance.getModuleBasicGradeInfo(this.moduleId);
if (!gradeInfo) {
return;
}
// Grades can be saved if simple grading.
if (gradeInfo.advancedgrading && gradeInfo.advancedgrading[0] &&
typeof gradeInfo.advancedgrading[0].method != 'undefined') {
const method = gradeInfo.advancedgrading[0].method || 'simple';
this.canSaveGrades = method == 'simple';
} else {
this.canSaveGrades = true;
}
}
/**
* Refresh all the data.
*
* @return Promise resolved when done.
*/
protected async refreshAllData(): Promise<void> {
const promises: Promise<void>[] = [];
promises.push(AddonModAssign.instance.invalidateAssignmentData(this.courseId));
if (this.assign) {
promises.push(AddonModAssign.instance.invalidateSubmissionData(this.assign.id));
promises.push(AddonModAssign.instance.invalidateAssignmentUserMappingsData(this.assign.id));
promises.push(AddonModAssign.instance.invalidateSubmissionStatusData(
this.assign.id,
this.submitId,
undefined,
this.blindMarking,
));
}
try {
await Promise.all(promises);
} finally {
this.submissionComponent && this.submissionComponent.invalidateAndRefresh(true);
await this.fetchSubmission();
}
}
/**
* Refresh the data.
*
* @param refresher Refresher.
*/
refreshSubmission(refresher?: CustomEvent<IonRefresher>): void {
this.refreshAllData().finally(() => {
refresher?.detail.complete();
});
}
/**
* Submit a grade and feedback.
*/
async submitGrade(): Promise<void> {
if (!this.submissionComponent) {
return;
}
try {
await this.submissionComponent.submitGrade();
// Grade submitted, leave the view if not in tablet.
if (!CoreScreen.instance.isTablet) {
this.forceLeave = true;
CoreNavigator.instance.back();
}
} catch (error) {
CoreDomUtils.instance.showErrorModalDefault(error, 'core.error', true);
}
}
}

View File

@ -0,0 +1,731 @@
// (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 { CoreFileUploader, CoreFileUploaderStoreFilesResult } from '@features/fileuploader/services/fileuploader';
import { CoreSites, CoreSitesCommonWSOptions } from '@services/sites';
import { CoreWSExternalFile } from '@services/ws';
import { FileEntry } from '@ionic-native/file/ngx';
import {
AddonModAssignProvider,
AddonModAssignAssign,
AddonModAssignSubmission,
AddonModAssignParticipant,
AddonModAssignSubmissionFeedback,
AddonModAssign,
AddonModAssignPlugin,
AddonModAssignSavePluginData,
} from './assign';
import { AddonModAssignOffline } from './assign-offline';
import { CoreUtils } from '@services/utils/utils';
import { CoreFile } from '@services/file';
import { CoreCourseCommonModWSOptions } from '@features/course/services/course';
import { CoreGroups } from '@services/groups';
import { AddonModAssignSubmissionDelegate } from './submission-delegate';
import { AddonModAssignFeedbackDelegate } from './feedback-delegate';
import { makeSingleton } from '@singletons';
/**
* Service that provides some helper functions for assign.
*/
@Injectable({ providedIn: 'root' })
export class AddonModAssignHelperProvider {
/**
* Check if a submission can be edited in offline.
*
* @param assign Assignment.
* @param submission Submission.
* @return Whether it can be edited offline.
*/
async canEditSubmissionOffline(assign: AddonModAssignAssign, submission?: AddonModAssignSubmission): Promise<boolean> {
if (!submission) {
return false;
}
if (submission.status == AddonModAssignProvider.SUBMISSION_STATUS_NEW ||
submission.status == AddonModAssignProvider.SUBMISSION_STATUS_REOPENED) {
// It's a new submission, allow creating it in offline.
return true;
}
let canEdit = true;
const promises = submission.plugins
? submission.plugins.map((plugin) =>
AddonModAssignSubmissionDelegate.instance.canPluginEditOffline(assign, submission, plugin).then((canEditPlugin) => {
if (!canEditPlugin) {
canEdit = false;
}
return;
}))
: [];
await Promise.all(promises);
return canEdit;
}
/**
* Clear plugins temporary data because a submission was cancelled.
*
* @param assign Assignment.
* @param submission Submission to clear the data for.
* @param inputData Data entered in the submission form.
*/
clearSubmissionPluginTmpData(
assign: AddonModAssignAssign,
submission: AddonModAssignSubmission | undefined,
inputData: Record<string, unknown>,
): void {
if (!submission) {
return;
}
submission.plugins?.forEach((plugin) => {
AddonModAssignSubmissionDelegate.instance.clearTmpData(assign, submission, plugin, inputData);
});
}
/**
* Copy the data from last submitted attempt to the current submission.
* Since we don't have any WS for that we'll have to re-submit everything manually.
*
* @param assign Assignment.
* @param previousSubmission Submission to copy.
* @return Promise resolved when done.
*/
async copyPreviousAttempt(assign: AddonModAssignAssign, previousSubmission: AddonModAssignSubmission): Promise<void> {
const pluginData: AddonModAssignSavePluginData = {};
const promises = previousSubmission.plugins
? previousSubmission.plugins.map((plugin) =>
AddonModAssignSubmissionDelegate.instance.copyPluginSubmissionData(assign, plugin, pluginData))
: [];
await Promise.all(promises);
// We got the plugin data. Now we need to submit it.
if (Object.keys(pluginData).length) {
// There's something to save.
return AddonModAssign.instance.saveSubmissionOnline(assign.id, pluginData);
}
}
/**
* Create an empty feedback object.
*
* @return Feedback.
*/
createEmptyFeedback(): AddonModAssignSubmissionFeedback {
return {
grade: undefined,
gradefordisplay: '',
gradeddate: 0,
};
}
/**
* Create an empty submission object.
*
* @return Submission.
*/
createEmptySubmission(): AddonModAssignSubmissionFormatted {
return {
id: 0,
userid: 0,
attemptnumber: 0,
timecreated: 0,
timemodified: 0,
status: '',
groupid: 0,
};
}
/**
* Delete stored submission files for a plugin. See storeSubmissionFiles.
*
* @param assignId Assignment ID.
* @param folderName Name of the plugin folder. Must be unique (both in submission and feedback plugins).
* @param userId User ID. If not defined, site's current user.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done.
*/
async deleteStoredSubmissionFiles(assignId: number, folderName: string, userId?: number, siteId?: string): Promise<void> {
const folderPath = await AddonModAssignOffline.instance.getSubmissionPluginFolder(assignId, folderName, userId, siteId);
await CoreFile.instance.removeDir(folderPath);
}
/**
* Delete all drafts of the feedback plugin data.
*
* @param assignId Assignment Id.
* @param userId User Id.
* @param feedback Feedback data.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done.
*/
async discardFeedbackPluginData(
assignId: number,
userId: number,
feedback: AddonModAssignSubmissionFeedback,
siteId?: string,
): Promise<void> {
const promises = feedback.plugins
? feedback.plugins.map((plugin) =>
AddonModAssignFeedbackDelegate.instance.discardPluginFeedbackData(assignId, userId, plugin, siteId))
: [];
await Promise.all(promises);
}
/**
* Check if a submission has no content.
*
* @param assign Assignment object.
* @param submission Submission to inspect.
* @return Whether the submission is empty.
*/
isSubmissionEmpty(assign: AddonModAssignAssign, submission?: AddonModAssignSubmission): boolean {
if (!submission) {
return true;
}
const anyNotEmpty = submission.plugins?.some((plugin) =>
!AddonModAssignSubmissionDelegate.instance.isPluginEmpty(assign, plugin));
// If any plugin is not empty, we consider that the submission is not empty either.
if (anyNotEmpty) {
return false;
}
// If all the plugins were empty (or there were no plugins), we consider the submission to be empty.
return true;
}
/**
* List the participants for a single assignment, with some summary info about their submissions.
*
* @param assign Assignment object.
* @param groupId Group Id.
* @param options Other options.
* @return Promise resolved with the list of participants and summary of submissions.
*/
async getParticipants(
assign: AddonModAssignAssign,
groupId?: number,
options: CoreSitesCommonWSOptions = {},
): Promise<AddonModAssignParticipant[]> {
groupId = groupId || 0;
options.siteId = options.siteId || CoreSites.instance.getCurrentSiteId();
// Create new options including all existing ones.
const modOptions: CoreCourseCommonModWSOptions = { cmId: assign.cmid, ...options };
const participants = await AddonModAssign.instance.listParticipants(assign.id, groupId, modOptions);
if (groupId || participants && participants.length > 0) {
return participants;
}
// If no participants returned and all groups specified, get participants by groups.
const groupsInfo = await CoreGroups.instance.getActivityGroupInfo(assign.cmid, false, undefined, modOptions.siteId);
const participantsIndexed: {[id: number]: AddonModAssignParticipant} = {};
const promises = groupsInfo.groups
? groupsInfo.groups.map((userGroup) =>
AddonModAssign.instance.listParticipants(assign.id, userGroup.id, modOptions).then((participantsFromList) => {
// Do not get repeated users.
participantsFromList.forEach((participant) => {
participantsIndexed[participant.id] = participant;
});
return;
}))
:[];
await Promise.all(promises);
return CoreUtils.instance.objectToArray(participantsIndexed);
}
/**
* Get plugin config from assignment config.
*
* @param assign Assignment object including all config.
* @param subtype Subtype name (assignsubmission or assignfeedback)
* @param type Name of the subplugin.
* @return Object containing all configurations of the subplugin selected.
*/
getPluginConfig(assign: AddonModAssignAssign, subtype: string, type: string): AddonModAssignPluginConfig {
const configs: AddonModAssignPluginConfig = {};
assign.configs.forEach((config) => {
if (config.subtype == subtype && config.plugin == type) {
configs[config.name] = config.value;
}
});
return configs;
}
/**
* Get enabled subplugins.
*
* @param assign Assignment object including all config.
* @param subtype Subtype name (assignsubmission or assignfeedback)
* @return List of enabled plugins for the assign.
*/
getPluginsEnabled(assign: AddonModAssignAssign, subtype: string): AddonModAssignPlugin[] {
const enabled: AddonModAssignPlugin[] = [];
assign.configs.forEach((config) => {
if (config.subtype == subtype && config.name == 'enabled' && parseInt(config.value, 10) === 1) {
// Format the plugin objects.
enabled.push({
type: config.plugin,
name: config.plugin,
});
}
});
return enabled;
}
/**
* Get a list of stored submission files. See storeSubmissionFiles.
*
* @param assignId Assignment ID.
* @param folderName Name of the plugin folder. Must be unique (both in submission and feedback plugins).
* @param userId User ID. If not defined, site's current user.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with the files.
*/
async getStoredSubmissionFiles(
assignId: number,
folderName: string,
userId?: number,
siteId?: string,
): Promise<(FileEntry | DirectoryEntry)[]> {
const folderPath = await AddonModAssignOffline.instance.getSubmissionPluginFolder(assignId, folderName, userId, siteId);
return CoreFile.instance.getDirectoryContents(folderPath);
}
/**
* Get the size that will be uploaded to perform an attempt copy.
*
* @param assign Assignment.
* @param previousSubmission Submission to copy.
* @return Promise resolved with the size.
*/
async getSubmissionSizeForCopy(assign: AddonModAssignAssign, previousSubmission: AddonModAssignSubmission): Promise<number> {
let totalSize = 0;
const promises = previousSubmission.plugins
? previousSubmission.plugins.map((plugin) =>
AddonModAssignSubmissionDelegate.instance.getPluginSizeForCopy(assign, plugin).then((size) => {
totalSize += (size || 0);
return;
}))
: [];
await Promise.all(promises);
return totalSize;
}
/**
* Get the size that will be uploaded to save a submission.
*
* @param assign Assignment.
* @param submission Submission to check data.
* @param inputData Data entered in the submission form.
* @return Promise resolved with the size.
*/
async getSubmissionSizeForEdit(
assign: AddonModAssignAssign,
submission: AddonModAssignSubmission,
inputData: Record<string, unknown>,
): Promise<number> {
let totalSize = 0;
const promises = submission.plugins
? submission.plugins.map((plugin) =>
AddonModAssignSubmissionDelegate.instance.getPluginSizeForEdit(assign, submission, plugin, inputData)
.then((size) => {
totalSize += (size || 0);
return;
}))
: [];
await Promise.all(promises);
return totalSize;
}
/**
* Get user data for submissions since they only have userid.
*
* @param assign Assignment object.
* @param submissions Submissions to get the data for.
* @param groupId Group Id.
* @param options Other options.
* @return Promise always resolved. Resolve param is the formatted submissions.
*/
async getSubmissionsUserData(
assign: AddonModAssignAssign,
submissions: AddonModAssignSubmissionFormatted[] = [],
groupId?: number,
options: CoreSitesCommonWSOptions = {},
): Promise<AddonModAssignSubmissionFormatted[]> {
// Create new options including all existing ones.
const modOptions: CoreCourseCommonModWSOptions = { cmId: assign.cmid, ...options };
const parts = await this.getParticipants(assign, groupId, options);
const blind = assign.blindmarking && !assign.revealidentities;
const promises: Promise<void>[] = [];
const result: AddonModAssignSubmissionFormatted[] = [];
const participants: {[id: number]: AddonModAssignParticipant} = CoreUtils.instance.arrayToObject(parts, 'id');
submissions.forEach((submission) => {
submission.submitid = submission.userid && submission.userid > 0 ? submission.userid : submission.blindid;
if (typeof submission.submitid == 'undefined' || submission.submitid <= 0) {
return;
}
const participant = participants[submission.submitid];
if (!participant) {
// Avoid permission denied error. Participant not found on list.
return;
}
delete participants[submission.submitid];
if (!blind) {
submission.userfullname = participant.fullname;
submission.userprofileimageurl = participant.profileimageurl;
}
submission.manyGroups = !!participant.groups && participant.groups.length > 1;
submission.noGroups = !!participant.groups && participant.groups.length == 0;
if (participant.groupname) {
submission.groupid = participant.groupid!;
submission.groupname = participant.groupname;
}
let promise = Promise.resolve();
if (submission.userid && submission.userid > 0 && blind) {
// Blind but not blinded! (Moodle < 3.1.1, 3.2).
delete submission.userid;
promise = AddonModAssign.instance.getAssignmentUserMappings(assign.id, submission.submitid, modOptions)
.then((blindId) => {
submission.blindid = blindId;
return;
});
}
promises.push(promise.then(() => {
// Add to the list.
if (submission.userfullname || submission.blindid) {
result.push(submission);
}
return;
}));
});
await Promise.all(promises);
// Create a submission for each participant left in the list (the participants already treated were removed).
CoreUtils.instance.objectToArray(participants).forEach((participant: AddonModAssignParticipant) => {
const submission = this.createEmptySubmission();
submission.submitid = participant.id;
if (!blind) {
submission.userid = participant.id;
submission.userfullname = participant.fullname;
submission.userprofileimageurl = participant.profileimageurl;
} else {
submission.blindid = participant.id;
}
submission.manyGroups = !!participant.groups && participant.groups.length > 1;
submission.noGroups = !!participant.groups && participant.groups.length == 0;
if (participant.groupname) {
submission.groupid = participant.groupid!;
submission.groupname = participant.groupname;
}
submission.status = participant.submitted ? AddonModAssignProvider.SUBMISSION_STATUS_SUBMITTED :
AddonModAssignProvider.SUBMISSION_STATUS_NEW;
result.push(submission);
});
return result;
}
/**
* Check if the feedback data has changed for a certain submission and assign.
*
* @param assign Assignment.
* @param submission The submission.
* @param feedback Feedback data.
* @param userId The user ID.
* @return Promise resolved with true if data has changed, resolved with false otherwise.
*/
async hasFeedbackDataChanged(
assign: AddonModAssignAssign,
submission: AddonModAssignSubmission | AddonModAssignSubmissionFormatted | undefined,
feedback: AddonModAssignSubmissionFeedback,
userId: number,
): Promise<boolean> {
if (!submission || !feedback.plugins) {
return false;
}
let hasChanged = false;
const promises = feedback.plugins.map((plugin) =>
this.prepareFeedbackPluginData(assign.id, userId, feedback).then(async (inputData) => {
const changed = await CoreUtils.instance.ignoreErrors(
AddonModAssignFeedbackDelegate.instance.hasPluginDataChanged(assign, submission, plugin, inputData, userId),
false,
);
if (changed) {
hasChanged = true;
}
return;
}));
await CoreUtils.instance.allPromises(promises);
return hasChanged;
}
/**
* Check if the submission data has changed for a certain submission and assign.
*
* @param assign Assignment.
* @param submission Submission to check data.
* @param inputData Data entered in the submission form.
* @return Promise resolved with true if data has changed, resolved with false otherwise.
*/
async hasSubmissionDataChanged(
assign: AddonModAssignAssign,
submission: AddonModAssignSubmission | undefined,
inputData: Record<string, unknown>,
): Promise<boolean> {
if (!submission) {
return false;
}
let hasChanged = false;
const promises = submission.plugins
? submission.plugins.map((plugin) =>
AddonModAssignSubmissionDelegate.instance.hasPluginDataChanged(assign, submission, plugin, inputData)
.then((changed) => {
if (changed) {
hasChanged = true;
}
return;
}).catch(() => {
// Ignore errors.
}))
: [];
await CoreUtils.instance.allPromises(promises);
return hasChanged;
}
/**
* Prepare and return the plugin data to send for a certain feedback and assign.
*
* @param assignId Assignment Id.
* @param userId User Id.
* @param feedback Feedback data.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with plugin data to send to server.
*/
async prepareFeedbackPluginData(
assignId: number,
userId: number,
feedback: AddonModAssignSubmissionFeedback,
siteId?: string,
): Promise<AddonModAssignSavePluginData> {
const pluginData: Record<string, unknown> = {};
const promises = feedback.plugins
? feedback.plugins.map((plugin) =>
AddonModAssignFeedbackDelegate.instance.preparePluginFeedbackData(assignId, userId, plugin, pluginData, siteId))
: [];
await Promise.all(promises);
return pluginData;
}
/**
* Prepare and return the plugin data to send for a certain submission and assign.
*
* @param assign Assignment.
* @param submission Submission to check data.
* @param inputData Data entered in the submission form.
* @param offline True to prepare the data for an offline submission, false otherwise.
* @return Promise resolved with plugin data to send to server.
*/
async prepareSubmissionPluginData(
assign: AddonModAssignAssign,
submission: AddonModAssignSubmission | undefined,
inputData: Record<string, unknown>,
offline = false,
): Promise<AddonModAssignSavePluginData> {
if (!submission || !submission.plugins) {
return {};
}
const pluginData: AddonModAssignSavePluginData = {};
const promises = submission.plugins.map((plugin) =>
AddonModAssignSubmissionDelegate.instance.preparePluginSubmissionData(
assign,
submission,
plugin,
inputData,
pluginData,
offline,
));
await Promise.all(promises);
return pluginData;
}
/**
* Given a list of files (either online files or local files), store the local files in a local folder
* to be submitted later.
*
* @param assignId Assignment ID.
* @param folderName Name of the plugin folder. Must be unique (both in submission and feedback plugins).
* @param files List of files.
* @param userId User ID. If not defined, site's current user.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved if success, rejected otherwise.
*/
async storeSubmissionFiles(
assignId: number,
folderName: string,
files: (CoreWSExternalFile | FileEntry)[],
userId?: number,
siteId?: string,
): Promise<CoreFileUploaderStoreFilesResult> {
// Get the folder where to store the files.
const folderPath = await AddonModAssignOffline.instance.getSubmissionPluginFolder(assignId, folderName, userId, siteId);
return CoreFileUploader.instance.storeFilesToUpload(folderPath, files);
}
/**
* Upload a file to a draft area. If the file is an online file it will be downloaded and then re-uploaded.
*
* @param assignId Assignment ID.
* @param file Online file or local FileEntry.
* @param itemId Draft ID to use. Undefined or 0 to create a new draft ID.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with the itemId.
*/
uploadFile(assignId: number, file: CoreWSExternalFile | FileEntry, itemId?: number, siteId?: string): Promise<number> {
return CoreFileUploader.instance.uploadOrReuploadFile(file, itemId, AddonModAssignProvider.COMPONENT, assignId, siteId);
}
/**
* Given a list of files (either online files or local files), upload them to a draft area and return the draft ID.
* Online files will be downloaded and then re-uploaded.
* If there are no files to upload it will return a fake draft ID (1).
*
* @param assignId Assignment ID.
* @param files List of files.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with the itemId.
*/
uploadFiles(assignId: number, files: (CoreWSExternalFile | FileEntry)[], siteId?: string): Promise<number> {
return CoreFileUploader.instance.uploadOrReuploadFiles(files, AddonModAssignProvider.COMPONENT, assignId, siteId);
}
/**
* Upload or store some files, depending if the user is offline or not.
*
* @param assignId Assignment ID.
* @param folderName Name of the plugin folder. Must be unique (both in submission and feedback plugins).
* @param files List of files.
* @param offline True if files sould be stored for offline, false to upload them.
* @param userId User ID. If not defined, site's current user.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done.
*/
async uploadOrStoreFiles(
assignId: number,
folderName: string,
files: (CoreWSExternalFile | FileEntry)[],
offline = false,
userId?: number,
siteId?: string,
): Promise<number | CoreFileUploaderStoreFilesResult> {
if (offline) {
return await this.storeSubmissionFiles(assignId, folderName, files, userId, siteId);
}
return await this.uploadFiles(assignId, files, siteId);
}
}
export const AddonModAssignHelper = makeSingleton(AddonModAssignHelperProvider);
/**
* Assign submission with some calculated data.
*/
export type AddonModAssignSubmissionFormatted =
Omit<AddonModAssignSubmission, 'userid'> & {
userid?: number; // Student id.
blindid?: number; // Calculated in the app. Blindid of the user that did the submission.
submitid?: number; // Calculated in the app. Userid or blindid of the user that did the submission.
userfullname?: string; // Calculated in the app. Full name of the user that did the submission.
userprofileimageurl?: string; // Calculated in the app. Avatar of the user that did the submission.
manyGroups?: boolean; // Calculated in the app. Whether the user belongs to more than 1 group.
noGroups?: boolean; // Calculated in the app. Whether the user doesn't belong to any group.
groupname?: string; // Calculated in the app. Name of the group the submission belongs to.
};
/**
* Assignment plugin config.
*/
export type AddonModAssignPluginConfig = {[name: string]: string};

View File

@ -0,0 +1,459 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { CoreError } from '@classes/errors/error';
import { SQLiteDBRecordValues } from '@classes/sqlitedb';
import { CoreFile } from '@services/file';
import { CoreSites } from '@services/sites';
import { CoreTextUtils } from '@services/utils/text';
import { CoreTimeUtils } from '@services/utils/time';
import { makeSingleton } from '@singletons';
import { AddonModAssignOutcomes, AddonModAssignSavePluginData } from './assign';
import {
AddonModAssignSubmissionsDBRecord,
AddonModAssignSubmissionsGradingDBRecord,
SUBMISSIONS_GRADES_TABLE,
SUBMISSIONS_TABLE,
} from './database/assign';
/**
* Service to handle offline assign.
*/
@Injectable({ providedIn: 'root' })
export class AddonModAssignOfflineProvider {
/**
* Delete a submission.
*
* @param assignId Assignment ID.
* @param userId User ID. If not defined, site's current user.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved if deleted, rejected if failure.
*/
async deleteSubmission(assignId: number, userId?: number, siteId?: string): Promise<void> {
const site = await CoreSites.instance.getSite(siteId);
userId = userId || site.getUserId();
await site.getDb().deleteRecords(
SUBMISSIONS_TABLE,
{ assignid: assignId, userid: userId },
);
}
/**
* Delete a submission grade.
*
* @param assignId Assignment ID.
* @param userId User ID. If not defined, site's current user.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved if deleted, rejected if failure.
*/
async deleteSubmissionGrade(assignId: number, userId?: number, siteId?: string): Promise<void> {
const site = await CoreSites.instance.getSite(siteId);
userId = userId || site.getUserId();
await site.getDb().deleteRecords(
SUBMISSIONS_GRADES_TABLE,
{ assignid: assignId, userid: userId },
);
}
/**
* Get all the assignments ids that have something to be synced.
*
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with assignments id that have something to be synced.
*/
async getAllAssigns(siteId?: string): Promise<number[]> {
const promises:
Promise<AddonModAssignSubmissionsDBRecordFormatted[] | AddonModAssignSubmissionsGradingDBRecordFormatted[]>[] = [];
promises.push(this.getAllSubmissions(siteId));
promises.push(this.getAllSubmissionsGrade(siteId));
const results = await Promise.all(promises);
// Flatten array.
const flatten: (AddonModAssignSubmissionsDBRecord | AddonModAssignSubmissionsGradingDBRecord)[] =
[].concat.apply([], results);
// Get assign id.
let assignIds: number[] = flatten.map((assign) => assign.assignid);
// Get unique values.
assignIds = assignIds.filter((id, pos) => assignIds.indexOf(id) == pos);
return assignIds;
}
/**
* Get all the stored submissions from all the assignments.
*
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with submissions.
*/
protected async getAllSubmissions(siteId?: string): Promise<AddonModAssignSubmissionsDBRecordFormatted[]> {
return this.getAssignSubmissionsFormatted(undefined, siteId);
}
/**
* Get all the stored submissions for a certain assignment.
*
* @param assignId Assignment ID.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with submissions.
*/
async getAssignSubmissions(assignId: number, siteId?: string): Promise<AddonModAssignSubmissionsDBRecordFormatted[]> {
return this.getAssignSubmissionsFormatted({ assignid: assignId }, siteId);
}
/**
* Convenience helper function to get stored submissions formatted.
*
* @param conditions Query conditions.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with submissions.
*/
protected async getAssignSubmissionsFormatted(
conditions: SQLiteDBRecordValues = {},
siteId?: string,
): Promise<AddonModAssignSubmissionsDBRecordFormatted[]> {
const db = await CoreSites.instance.getSiteDb(siteId);
const submissions: AddonModAssignSubmissionsDBRecord[] = await db.getRecords(SUBMISSIONS_TABLE, conditions);
// Parse the plugin data.
return submissions.map((submission) => ({
assignid: submission.assignid,
userid: submission.userid,
courseid: submission.courseid,
plugindata: CoreTextUtils.instance.parseJSON<AddonModAssignSavePluginData>(submission.plugindata, {}),
onlinetimemodified: submission.onlinetimemodified,
timecreated: submission.timecreated,
timemodified: submission.timemodified,
submitted: submission.submitted,
submissionstatement: submission.submissionstatement,
}));
}
/**
* Get all the stored submissions grades from all the assignments.
*
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with submissions grades.
*/
protected async getAllSubmissionsGrade(siteId?: string): Promise<AddonModAssignSubmissionsGradingDBRecordFormatted[]> {
return this.getAssignSubmissionsGradeFormatted(undefined, siteId);
}
/**
* Get all the stored submissions grades for a certain assignment.
*
* @param assignId Assignment ID.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with submissions grades.
*/
async getAssignSubmissionsGrade(
assignId: number,
siteId?: string,
): Promise<AddonModAssignSubmissionsGradingDBRecordFormatted[]> {
return this.getAssignSubmissionsGradeFormatted({ assignid: assignId }, siteId);
}
/**
* Convenience helper function to get stored submissions grading formatted.
*
* @param conditions Query conditions.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with submissions grades.
*/
protected async getAssignSubmissionsGradeFormatted(
conditions: SQLiteDBRecordValues = {},
siteId?: string,
): Promise<AddonModAssignSubmissionsGradingDBRecordFormatted[]> {
const db = await CoreSites.instance.getSiteDb(siteId);
const submissions: AddonModAssignSubmissionsGradingDBRecord[] = await db.getRecords(SUBMISSIONS_GRADES_TABLE, conditions);
// Parse the plugin data and outcomes.
return submissions.map((submission) => ({
assignid: submission.assignid,
userid: submission.userid,
courseid: submission.courseid,
grade: submission.grade,
attemptnumber: submission.attemptnumber,
addattempt: submission.addattempt,
workflowstate: submission.workflowstate,
applytoall: submission.applytoall,
outcomes: CoreTextUtils.instance.parseJSON<AddonModAssignOutcomes>(submission.outcomes, {}),
plugindata: CoreTextUtils.instance.parseJSON<AddonModAssignSavePluginData>(submission.plugindata, {}),
timemodified: submission.timemodified,
}));
}
/**
* Get a stored submission.
*
* @param assignId Assignment ID.
* @param userId User ID. If not defined, site's current user.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with submission.
*/
async getSubmission(assignId: number, userId?: number, siteId?: string): Promise<AddonModAssignSubmissionsDBRecordFormatted> {
userId = userId || CoreSites.instance.getCurrentSiteUserId();
const submissions = await this.getAssignSubmissionsFormatted({ assignid: assignId, userid: userId }, siteId);
if (submissions.length) {
return submissions[0];
}
throw new CoreError('No records found.');
}
/**
* Get the path to the folder where to store files for an offline submission.
*
* @param assignId Assignment ID.
* @param userId User ID. If not defined, site's current user.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with the path.
*/
async getSubmissionFolder(assignId: number, userId?: number, siteId?: string): Promise<string> {
const site = await CoreSites.instance.getSite(siteId);
userId = userId || site.getUserId();
const siteFolderPath = CoreFile.instance.getSiteFolder(site.getId());
const submissionFolderPath = 'offlineassign/' + assignId + '/' + userId;
return CoreTextUtils.instance.concatenatePaths(siteFolderPath, submissionFolderPath);
}
/**
* Get a stored submission grade.
* Submission grades are not identified using attempt number so it can retrieve the feedback for a previous attempt.
*
* @param assignId Assignment ID.
* @param userId User ID. If not defined, site's current user.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with submission grade.
*/
async getSubmissionGrade(
assignId: number,
userId?: number,
siteId?: string,
): Promise<AddonModAssignSubmissionsGradingDBRecordFormatted> {
userId = userId || CoreSites.instance.getCurrentSiteUserId();
const submissions = await this.getAssignSubmissionsGradeFormatted({ assignid: assignId, userid: userId }, siteId);
if (submissions.length) {
return submissions[0];
}
throw new CoreError('No records found.');
}
/**
* Get the path to the folder where to store files for a certain plugin in an offline submission.
*
* @param assignId Assignment ID.
* @param pluginName Name of the plugin. Must be unique (both in submission and feedback plugins).
* @param userId User ID. If not defined, site's current user.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with the path.
*/
async getSubmissionPluginFolder(assignId: number, pluginName: string, userId?: number, siteId?: string): Promise<string> {
const folderPath = await this.getSubmissionFolder(assignId, userId, siteId);
return CoreTextUtils.instance.concatenatePaths(folderPath, pluginName);
}
/**
* Check if the assignment has something to be synced.
*
* @param assignId Assignment ID.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with boolean: whether the assignment has something to be synced.
*/
async hasAssignOfflineData(assignId: number, siteId?: string): Promise<boolean> {
const promises:
Promise<AddonModAssignSubmissionsDBRecordFormatted[] | AddonModAssignSubmissionsGradingDBRecordFormatted[]>[] = [];
promises.push(this.getAssignSubmissions(assignId, siteId));
promises.push(this.getAssignSubmissionsGrade(assignId, siteId));
try {
const results = await Promise.all(promises);
return results.some((result) => result.length);
} catch {
// No offline data found.
return false;
}
}
/**
* Mark/Unmark a submission as being submitted.
*
* @param assignId Assignment ID.
* @param courseId Course ID the assign belongs to.
* @param submitted True to mark as submitted, false to mark as not submitted.
* @param acceptStatement True to accept the submission statement, false otherwise.
* @param timemodified The time the submission was last modified in online.
* @param userId User ID. If not defined, site's current user.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved if marked, rejected if failure.
*/
async markSubmitted(
assignId: number,
courseId: number,
submitted: boolean,
acceptStatement: boolean,
timemodified: number,
userId?: number,
siteId?: string,
): Promise<number> {
const site = await CoreSites.instance.getSite(siteId);
userId = userId || site.getUserId();
let submission: AddonModAssignSubmissionsDBRecord;
try {
const savedSubmission: AddonModAssignSubmissionsDBRecordFormatted =
await this.getSubmission(assignId, userId, site.getId());
submission = Object.assign(savedSubmission, {
plugindata: savedSubmission.plugindata ? JSON.stringify(savedSubmission.plugindata) : '{}',
submitted: submitted ? 1 : 0, // Mark the submission.
submissionstatement: acceptStatement ? 1 : 0, // Mark the submission.
});
} catch {
// No submission, create an empty one.
const now = CoreTimeUtils.instance.timestamp();
submission = {
assignid: assignId,
courseid: courseId,
userid: userId,
onlinetimemodified: timemodified,
timecreated: now,
timemodified: now,
plugindata: '{}',
submitted: submitted ? 1 : 0, // Mark the submission.
submissionstatement: acceptStatement ? 1 : 0, // Mark the submission.
};
}
return await site.getDb().insertRecord(SUBMISSIONS_TABLE, submission);
}
/**
* Save a submission to be sent later.
*
* @param assignId Assignment ID.
* @param courseId Course ID the assign belongs to.
* @param pluginData Data to save.
* @param timemodified The time the submission was last modified in online.
* @param submitted True if submission has been submitted, false otherwise.
* @param userId User ID. If not defined, site's current user.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved if stored, rejected if failure.
*/
async saveSubmission(
assignId: number,
courseId: number,
pluginData: AddonModAssignSavePluginData,
timemodified: number,
submitted: boolean,
userId?: number,
siteId?: string,
): Promise<number> {
const site = await CoreSites.instance.getSite(siteId);
userId = userId || site.getUserId();
const now = CoreTimeUtils.instance.timestamp();
const entry: AddonModAssignSubmissionsDBRecord = {
assignid: assignId,
courseid: courseId,
plugindata: pluginData ? JSON.stringify(pluginData) : '{}',
userid: userId,
submitted: submitted ? 1 : 0,
timecreated: now,
timemodified: now,
onlinetimemodified: timemodified,
};
return await site.getDb().insertRecord(SUBMISSIONS_TABLE, entry);
}
/**
* Save a grading to be sent later.
*
* @param assignId Assign ID.
* @param userId User ID.
* @param courseId Course ID the assign belongs to.
* @param grade Grade to submit.
* @param attemptNumber Number of the attempt being graded.
* @param addAttempt Admit the user to attempt again.
* @param workflowState Next workflow State.
* @param applyToAll If it's a team submission, whether the grade applies to all group members.
* @param outcomes Object including all outcomes values. If empty, any of them will be sent.
* @param pluginData Plugin data to save.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved if stored, rejected if failure.
*/
async submitGradingForm(
assignId: number,
userId: number,
courseId: number,
grade: number,
attemptNumber: number,
addAttempt: boolean,
workflowState: string,
applyToAll: boolean,
outcomes: AddonModAssignOutcomes,
pluginData: AddonModAssignSavePluginData,
siteId?: string,
): Promise<number> {
const site = await CoreSites.instance.getSite(siteId);
const now = CoreTimeUtils.instance.timestamp();
const entry: AddonModAssignSubmissionsGradingDBRecord = {
assignid: assignId,
userid: userId,
courseid: courseId,
grade: grade,
attemptnumber: attemptNumber,
addattempt: addAttempt ? 1 : 0,
workflowstate: workflowState,
applytoall: applyToAll ? 1 : 0,
outcomes: outcomes ? JSON.stringify(outcomes) : '{}',
plugindata: pluginData ? JSON.stringify(pluginData) : '{}',
timemodified: now,
};
return await site.getDb().insertRecord(SUBMISSIONS_GRADES_TABLE, entry);
}
}
export const AddonModAssignOffline = makeSingleton(AddonModAssignOfflineProvider);
export type AddonModAssignSubmissionsDBRecordFormatted = Omit<AddonModAssignSubmissionsDBRecord, 'plugindata'> & {
plugindata: AddonModAssignSavePluginData;
};
export type AddonModAssignSubmissionsGradingDBRecordFormatted =
Omit<AddonModAssignSubmissionsGradingDBRecord, 'plugindata'|'outcomes'> & {
plugindata: AddonModAssignSavePluginData;
outcomes: AddonModAssignOutcomes;
};

View File

@ -0,0 +1,572 @@
// (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 { CoreEvents, CoreEventSiteData } from '@singletons/events';
import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
import { CoreSyncBlockedError } from '@classes/base-sync';
import {
AddonModAssignProvider,
AddonModAssignAssign,
AddonModAssignSubmission,
AddonModAssign,
AddonModAssignGetSubmissionStatusWSResponse,
AddonModAssignSubmissionStatusOptions,
} from './assign';
import { makeSingleton, Translate } from '@singletons';
import { CoreCourse } from '@features/course/services/course';
import { CoreCourseActivitySyncBaseProvider } from '@features/course/classes/activity-sync';
import {
AddonModAssignOffline,
AddonModAssignSubmissionsDBRecordFormatted,
AddonModAssignSubmissionsGradingDBRecordFormatted,
} from './assign-offline';
import { CoreSync } from '@services/sync';
import { CoreCourseLogHelper } from '@features/course/services/log-helper';
import { CoreUtils } from '@services/utils/utils';
import { CoreApp } from '@services/app';
import { CoreTextUtils } from '@services/utils/text';
import { CoreNetworkError } from '@classes/errors/network-error';
import { CoreGradesFormattedItem, CoreGradesFormattedRow, CoreGradesHelper } from '@features/grades/services/grades-helper';
import { AddonModAssignSubmissionDelegate } from './submission-delegate';
import { AddonModAssignFeedbackDelegate } from './feedback-delegate';
/**
* Service to sync assigns.
*/
@Injectable({ providedIn: 'root' })
export class AddonModAssignSyncProvider extends CoreCourseActivitySyncBaseProvider<AddonModAssignSyncResult> {
static readonly AUTO_SYNCED = 'addon_mod_assign_autom_synced';
static readonly MANUAL_SYNCED = 'addon_mod_assign_manual_synced';
protected componentTranslate: string;
constructor() {
super('AddonModLessonSyncProvider');
this.componentTranslate = CoreCourse.instance.translateModuleName('assign');
}
/**
* Get the sync ID for a certain user grade.
*
* @param assignId Assign ID.
* @param userId User the grade belongs to.
* @return Sync ID.
*/
getGradeSyncId(assignId: number, userId: number): string {
return 'assignGrade#' + assignId + '#' + userId;
}
/**
* Convenience function to get scale selected option.
*
* @param options Possible options.
* @param selected Selected option to search.
* @return Index of the selected option.
*/
protected getSelectedScaleId(options: string, selected: string): number {
let optionsList = options.split(',');
optionsList = optionsList.map((value) => value.trim());
optionsList.unshift('');
const index = options.indexOf(selected) || 0;
if (index < 0) {
return 0;
}
return index;
}
/**
* Check if an assignment has data to synchronize.
*
* @param assignId Assign ID.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with boolean: whether it has data to sync.
*/
hasDataToSync(assignId: number, siteId?: string): Promise<boolean> {
return AddonModAssignOffline.instance.hasAssignOfflineData(assignId, siteId);
}
/**
* Try to synchronize all the assignments in a certain site or in all sites.
*
* @param siteId Site ID to sync. If not defined, sync all sites.
* @param force Wether to force sync not depending on last execution.
* @return Promise resolved if sync is successful, rejected if sync fails.
*/
syncAllAssignments(siteId?: string, force?: boolean): Promise<void> {
return this.syncOnSites('all assignments', this.syncAllAssignmentsFunc.bind(this, !!force), siteId);
}
/**
* Sync all assignments on a site.
*
* @param force Wether to force sync not depending on last execution.
* @param siteId Site ID to sync. If not defined, sync all sites.
* @param Promise resolved if sync is successful, rejected if sync fails.
*/
protected async syncAllAssignmentsFunc(force: boolean, siteId: string): Promise<void> {
// Get all assignments that have offline data.
const assignIds = await AddonModAssignOffline.instance.getAllAssigns(siteId);
// Try to sync all assignments.
await Promise.all(assignIds.map(async (assignId) => {
const result = force
? await this.syncAssign(assignId, siteId)
: await this.syncAssignIfNeeded(assignId, siteId);
if (result?.updated) {
CoreEvents.trigger<AddonModAssignAutoSyncData>(AddonModAssignSyncProvider.AUTO_SYNCED, {
assignId: assignId,
warnings: result.warnings,
gradesBlocked: result.gradesBlocked,
}, siteId);
}
}));
}
/**
* Sync an assignment only if a certain time has passed since the last time.
*
* @param assignId Assign ID.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when the assign is synced or it doesn't need to be synced.
*/
async syncAssignIfNeeded(assignId: number, siteId?: string): Promise<AddonModAssignSyncResult | undefined> {
const needed = await this.isSyncNeeded(assignId, siteId);
if (needed) {
return this.syncAssign(assignId, siteId);
}
}
/**
* Try to synchronize an assign.
*
* @param assignId Assign ID.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved in success.
*/
async syncAssign(assignId: number, siteId?: string): Promise<AddonModAssignSyncResult> {
siteId = siteId || CoreSites.instance.getCurrentSiteId();
this.componentTranslate = this.componentTranslate || CoreCourse.instance.translateModuleName('assign');
if (this.isSyncing(assignId, siteId)) {
// There's already a sync ongoing for this assign, return the promise.
return this.getOngoingSync(assignId, siteId)!;
}
// Verify that assign isn't blocked.
if (CoreSync.instance.isBlocked(AddonModAssignProvider.COMPONENT, assignId, siteId)) {
this.logger.debug('Cannot sync assign ' + assignId + ' because it is blocked.');
throw new CoreSyncBlockedError(Translate.instance.instant('core.errorsyncblocked', { $a: this.componentTranslate }));
}
this.logger.debug('Try to sync assign ' + assignId + ' in site ' + siteId);
const syncPromise = this.performSyncAssign(assignId, siteId);
return this.addOngoingSync(assignId, syncPromise, siteId);
}
/**
* Perform the assign submission.
*
* @param assignId Assign ID.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved in success.
*/
protected async performSyncAssign(assignId: number, siteId: string): Promise<AddonModAssignSyncResult> {
// Sync offline logs.
await CoreUtils.instance.ignoreErrors(
CoreCourseLogHelper.instance.syncActivity(AddonModAssignProvider.COMPONENT, assignId, siteId),
);
const result: AddonModAssignSyncResult = {
warnings: [],
updated: false,
gradesBlocked: [],
};
// Load offline data and sync offline logs.
const [submissions, grades] = await Promise.all([
this.getOfflineSubmissions(assignId, siteId),
this.getOfflineGrades(assignId, siteId),
]);
if (!submissions.length && !grades.length) {
// Nothing to sync.
await CoreUtils.instance.ignoreErrors(this.setSyncTime(assignId, siteId));
return result;
}
if (!CoreApp.instance.isOnline()) {
// Cannot sync in offline.
throw new CoreNetworkError();
}
const courseId = submissions.length > 0 ? submissions[0].courseid : grades[0].courseid;
const assign = await AddonModAssign.instance.getAssignmentById(courseId, assignId, { siteId });
let promises: Promise<void>[] = [];
promises = promises.concat(submissions.map(async (submission) => {
await this.syncSubmission(assign, submission, result.warnings, siteId);
result.updated = true;
return;
}));
promises = promises.concat(grades.map(async (grade) => {
try {
await this.syncSubmissionGrade(assign, grade, result.warnings, courseId, siteId);
result.updated = true;
} catch (error) {
if (error instanceof CoreSyncBlockedError) {
// Grade blocked, but allow finish the sync.
result.gradesBlocked.push(grade.userid);
} else {
throw error;
}
}
}));
await CoreUtils.instance.allPromises(promises);
if (result.updated) {
// Data has been sent to server. Now invalidate the WS calls.
await CoreUtils.instance.ignoreErrors(AddonModAssign.instance.invalidateContent(assign.cmid, courseId, siteId));
}
// Sync finished, set sync time.
await CoreUtils.instance.ignoreErrors(this.setSyncTime(assignId, siteId));
// All done, return the result.
return result;
}
/**
* Get offline grades to be sent.
*
* @param assignId Assign ID.
* @param siteId Site ID. If not defined, current site.
* @return Promise with grades.
*/
protected async getOfflineGrades(
assignId: number,
siteId: string,
): Promise<AddonModAssignSubmissionsGradingDBRecordFormatted[]> {
// If no offline data found, return empty array.
return CoreUtils.instance.ignoreErrors(AddonModAssignOffline.instance.getAssignSubmissionsGrade(assignId, siteId), []);
}
/**
* Get offline submissions to be sent.
*
* @param assignId Assign ID.
* @param siteId Site ID. If not defined, current site.
* @return Promise with submissions.
*/
protected async getOfflineSubmissions(
assignId: number,
siteId: string,
): Promise<AddonModAssignSubmissionsDBRecordFormatted[]> {
// If no offline data found, return empty array.
return CoreUtils.instance.ignoreErrors(AddonModAssignOffline.instance.getAssignSubmissions(assignId, siteId), []);
}
/**
* Synchronize a submission.
*
* @param assign Assignment.
* @param offlineData Submission offline data.
* @param warnings List of warnings.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved if success, rejected otherwise.
*/
protected async syncSubmission(
assign: AddonModAssignAssign,
offlineData: AddonModAssignSubmissionsDBRecordFormatted,
warnings: string[],
siteId: string,
): Promise<void> {
const userId = offlineData.userid;
const pluginData = {};
const options: AddonModAssignSubmissionStatusOptions = {
userId,
cmId: assign.cmid,
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
siteId,
};
const status = await AddonModAssign.instance.getSubmissionStatus(assign.id, options);
const submission = AddonModAssign.instance.getSubmissionObjectFromAttempt(assign, status.lastattempt);
if (submission && submission.timemodified != offlineData.onlinetimemodified) {
// The submission was modified in Moodle, discard the submission.
this.addOfflineDataDeletedWarning(
warnings,
this.componentTranslate,
assign.name,
Translate.instance.instant('addon.mod_assign.warningsubmissionmodified'),
);
return this.deleteSubmissionData(assign, offlineData, submission, siteId);
}
try {
if (submission?.plugins) {
// Prepare plugins data.
await Promise.all(submission.plugins.map((plugin) =>
AddonModAssignSubmissionDelegate.instance.preparePluginSyncData(
assign,
submission,
plugin,
offlineData,
pluginData,
siteId,
)));
}
// Now save the submission.
if (Object.keys(pluginData).length > 0) {
await AddonModAssign.instance.saveSubmissionOnline(assign.id, pluginData, siteId);
}
if (assign.submissiondrafts && offlineData.submitted) {
// The user submitted the assign manually. Submit it for grading.
await AddonModAssign.instance.submitForGradingOnline(assign.id, !!offlineData.submissionstatement, siteId);
}
// Submission data sent, update cached data. No need to block the user for this.
AddonModAssign.instance.getSubmissionStatus(assign.id, options);
} catch (error) {
if (!error || !CoreUtils.instance.isWebServiceError(error)) {
// Local error, reject.
throw error;
}
// A WebService has thrown an error, this means it cannot be submitted. Discard the submission.
this.addOfflineDataDeletedWarning(
warnings,
this.componentTranslate,
assign.name,
CoreTextUtils.instance.getErrorMessageFromError(error) || '',
);
}
// Delete the offline data.
await this.deleteSubmissionData(assign, offlineData, submission, siteId);
}
/**
* Delete the submission offline data (not grades).
*
* @param assign Assign.
* @param submission Submission.
* @param offlineData Offline data.
* @param siteId Site ID.
* @return Promise resolved when done.
*/
protected async deleteSubmissionData(
assign: AddonModAssignAssign,
offlineData: AddonModAssignSubmissionsDBRecordFormatted,
submission?: AddonModAssignSubmission,
siteId?: string,
): Promise<void> {
// Delete the offline data.
await AddonModAssignOffline.instance.deleteSubmission(assign.id, offlineData.userid, siteId);
if (submission?.plugins){
// Delete plugins data.
await Promise.all(submission.plugins.map((plugin) =>
AddonModAssignSubmissionDelegate.instance.deletePluginOfflineData(
assign,
submission,
plugin,
offlineData,
siteId,
)));
}
}
/**
* Synchronize a submission grade.
*
* @param assign Assignment.
* @param offlineData Submission grade offline data.
* @param warnings List of warnings.
* @param courseId Course Id.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved if success, rejected otherwise.
*/
protected async syncSubmissionGrade(
assign: AddonModAssignAssign,
offlineData: AddonModAssignSubmissionsGradingDBRecordFormatted,
warnings: string[],
courseId: number,
siteId: string,
): Promise<void> {
const userId = offlineData.userid;
const syncId = this.getGradeSyncId(assign.id, userId);
const options: AddonModAssignSubmissionStatusOptions = {
userId,
cmId: assign.cmid,
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
siteId,
};
// Check if this grade sync is blocked.
if (CoreSync.instance.isBlocked(AddonModAssignProvider.COMPONENT, syncId, siteId)) {
this.logger.error(`Cannot sync grade for assign ${assign.id} and user ${userId} because it is blocked.!!!!`);
throw new CoreSyncBlockedError(Translate.instance.instant(
'core.errorsyncblocked',
{ $a: Translate.instance.instant('addon.mod_assign.syncblockedusercomponent') },
));
}
const status = await AddonModAssign.instance.getSubmissionStatus(assign.id, options);
const timemodified = (status.feedback && (status.feedback.gradeddate || status.feedback.grade?.timemodified)) || 0;
if (timemodified > offlineData.timemodified) {
// The submission grade was modified in Moodle, discard it.
this.addOfflineDataDeletedWarning(
warnings,
this.componentTranslate,
assign.name,
Translate.instance.instant('addon.mod_assign.warningsubmissiongrademodified'),
);
return AddonModAssignOffline.instance.deleteSubmissionGrade(assign.id, userId, siteId);
}
// If grade has been modified from gradebook, do not use offline.
const grades: CoreGradesFormattedItem[] | CoreGradesFormattedRow[] =
await CoreGradesHelper.instance.getGradeModuleItems(courseId, assign.cmid, userId, undefined, siteId, true);
const gradeInfo = await CoreCourse.instance.getModuleBasicGradeInfo(assign.cmid, siteId);
// Override offline grade and outcomes based on the gradebook data.
grades.forEach((grade: CoreGradesFormattedItem | CoreGradesFormattedRow) => {
if ('gradedategraded' in grade && (grade.gradedategraded || 0) >= offlineData.timemodified) {
if (!grade.outcomeid && !grade.scaleid) {
if (gradeInfo && gradeInfo.scale) {
offlineData.grade = this.getSelectedScaleId(gradeInfo.scale, grade.grade || '');
} else {
offlineData.grade = parseFloat(grade.grade || '');
}
} else if (gradeInfo && grade.outcomeid && AddonModAssign.instance.isOutcomesEditEnabled() && gradeInfo.outcomes) {
gradeInfo.outcomes.forEach((outcome, index) => {
if (outcome.scale && grade.itemnumber == index) {
offlineData.outcomes[grade.itemnumber] = this.getSelectedScaleId(
outcome.scale,
grade.grade || '',
);
}
});
}
}
});
try {
// Now submit the grade.
await AddonModAssign.instance.submitGradingFormOnline(
assign.id,
userId,
offlineData.grade,
offlineData.attemptnumber,
!!offlineData.addattempt,
offlineData.workflowstate,
!!offlineData.applytoall,
offlineData.outcomes,
offlineData.plugindata,
siteId,
);
// Grades sent. Discard grades drafts.
let promises: Promise<void | AddonModAssignGetSubmissionStatusWSResponse>[] = [];
if (status.feedback && status.feedback.plugins) {
promises = status.feedback.plugins.map((plugin) =>
AddonModAssignFeedbackDelegate.instance.discardPluginFeedbackData(assign.id, userId, plugin, siteId));
}
// Update cached data.
promises.push(AddonModAssign.instance.getSubmissionStatus(assign.id, options));
await CoreUtils.instance.allPromises(promises);
} catch (error) {
if (!error || !CoreUtils.instance.isWebServiceError(error)) {
// Local error, reject.
throw error;
}
// A WebService has thrown an error, this means it cannot be submitted. Discard the submission.
this.addOfflineDataDeletedWarning(
warnings,
this.componentTranslate,
assign.name,
CoreTextUtils.instance.getErrorMessageFromError(error) || '',
);
}
// Delete the offline data.
await AddonModAssignOffline.instance.deleteSubmissionGrade(assign.id, userId, siteId);
}
}
export const AddonModAssignSync = makeSingleton(AddonModAssignSyncProvider);
/**
* Data returned by a assign sync.
*/
export type AddonModAssignSyncResult = {
warnings: string[]; // List of warnings.
updated: boolean; // Whether some data was sent to the server or offline data was updated.
courseId?: number; // Course the assign belongs to (if known).
gradesBlocked: number[]; // Whether some grade couldn't be synced because it was blocked. UserId fields of the blocked grade.
};
/**
* Data passed to AUTO_SYNCED event.
*/
export type AddonModAssignAutoSyncData = CoreEventSiteData & {
assignId: number;
warnings: string[];
gradesBlocked: number[]; // Whether some grade couldn't be synced because it was blocked. UserId fields of the blocked grade.
};
/**
* Data passed to MANUAL_SYNCED event.
*/
export type AddonModAssignManualSyncData = AddonModAssignAutoSyncData & {
context: string;
submitId?: number;
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,151 @@
// (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 { CoreSiteSchema } from '@services/sites';
/**
* Database variables for AddonModAssignOfflineProvider.
*/
export const SUBMISSIONS_TABLE = 'addon_mod_assign_submissions';
export const SUBMISSIONS_GRADES_TABLE = 'addon_mod_assign_submissions_grading';
export const OFFLINE_SITE_SCHEMA: CoreSiteSchema = {
name: 'AddonModAssignOfflineProvider',
version: 1,
tables: [
{
name: SUBMISSIONS_TABLE,
columns: [
{
name: 'assignid',
type: 'INTEGER',
},
{
name: 'courseid',
type: 'INTEGER',
},
{
name: 'userid',
type: 'INTEGER',
},
{
name: 'plugindata',
type: 'TEXT',
},
{
name: 'onlinetimemodified',
type: 'INTEGER',
},
{
name: 'timecreated',
type: 'INTEGER',
},
{
name: 'timemodified',
type: 'INTEGER',
},
{
name: 'submitted',
type: 'INTEGER',
},
{
name: 'submissionstatement',
type: 'INTEGER',
},
],
primaryKeys: ['assignid', 'userid'],
},
{
name: SUBMISSIONS_GRADES_TABLE,
columns: [
{
name: 'assignid',
type: 'INTEGER',
},
{
name: 'courseid',
type: 'INTEGER',
},
{
name: 'userid',
type: 'INTEGER',
},
{
name: 'grade',
type: 'REAL',
},
{
name: 'attemptnumber',
type: 'INTEGER',
},
{
name: 'addattempt',
type: 'INTEGER',
},
{
name: 'workflowstate',
type: 'TEXT',
},
{
name: 'applytoall',
type: 'INTEGER',
},
{
name: 'outcomes',
type: 'TEXT',
},
{
name: 'plugindata',
type: 'TEXT',
},
{
name: 'timemodified',
type: 'INTEGER',
},
],
primaryKeys: ['assignid', 'userid'],
},
],
};
/**
* Data about assign submissions to sync.
*/
export type AddonModAssignSubmissionsDBRecord = {
assignid: number; // Primary key.
userid: number; // Primary key.
courseid: number;
plugindata: string;
onlinetimemodified: number;
timecreated: number;
timemodified: number;
submitted: number;
submissionstatement?: number;
};
/**
* Data about assign submission grades to sync.
*/
export type AddonModAssignSubmissionsGradingDBRecord = {
assignid: number; // Primary key.
userid: number; // Primary key.
courseid: number;
grade: number; // Real.
attemptnumber: number;
addattempt: number;
workflowstate: string;
applytoall: number;
outcomes: string;
plugindata: string;
timemodified: number;
};

View File

@ -0,0 +1,386 @@
// (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, Type } from '@angular/core';
import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate';
import { AddonModAssignDefaultFeedbackHandler } from './handlers/default-feedback';
import { AddonModAssignAssign, AddonModAssignSubmission, AddonModAssignPlugin, AddonModAssignSavePluginData } from './assign';
import { makeSingleton } from '@singletons';
import { CoreWSExternalFile } from '@services/ws';
import { AddonModAssignSubmissionFormatted } from './assign-helper';
/**
* Interface that all feedback handlers must implement.
*/
export interface AddonModAssignFeedbackHandler extends CoreDelegateHandler {
/**
* Name of the type of feedback the handler supports. E.g. 'file'.
*/
type: string;
/**
* Discard the draft data of the feedback plugin.
*
* @param assignId The assignment ID.
* @param userId User ID.
* @param siteId Site ID. If not defined, current site.
* @return If the function is async, it should return a Promise resolved when done.
*/
discardDraft?(assignId: number, userId: number, siteId?: string): void | Promise<void>;
/**
* Return the Component to use to display the plugin data.
* It's recommended to return the class of the component, but you can also return an instance of the component.
*
* @param plugin The plugin object.
* @return The component (or promise resolved with component) to use, undefined if not found.
*/
getComponent?(plugin: AddonModAssignPlugin): Type<unknown> | undefined | Promise<Type<unknown> | undefined>;
/**
* Return the draft saved data of the feedback plugin.
*
* @param assignId The assignment ID.
* @param userId User ID.
* @param siteId Site ID. If not defined, current site.
* @return Data (or promise resolved with the data).
*/
getDraft?(
assignId: number,
userId: number,
siteId?: string,
): Record<string, unknown> | Promise<Record<string, unknown> | undefined> | undefined;
/**
* Get files used by this plugin.
* The files returned by this function will be prefetched when the user prefetches the assign.
*
* @param assign The assignment.
* @param submission The submission.
* @param plugin The plugin object.
* @param siteId Site ID. If not defined, current site.
* @return The files (or promise resolved with the files).
*/
getPluginFiles?(
assign: AddonModAssignAssign,
submission: AddonModAssignSubmission,
plugin: AddonModAssignPlugin,
siteId?: string,
): CoreWSExternalFile[] | Promise<CoreWSExternalFile[]>;
/**
* Get a readable name to use for the plugin.
*
* @param plugin The plugin object.
* @return The plugin name.
*/
getPluginName?(plugin: AddonModAssignPlugin): string;
/**
* Check if the feedback data has changed for this plugin.
*
* @param assign The assignment.
* @param submission The submission.
* @param plugin The plugin object.
* @param inputData Data entered by the user for the feedback.
* @param userId User ID of the submission.
* @return Boolean (or promise resolved with boolean): whether the data has changed.
*/
hasDataChanged?(
assign: AddonModAssignAssign,
submission: AddonModAssignSubmission,
plugin: AddonModAssignPlugin,
inputData: Record<string, unknown>,
userId: number,
): boolean | Promise<boolean>;
/**
* Check whether the plugin has draft data stored.
*
* @param assignId The assignment ID.
* @param userId User ID.
* @param siteId Site ID. If not defined, current site.
* @return Boolean or promise resolved with boolean: whether the plugin has draft data.
*/
hasDraftData?(assignId: number, userId: number, siteId?: string): boolean | Promise<boolean>;
/**
* Prefetch any required data for the plugin.
* This should NOT prefetch files. Files to be prefetched should be returned by the getPluginFiles function.
*
* @param assign The assignment.
* @param submission The submission.
* @param plugin The plugin object.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done.
*/
prefetch?(
assign: AddonModAssignAssign,
submission: AddonModAssignSubmission,
plugin: AddonModAssignPlugin,
siteId?: string,
): Promise<void>;
/**
* Prepare and add to pluginData the data to send to the server based on the draft data saved.
*
* @param assignId The assignment ID.
* @param userId User ID.
* @param plugin The plugin object.
* @param pluginData Object where to store the data to send.
* @param siteId Site ID. If not defined, current site.
* @return If the function is async, it should return a Promise resolved when done.
*/
prepareFeedbackData?(
assignId: number,
userId: number,
plugin: AddonModAssignPlugin,
pluginData: AddonModAssignSavePluginData,
siteId?: string,
): void | Promise<void>;
/**
* Save draft data of the feedback plugin.
*
* @param assignId The assignment ID.
* @param userId User ID.
* @param plugin The plugin object.
* @param data The data to save.
* @param siteId Site ID. If not defined, current site.
* @return If the function is async, it should return a Promise resolved when done.
*/
saveDraft?(
assignId: number,
userId: number,
plugin: AddonModAssignPlugin,
data: Record<string, unknown>,
siteId?: string,
): void | Promise<void>;
}
/**
* Delegate to register plugins for assign feedback.
*/
@Injectable({ providedIn: 'root' })
export class AddonModAssignFeedbackDelegateService extends CoreDelegate<AddonModAssignFeedbackHandler> {
protected handlerNameProperty = 'type';
constructor(
protected defaultHandler: AddonModAssignDefaultFeedbackHandler,
) {
super('AddonModAssignFeedbackDelegate', true);
}
/**
* Discard the draft data of the feedback plugin.
*
* @param assignId The assignment ID.
* @param userId User ID.
* @param plugin The plugin object.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done.
*/
async discardPluginFeedbackData(
assignId: number,
userId: number,
plugin: AddonModAssignPlugin,
siteId?: string,
): Promise<void> {
return await this.executeFunctionOnEnabled(plugin.type, 'discardDraft', [assignId, userId, siteId]);
}
/**
* Get the component to use for a certain feedback plugin.
*
* @param plugin The plugin object.
* @return Promise resolved with the component to use, undefined if not found.
*/
async getComponentForPlugin(plugin: AddonModAssignPlugin): Promise<Type<unknown> | undefined> {
return await this.executeFunctionOnEnabled(plugin.type, 'getComponent', [plugin]);
}
/**
* Return the draft saved data of the feedback plugin.
*
* @param assignId The assignment ID.
* @param userId User ID.
* @param plugin The plugin object.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with the draft data.
*/
async getPluginDraftData<T>(
assignId: number,
userId: number,
plugin: AddonModAssignPlugin,
siteId?: string,
): Promise<T | undefined> {
return await this.executeFunctionOnEnabled(plugin.type, 'getDraft', [assignId, userId, siteId]);
}
/**
* Get files used by this plugin.
* The files returned by this function will be prefetched when the user prefetches the assign.
*
* @param assign The assignment.
* @param submission The submission.
* @param plugin The plugin object.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with the files.
*/
async getPluginFiles(
assign: AddonModAssignAssign,
submission: AddonModAssignSubmission,
plugin: AddonModAssignPlugin,
siteId?: string,
): Promise<CoreWSExternalFile[]> {
const files: CoreWSExternalFile[] | undefined =
await this.executeFunctionOnEnabled(plugin.type, 'getPluginFiles', [assign, submission, plugin, siteId]);
return files || [];
}
/**
* Get a readable name to use for a certain feedback plugin.
*
* @param plugin Plugin to get the name for.
* @return Human readable name.
*/
getPluginName(plugin: AddonModAssignPlugin): string | undefined {
return this.executeFunctionOnEnabled(plugin.type, 'getPluginName', [plugin]);
}
/**
* Check if the feedback data has changed for a certain plugin.
*
* @param assign The assignment.
* @param submission The submission.
* @param plugin The plugin object.
* @param inputData Data entered by the user for the feedback.
* @param userId User ID of the submission.
* @return Promise resolved with true if data has changed, resolved with false otherwise.
*/
async hasPluginDataChanged(
assign: AddonModAssignAssign,
submission: AddonModAssignSubmission | AddonModAssignSubmissionFormatted,
plugin: AddonModAssignPlugin,
inputData: Record<string, unknown>,
userId: number,
): Promise<boolean | undefined> {
return await this.executeFunctionOnEnabled(
plugin.type,
'hasDataChanged',
[assign, submission, plugin, inputData, userId],
);
}
/**
* Check whether the plugin has draft data stored.
*
* @param assignId The assignment ID.
* @param userId User ID.
* @param plugin The plugin object.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with true if it has draft data.
*/
async hasPluginDraftData(
assignId: number,
userId: number,
plugin: AddonModAssignPlugin,
siteId?: string,
): Promise<boolean | undefined> {
return await this.executeFunctionOnEnabled(plugin.type, 'hasDraftData', [assignId, userId, siteId]);
}
/**
* Check if a feedback plugin is supported.
*
* @param pluginType Type of the plugin.
* @return Whether it's supported.
*/
isPluginSupported(pluginType: string): boolean {
return this.hasHandler(pluginType, true);
}
/**
* Prefetch any required data for a feedback plugin.
*
* @param assign The assignment.
* @param submission The submission.
* @param plugin The plugin object.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done.
*/
async prefetch(
assign: AddonModAssignAssign,
submission: AddonModAssignSubmission,
plugin: AddonModAssignPlugin,
siteId?: string,
): Promise<void> {
return await this.executeFunctionOnEnabled(plugin.type, 'prefetch', [assign, submission, plugin, siteId]);
}
/**
* Prepare and add to pluginData the data to submit for a certain feedback plugin.
*
* @param assignId The assignment ID.
* @param userId User ID.
* @param plugin The plugin object.
* @param pluginData Object where to store the data to send.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when data has been gathered.
*/
async preparePluginFeedbackData(
assignId: number,
userId: number,
plugin: AddonModAssignPlugin,
pluginData: AddonModAssignSavePluginData,
siteId?: string,
): Promise<void> {
return await this.executeFunctionOnEnabled(
plugin.type,
'prepareFeedbackData',
[assignId, userId, plugin, pluginData, siteId],
);
}
/**
* Save draft data of the feedback plugin.
*
* @param assignId The assignment ID.
* @param userId User ID.
* @param plugin The plugin object.
* @param inputData Data to save.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when data has been saved.
*/
async saveFeedbackDraft(
assignId: number,
userId: number,
plugin: AddonModAssignPlugin,
inputData: Record<string, unknown>,
siteId?: string,
): Promise<void> {
return await this.executeFunctionOnEnabled(
plugin.type,
'saveDraft',
[assignId, userId, plugin, inputData, siteId],
);
}
}
export const AddonModAssignFeedbackDelegate = makeSingleton(AddonModAssignFeedbackDelegateService);

View File

@ -0,0 +1,138 @@
// (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 { CoreWSExternalFile } from '@services/ws';
import { Translate } from '@singletons';
import { AddonModAssignPlugin } from '../assign';
import { AddonModAssignFeedbackHandler } from '../feedback-delegate';
/**
* Default handler used when a feedback plugin doesn't have a specific implementation.
*/
@Injectable({ providedIn: 'root' })
export class AddonModAssignDefaultFeedbackHandler implements AddonModAssignFeedbackHandler {
name = 'AddonModAssignDefaultFeedbackHandler';
type = 'default';
/**
* Discard the draft data of the feedback plugin.
*
* @return If the function is async, it should return a Promise resolved when done.
*/
discardDraft(): void {
// Nothing to do.
}
/**
* Return the draft saved data of the feedback plugin.
*
* @return Data (or promise resolved with the data).
*/
getDraft(): undefined {
// Nothing to do.
return;
}
/**
* Get files used by this plugin.
* The files returned by this function will be prefetched when the user prefetches the assign.
*
* @return The files (or promise resolved with the files).
*/
getPluginFiles(): CoreWSExternalFile[] {
return [];
}
/**
* Get a readable name to use for the plugin.
*
* @param plugin The plugin object.
* @return The plugin name.
*/
getPluginName(plugin: AddonModAssignPlugin): string {
// Check if there's a translated string for the plugin.
const translationId = 'addon.mod_assign_feedback_' + plugin.type + '.pluginname';
const translation = Translate.instance.instant(translationId);
if (translationId != translation) {
// Translation found, use it.
return translation;
}
// Fallback to WS string.
if (plugin.name) {
return plugin.name;
}
return '';
}
/**
* Check if the feedback data has changed for this plugin.
*
* @return Boolean (or promise resolved with boolean): whether the data has changed.
*/
hasDataChanged(): boolean {
return false;
}
/**
* Check whether the plugin has draft data stored.
*
* @return Boolean or promise resolved with boolean: whether the plugin has draft data.
*/
hasDraftData(): boolean {
return false;
}
/**
* Whether or not the handler is enabled on a site level.
*
* @return True or promise resolved with true if enabled.
*/
async isEnabled(): Promise<boolean> {
return true;
}
/**
* Prefetch any required data for the plugin.
* This should NOT prefetch files. Files to be prefetched should be returned by the getPluginFiles function.
*
* @return Promise resolved when done.
*/
async prefetch(): Promise<void> {
return;
}
/**
* Prepare and add to pluginData the data to send to the server based on the draft data saved.
*
* @return If the function is async, it should return a Promise resolved when done.
*/
prepareFeedbackData(): void {
// Nothing to do.
}
/**
* Save draft data of the feedback plugin.
*
* @return If the function is async, it should return a Promise resolved when done.
*/
saveDraft(): void {
// Nothing to do.
}
}

View File

@ -0,0 +1,201 @@
// (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 { CoreWSExternalFile } from '@services/ws';
import { Translate } from '@singletons';
import { AddonModAssignPlugin } from '../assign';
import { AddonModAssignSubmissionHandler } from '../submission-delegate';
/**
* Default handler used when a submission plugin doesn't have a specific implementation.
*/
@Injectable({ providedIn: 'root' })
export class AddonModAssignDefaultSubmissionHandler implements AddonModAssignSubmissionHandler {
name = 'AddonModAssignBaseSubmissionHandler';
type = 'base';
/**
* Whether the plugin can be edited in offline for existing submissions. In general, this should return false if the
* plugin uses Moodle filters. The reason is that the app only prefetches filtered data, and the user should edit
* unfiltered data.
*
* @return Boolean or promise resolved with boolean: whether it can be edited in offline.
*/
canEditOffline(): boolean | Promise<boolean> {
return false;
}
/**
* Check if a plugin has no data.
*
* @return Whether the plugin is empty.
*/
isEmpty(): boolean {
return true;
}
/**
* Should clear temporary data for a cancelled submission.
*/
clearTmpData(): void {
// Nothing to do.
}
/**
* This function will be called when the user wants to create a new submission based on the previous one.
* It should add to pluginData the data to send to server based in the data in plugin (previous attempt).
*
* @return If the function is async, it should return a Promise resolved when done.
*/
copySubmissionData(): void {
// Nothing to do.
}
/**
* Delete any stored data for the plugin and submission.
*
* @return If the function is async, it should return a Promise resolved when done.
*/
deleteOfflineData(): void {
// Nothing to do.
}
/**
* Get files used by this plugin.
* The files returned by this function will be prefetched when the user prefetches the assign.
*
* @return The files (or promise resolved with the files).
*/
getPluginFiles(): CoreWSExternalFile[] {
return [];
}
/**
* Get a readable name to use for the plugin.
*
* @param plugin The plugin object.
* @return The plugin name.
*/
getPluginName(plugin: AddonModAssignPlugin): string {
// Check if there's a translated string for the plugin.
const translationId = 'addon.mod_assign_submission_' + plugin.type + '.pluginname';
const translation = Translate.instance.instant(translationId);
if (translationId != translation) {
// Translation found, use it.
return translation;
}
// Fallback to WS string.
if (plugin.name) {
return plugin.name;
}
return '';
}
/**
* Get the size of data (in bytes) this plugin will send to copy a previous submission.
*
* @param assign The assignment.
* @param plugin The plugin object.
* @return The size (or promise resolved with size).
*/
getSizeForCopy(): number {
return 0;
}
/**
* Get the size of data (in bytes) this plugin will send to add or edit a submission.
*
* @param assign The assignment.
* @param submission The submission.
* @param plugin The plugin object.
* @return The size (or promise resolved with size).
*/
getSizeForEdit(): number {
return 0;
}
/**
* Check if the submission data has changed for this plugin.
*
* @param assign The assignment.
* @param submission The submission.
* @param plugin The plugin object.
* @param inputData Data entered by the user for the submission.
* @return Boolean (or promise resolved with boolean): whether the data has changed.
*/
hasDataChanged(): boolean {
return false;
}
/**
* Whether or not the handler is enabled on a site level.
*
* @return True or promise resolved with true if enabled.
*/
async isEnabled(): Promise<boolean> {
return true;
}
/**
* Whether or not the handler is enabled for edit on a site level.
*
* @return Whether or not the handler is enabled for edit on a site level.
*/
isEnabledForEdit(): boolean {
return false;
}
/**
* Prefetch any required data for the plugin.
* This should NOT prefetch files. Files to be prefetched should be returned by the getPluginFiles function.
*
* @return Promise resolved when done.
*/
async prefetch(): Promise<void> {
return;
}
/**
* Prepare and add to pluginData the data to send to the server based on the input data.
*
* @param assign The assignment.
* @param submission The submission.
* @param plugin The plugin object.
* @param inputData Data entered by the user for the submission.
* @param pluginData Object where to store the data to send.
* @param offline Whether the user is editing in offline.
* @param userId User ID. If not defined, site's current user.
* @param siteId Site ID. If not defined, current site.
* @return If the function is async, it should return a Promise resolved when done.
*/
prepareSubmissionData(): void {
// Nothing to do.
}
/**
* Prepare and add to pluginData the data to send to the server based on the offline data stored.
* This will be used when performing a synchronization.
*
* @return If the function is async, it should return a Promise resolved when done.
*/
prepareSyncData(): void {
// Nothing to do.
}
}

View File

@ -0,0 +1,32 @@
// (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 { CoreContentLinksModuleIndexHandler } from '@features/contentlinks/classes/module-index-handler';
import { makeSingleton } from '@singletons';
/**
* Handler to treat links to assign index page.
*/
@Injectable({ providedIn: 'root' })
export class AddonModAssignIndexLinkHandlerService extends CoreContentLinksModuleIndexHandler {
name = 'AddonModAssignIndexLinkHandler';
constructor() {
super('AddonModAssign', 'assign');
}
}
export const AddonModAssignIndexLinkHandler = makeSingleton(AddonModAssignIndexLinkHandlerService);

View File

@ -0,0 +1,32 @@
// (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 { CoreContentLinksModuleListHandler } from '@features/contentlinks/classes/module-list-handler';
import { makeSingleton } from '@singletons';
/**
* Handler to treat links to assign list page.
*/
@Injectable({ providedIn: 'root' })
export class AddonModAssignListLinkHandlerService extends CoreContentLinksModuleListHandler {
name = 'AddonModAssignListLinkHandler';
constructor() {
super('AddonModAssign', 'assign');
}
}
export const AddonModAssignListLinkHandler = makeSingleton(AddonModAssignListLinkHandlerService);

View File

@ -0,0 +1,94 @@
// (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 { CoreConstants } from '@/core/constants';
import { Injectable, Type } from '@angular/core';
import { CoreCourseModuleHandler, CoreCourseModuleHandlerData } from '@features/course/services/module-delegate';
import { AddonModAssignIndexComponent } from '../../components/index';
import { makeSingleton } from '@singletons';
import { CoreCourse, CoreCourseAnyModuleData } from '@features/course/services/course';
import { CoreCourseModule } from '@features/course/services/course-helper';
import { CoreNavigationOptions, CoreNavigator } from '@services/navigator';
import { AddonModAssign } from '../assign';
/**
* Handler to support assign modules.
*/
@Injectable({ providedIn: 'root' })
export class AddonModAssignModuleHandlerService implements CoreCourseModuleHandler {
static readonly PAGE_NAME = 'mod_assign';
name = 'AddonModAssign';
modName = 'assign';
supportedFeatures = {
[CoreConstants.FEATURE_GROUPS]: true,
[CoreConstants.FEATURE_GROUPINGS]: true,
[CoreConstants.FEATURE_MOD_INTRO]: true,
[CoreConstants.FEATURE_COMPLETION_TRACKS_VIEWS]: true,
[CoreConstants.FEATURE_COMPLETION_HAS_RULES]: true,
[CoreConstants.FEATURE_GRADE_HAS_GRADE]: true,
[CoreConstants.FEATURE_GRADE_OUTCOMES]: true,
[CoreConstants.FEATURE_BACKUP_MOODLE2]: true,
[CoreConstants.FEATURE_SHOW_DESCRIPTION]: true,
[CoreConstants.FEATURE_ADVANCED_GRADING]: true,
[CoreConstants.FEATURE_PLAGIARISM]: true,
[CoreConstants.FEATURE_COMMENT]: true,
};
/**
* Check if the handler is enabled on a site level.
*
* @return Whether or not the handler is enabled on a site level.
*/
async isEnabled(): Promise<boolean> {
return AddonModAssign.instance.isPluginEnabled();
}
/**
* Get the data required to display the module in the course contents view.
*
* @param module The module object.
* @return Data to render the module.
*/
getData(module: CoreCourseAnyModuleData): CoreCourseModuleHandlerData {
return {
icon: CoreCourse.instance.getModuleIconSrc(this.modName, 'modicon' in module ? module.modicon : undefined),
title: module.name,
class: 'addon-mod_assign-handler',
showDownloadButton: true,
action(event: Event, module: CoreCourseModule, courseId: number, options?: CoreNavigationOptions): void {
options = options || {};
options.params = options.params || {};
Object.assign(options.params, { module });
const routeParams = '/' + courseId + '/' + module.id;
CoreNavigator.instance.navigateToSitePath(AddonModAssignModuleHandlerService.PAGE_NAME + routeParams, options);
},
};
}
/**
* Get the component to render the module. This is needed to support singleactivity course format.
* The component returned must implement CoreCourseModuleMainComponent.
*
* @return The component to use, undefined if not found.
*/
async getMainComponent(): Promise<Type<unknown> | undefined> {
return AddonModAssignIndexComponent;
}
}
export const AddonModAssignModuleHandler = makeSingleton(AddonModAssignModuleHandlerService);

View File

@ -0,0 +1,531 @@
// (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 { CoreSites, CoreSitesCommonWSOptions, CoreSitesReadingStrategy } from '@services/sites';
import { makeSingleton } from '@singletons';
import {
AddonModAssign,
AddonModAssignAssign,
AddonModAssignProvider,
AddonModAssignSubmission,
AddonModAssignSubmissionStatusOptions,
} from '../assign';
import { AddonModAssignSubmissionDelegate } from '../submission-delegate';
import { AddonModAssignFeedbackDelegate } from '../feedback-delegate';
import { CoreCourseActivityPrefetchHandlerBase } from '@features/course/classes/activity-prefetch-handler';
import { CoreCourse, CoreCourseAnyModuleData, CoreCourseCommonModWSOptions } from '@features/course/services/course';
import { CoreWSExternalFile } from '@services/ws';
import { AddonModAssignHelper, AddonModAssignSubmissionFormatted } from '../assign-helper';
import { CoreCourseHelper } from '@features/course/services/course-helper';
import { CoreUtils } from '@services/utils/utils';
import { CoreFilepool } from '@services/filepool';
import { CoreGroups } from '@services/groups';
import { AddonModAssignSync, AddonModAssignSyncResult } from '../assign-sync';
import { CoreUser } from '@features/user/services/user';
import { CoreGradesHelper } from '@features/grades/services/grades-helper';
/**
* Handler to prefetch assigns.
*/
@Injectable({ providedIn: 'root' })
export class AddonModAssignPrefetchHandlerService extends CoreCourseActivityPrefetchHandlerBase {
name = 'AddonModAssign';
modName = 'assign';
component = AddonModAssignProvider.COMPONENT;
updatesNames = /^configuration$|^.*files$|^submissions$|^grades$|^gradeitems$|^outcomes$|^comments$/;
/**
* Check if a certain module can use core_course_check_updates to check if it has updates.
* If not defined, it will assume all modules can be checked.
* The modules that return false will always be shown as outdated when they're downloaded.
*
* @param module Module.
* @param courseId Course ID the module belongs to.
* @return Whether the module can use check_updates. The promise should never be rejected.
*/
async canUseCheckUpdates(module: CoreCourseAnyModuleData, courseId: number): Promise<boolean> {
// Teachers cannot use the WS because it doesn't check student submissions.
try {
const assign = await AddonModAssign.instance.getAssignment(courseId, module.id);
const data = await AddonModAssign.instance.getSubmissions(assign.id, { cmId: module.id });
if (data.canviewsubmissions) {
return false;
}
// Check if the user can view their own submission.
await AddonModAssign.instance.getSubmissionStatus(assign.id, { cmId: module.id });
return true;
} catch {
return false;
}
}
/**
* Get list of files. If not defined, we'll assume they're in module.contents.
*
* @param module Module.
* @param courseId Course ID the module belongs to.
* @return Promise resolved with the list of files.
*/
async getFiles(module: CoreCourseAnyModuleData, courseId: number): Promise<CoreWSExternalFile[]> {
const siteId = CoreSites.instance.getCurrentSiteId();
try {
const assign = await AddonModAssign.instance.getAssignment(courseId, module.id, { siteId });
// Get intro files and attachments.
let files = assign.introattachments || [];
files = files.concat(this.getIntroFilesFromInstance(module, assign));
// Now get the files in the submissions.
const submissionData = await AddonModAssign.instance.getSubmissions(assign.id, { cmId: module.id, siteId });
if (submissionData.canviewsubmissions) {
// Teacher, get all submissions.
const submissions =
await AddonModAssignHelper.instance.getSubmissionsUserData(assign, submissionData.submissions, 0, { siteId });
// Get all the files in the submissions.
const promises = submissions.map((submission) =>
this.getSubmissionFiles(assign, submission.submitid!, !!submission.blindid, siteId).then((submissionFiles) => {
files = files.concat(submissionFiles);
return;
}).catch((error) => {
if (error && error.errorcode == 'nopermission') {
// The user does not have persmission to view this submission, ignore it.
return;
}
throw error;
}));
await Promise.all(promises);
} else {
// Student, get only his/her submissions.
const userId = CoreSites.instance.getCurrentSiteUserId();
const blindMarking = !!assign.blindmarking && !assign.revealidentities;
const submissionFiles = await this.getSubmissionFiles(assign, userId, blindMarking, siteId);
files = files.concat(submissionFiles);
}
return files;
} catch {
// Error getting data, return empty list.
return [];
}
}
/**
* Get submission files.
*
* @param assign Assign.
* @param submitId User ID of the submission to get.
* @param blindMarking True if blind marking, false otherwise.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with array of files.
*/
protected async getSubmissionFiles(
assign: AddonModAssignAssign,
submitId: number,
blindMarking: boolean,
siteId?: string,
): Promise<CoreWSExternalFile[]> {
const submissionStatus = await AddonModAssign.instance.getSubmissionStatusWithRetry(assign, {
userId: submitId,
isBlind: blindMarking,
siteId,
});
const userSubmission = AddonModAssign.instance.getSubmissionObjectFromAttempt(assign, submissionStatus.lastattempt);
if (!submissionStatus.lastattempt || !userSubmission) {
return [];
}
const promises: Promise<CoreWSExternalFile[]>[] = [];
if (userSubmission.plugins) {
// Add submission plugin files.
userSubmission.plugins.forEach((plugin) => {
promises.push(AddonModAssignSubmissionDelegate.instance.getPluginFiles(assign, userSubmission, plugin, siteId));
});
}
if (submissionStatus.feedback && submissionStatus.feedback.plugins) {
// Add feedback plugin files.
submissionStatus.feedback.plugins.forEach((plugin) => {
promises.push(AddonModAssignFeedbackDelegate.instance.getPluginFiles(assign, userSubmission, plugin, siteId));
});
}
const filesLists = await Promise.all(promises);
return [].concat.apply([], filesLists);
}
/**
* Invalidate the prefetched content.
*
* @param moduleId The module ID.
* @param courseId The course ID the module belongs to.
* @return Promise resolved when the data is invalidated.
*/
async invalidateContent(moduleId: number, courseId: number): Promise<void> {
await AddonModAssign.instance.invalidateContent(moduleId, courseId);
}
/**
* Invalidate WS calls needed to determine module status.
*
* @param module Module.
* @param courseId Course ID the module belongs to.
* @return Promise resolved when invalidated.
*/
async invalidateModule(module: CoreCourseAnyModuleData): Promise<void> {
return CoreCourse.instance.invalidateModule(module.id);
}
/**
* Whether or not the handler is enabled on a site level.
*
* @return A boolean, or a promise resolved with a boolean, indicating if the handler is enabled.
*/
async isEnabled(): Promise<boolean> {
return AddonModAssign.instance.isPluginEnabled();
}
/**
* Prefetch a module.
*
* @param module Module.
* @param courseId Course ID the module belongs to.
* @return Promise resolved when done.
*/
prefetch(module: CoreCourseAnyModuleData, courseId?: number): Promise<void> {
return this.prefetchPackage(module, courseId, this.prefetchAssign.bind(this, module, courseId));
}
/**
* Prefetch an assignment.
*
* @param module Module.
* @param courseId Course ID the module belongs to.
* @return Promise resolved when done.
*/
protected async prefetchAssign(module: CoreCourseAnyModuleData, courseId?: number): Promise<void> {
const userId = CoreSites.instance.getCurrentSiteUserId();
courseId = courseId || module.course || CoreSites.instance.getCurrentSiteHomeId();
const siteId = CoreSites.instance.getCurrentSiteId();
const options: CoreSitesCommonWSOptions = {
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
siteId,
};
const modOptions: CoreCourseCommonModWSOptions = {
cmId: module.id,
...options,
};
// Get assignment to retrieve all its submissions.
const assign = await AddonModAssign.instance.getAssignment(courseId, module.id, options);
const promises: Promise<any>[] = [];
const blindMarking = assign.blindmarking && !assign.revealidentities;
if (blindMarking) {
promises.push(
CoreUtils.instance.ignoreErrors(AddonModAssign.instance.getAssignmentUserMappings(assign.id, -1, modOptions)),
);
}
promises.push(this.prefetchSubmissions(assign, courseId, module.id, userId, siteId));
promises.push(CoreCourseHelper.instance.getModuleCourseIdByInstance(assign.id, 'assign', siteId));
// Download intro files and attachments. Do not call getFiles because it'd call some WS twice.
let files = assign.introattachments || [];
files = files.concat(this.getIntroFilesFromInstance(module, assign));
promises.push(CoreFilepool.instance.addFilesToQueue(siteId, files, this.component, module.id));
await Promise.all(promises);
}
/**
* Prefetch assign submissions.
*
* @param assign Assign.
* @param courseId Course ID.
* @param moduleId Module ID.
* @param userId User ID. If not defined, site's current user.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when prefetched, rejected otherwise.
*/
protected async prefetchSubmissions(
assign: AddonModAssignAssign,
courseId: number,
moduleId: number,
userId: number,
siteId: string,
): Promise<void> {
const modOptions: CoreCourseCommonModWSOptions = {
cmId: moduleId,
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
siteId,
};
// Get submissions.
const submissions = await AddonModAssign.instance.getSubmissions(assign.id, modOptions);
const promises: Promise<any>[] = [];
promises.push(this.prefetchParticipantSubmissions(
assign,
submissions.canviewsubmissions,
submissions.submissions,
moduleId,
courseId,
userId,
siteId,
));
// Prefetch own submission, we need to do this for teachers too so the response with error is cached.
promises.push(
this.prefetchSubmission(
assign,
courseId,
moduleId,
{
userId,
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
siteId,
},
true,
),
);
await Promise.all(promises);
}
protected async prefetchParticipantSubmissions(
assign: AddonModAssignAssign,
canviewsubmissions: boolean,
submissions: AddonModAssignSubmission[] = [],
moduleId: number,
courseId: number,
userId: number,
siteId: string,
): Promise<void> {
const options: CoreSitesCommonWSOptions = {
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
siteId,
};
const modOptions: CoreCourseCommonModWSOptions = {
cmId: moduleId,
...options,
};
// Always prefetch groupInfo.
const groupInfo = await CoreGroups.instance.getActivityGroupInfo(assign.cmid, false, undefined, siteId);
if (!canviewsubmissions) {
return;
}
// Teacher, prefetch all submissions.
if (!groupInfo.groups || groupInfo.groups.length == 0) {
groupInfo.groups = [{ id: 0, name: '' }];
}
const promises = groupInfo.groups.map((group) =>
AddonModAssignHelper.instance.getSubmissionsUserData(assign, submissions, group.id, options)
.then((submissions: AddonModAssignSubmissionFormatted[]) => {
const subPromises: Promise<any>[] = submissions.map((submission) => {
const submissionOptions = {
userId: submission.submitid,
groupId: group.id,
isBlind: !!submission.blindid,
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
siteId,
};
return this.prefetchSubmission(assign, courseId, moduleId, submissionOptions, true);
});
if (!assign.markingworkflow) {
// Get assignment grades only if workflow is not enabled to check grading date.
subPromises.push(AddonModAssign.instance.getAssignmentGrades(assign.id, modOptions));
}
// Prefetch the submission of the current user even if it does not exist, this will be create it.
if (!submissions || !submissions.find((subm: AddonModAssignSubmissionFormatted) => subm.submitid == userId)) {
const submissionOptions = {
userId,
groupId: group.id,
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
siteId,
};
subPromises.push(this.prefetchSubmission(assign, courseId, moduleId, submissionOptions));
}
return Promise.all(subPromises);
}).then(async () => {
// Participiants already fetched, we don't need to ignore cache now.
const participants = await AddonModAssignHelper.instance.getParticipants(assign, group.id, { siteId });
// Fail silently (Moodle < 3.2).
await CoreUtils.instance.ignoreErrors(
CoreUser.instance.prefetchUserAvatars(participants, 'profileimageurl', siteId),
);
return;
}));
await Promise.all(promises);
}
/**
* Prefetch a submission.
*
* @param assign Assign.
* @param courseId Course ID.
* @param moduleId Module ID.
* @param options Other options, see getSubmissionStatusWithRetry.
* @param resolveOnNoPermission If true, will avoid throwing if a nopermission error is raised.
* @return Promise resolved when prefetched, rejected otherwise.
*/
protected async prefetchSubmission(
assign: AddonModAssignAssign,
courseId: number,
moduleId: number,
options: AddonModAssignSubmissionStatusOptions = {},
resolveOnNoPermission = false,
): Promise<void> {
const submission = await AddonModAssign.instance.getSubmissionStatusWithRetry(assign, options);
const siteId = options.siteId!;
const userId = options.userId;
try {
const promises: Promise<any>[] = [];
const blindMarking = !!assign.blindmarking && !assign.revealidentities;
let userIds: number[] = [];
const userSubmission = AddonModAssign.instance.getSubmissionObjectFromAttempt(assign, submission.lastattempt);
if (submission.lastattempt) {
// Get IDs of the members who need to submit.
if (!blindMarking && submission.lastattempt.submissiongroupmemberswhoneedtosubmit) {
userIds = userIds.concat(submission.lastattempt.submissiongroupmemberswhoneedtosubmit);
}
if (userSubmission && userSubmission.id) {
// Prefetch submission plugins data.
if (userSubmission.plugins) {
userSubmission.plugins.forEach((plugin) => {
// Prefetch the plugin WS data.
promises.push(
AddonModAssignSubmissionDelegate.instance.prefetch(assign, userSubmission, plugin, siteId),
);
// Prefetch the plugin files.
promises.push(
AddonModAssignSubmissionDelegate.instance.getPluginFiles(assign, userSubmission, plugin, siteId)
.then((files) =>
CoreFilepool.instance.addFilesToQueue(siteId, files, this.component, module.id))
.catch(() => {
// Ignore errors.
}),
);
});
}
// Get ID of the user who did the submission.
if (userSubmission.userid) {
userIds.push(userSubmission.userid);
}
}
}
// Prefetch grade items.
if (userId) {
promises.push(CoreCourse.instance.getModuleBasicGradeInfo(moduleId, siteId).then((gradeInfo) => {
if (gradeInfo) {
promises.push(
CoreGradesHelper.instance.getGradeModuleItems(courseId, moduleId, userId, undefined, siteId, true),
);
}
return;
}));
}
// Prefetch feedback.
if (submission.feedback) {
// Get profile and image of the grader.
if (submission.feedback.grade && submission.feedback.grade.grader > 0) {
userIds.push(submission.feedback.grade.grader);
}
// Prefetch feedback plugins data.
if (submission.feedback.plugins && userSubmission && userSubmission.id) {
submission.feedback.plugins.forEach((plugin) => {
// Prefetch the plugin WS data.
promises.push(AddonModAssignFeedbackDelegate.instance.prefetch(assign, userSubmission, plugin, siteId));
// Prefetch the plugin files.
promises.push(
AddonModAssignFeedbackDelegate.instance.getPluginFiles(assign, userSubmission, plugin, siteId)
.then((files) => CoreFilepool.instance.addFilesToQueue(siteId, files, this.component, module.id))
.catch(() => {
// Ignore errors.
}),
);
});
}
}
// Prefetch user profiles.
promises.push(CoreUser.instance.prefetchProfiles(userIds, courseId, siteId));
await Promise.all(promises);
} catch (error) {
// Ignore if the user can't view their own submission.
if (resolveOnNoPermission && error.errorcode != 'nopermission') {
throw error;
}
}
}
/**
* Sync a module.
*
* @param module Module.
* @param courseId Course ID the module belongs to
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done.
*/
sync(module: CoreCourseAnyModuleData, courseId: number, siteId?: string): Promise<AddonModAssignSyncResult> {
return AddonModAssignSync.instance.syncAssign(module.instance!, siteId);
}
}
export const AddonModAssignPrefetchHandler = makeSingleton(AddonModAssignPrefetchHandlerService);

View File

@ -0,0 +1,66 @@
// (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 { CoreCourseHelper } from '@features/course/services/course-helper';
import { CorePushNotificationsClickHandler } from '@features/pushnotifications/services/push-delegate';
import { CorePushNotificationsNotificationBasicData } from '@features/pushnotifications/services/pushnotifications';
import { CoreUrlUtils } from '@services/utils/url';
import { CoreUtils } from '@services/utils/utils';
import { makeSingleton } from '@singletons';
import { AddonModAssign } from '../assign';
/**
* Handler for assign push notifications clicks.
*/
@Injectable({ providedIn: 'root' })
export class AddonModAssignPushClickHandlerService implements CorePushNotificationsClickHandler {
name = 'AddonModAssignPushClickHandler';
priority = 200;
featureName = 'CoreCourseModuleDelegate_AddonModAssign';
/**
* Check if a notification click is handled by this handler.
*
* @param notification The notification to check.
* @return Whether the notification click is handled by this handler
*/
async handles(notification: NotificationData): Promise<boolean> {
return CoreUtils.instance.isTrueOrOne(notification.notif) && notification.moodlecomponent == 'mod_assign' &&
notification.name == 'assign_notification';
}
/**
* Handle the notification click.
*
* @param notification The notification to check.
* @return Promise resolved when done.
*/
async handleClick(notification: NotificationData): Promise<void> {
const contextUrlParams = CoreUrlUtils.instance.extractUrlParams(notification.contexturl);
const courseId = Number(notification.courseid);
const moduleId = Number(contextUrlParams.id);
await CoreUtils.instance.ignoreErrors(AddonModAssign.instance.invalidateContent(moduleId, courseId, notification.site));
await CoreCourseHelper.instance.navigateToModule(moduleId, notification.site, courseId);
}
}
export const AddonModAssignPushClickHandler = makeSingleton(AddonModAssignPushClickHandlerService);
type NotificationData = CorePushNotificationsNotificationBasicData & {
courseid: number;
contexturl: string;
};

View File

@ -0,0 +1,50 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { CoreCronHandler } from '@services/cron';
import { makeSingleton } from '@singletons';
import { AddonModAssignSync } from '../assign-sync';
/**
* Synchronization cron handler.
*/
@Injectable({ providedIn: 'root' })
export class AddonModAssignSyncCronHandlerService implements CoreCronHandler {
name = 'AddonModAssignSyncCronHandler';
/**
* Execute the process.
* Receives the ID of the site affected, undefined for all sites.
*
* @param siteId ID of the site affected, undefined for all sites.
* @param force Wether the execution is forced (manual sync).
* @return Promise resolved when done, rejected if failure.
*/
execute(siteId?: string, force?: boolean): Promise<void> {
return AddonModAssignSync.instance.syncAllAssignments(siteId, force);
}
/**
* Get the time between consecutive executions.
*
* @return Time between consecutive executions (in ms).
*/
getInterval(): number {
return AddonModAssignSync.instance.syncInterval;
}
}
export const AddonModAssignSyncCronHandler = makeSingleton(AddonModAssignSyncCronHandlerService);

View File

@ -0,0 +1,566 @@
// (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, Type } from '@angular/core';
import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate';
import { AddonModAssignDefaultSubmissionHandler } from './handlers/default-submission';
import { AddonModAssignAssign, AddonModAssignSubmission, AddonModAssignPlugin, AddonModAssignSavePluginData } from './assign';
import { makeSingleton } from '@singletons';
import { CoreWSExternalFile } from '@services/ws';
import { AddonModAssignSubmissionsDBRecordFormatted } from './assign-offline';
/**
* Interface that all submission handlers must implement.
*/
export interface AddonModAssignSubmissionHandler extends CoreDelegateHandler {
/**
* Name of the type of submission the handler supports. E.g. 'file'.
*/
type: string;
/**
* Whether the plugin can be edited in offline for existing submissions. In general, this should return false if the
* plugin uses Moodle filters. The reason is that the app only prefetches filtered data, and the user should edit
* unfiltered data.
*
* @param assign The assignment.
* @param submission The submission.
* @param plugin The plugin object.
* @return Boolean or promise resolved with boolean: whether it can be edited in offline.
*/
canEditOffline?(
assign: AddonModAssignAssign,
submission: AddonModAssignSubmission,
plugin: AddonModAssignPlugin,
): boolean | Promise<boolean>;
/**
* Check if a plugin has no data.
*
* @param assign The assignment.
* @param plugin The plugin object.
* @return Whether the plugin is empty.
*/
isEmpty?(
assign: AddonModAssignAssign,
plugin: AddonModAssignPlugin,
): boolean;
/**
* Should clear temporary data for a cancelled submission.
*
* @param assign The assignment.
* @param submission The submission.
* @param plugin The plugin object.
* @param inputData Data entered by the user for the submission.
*/
clearTmpData?(
assign: AddonModAssignAssign,
submission: AddonModAssignSubmission,
plugin: AddonModAssignPlugin,
inputData: Record<string, unknown>,
): void;
/**
* This function will be called when the user wants to create a new submission based on the previous one.
* It should add to pluginData the data to send to server based in the data in plugin (previous attempt).
*
* @param assign The assignment.
* @param plugin The plugin object.
* @param pluginData Object where to store the data to send.
* @param userId User ID. If not defined, site's current user.
* @param siteId Site ID. If not defined, current site.
* @return If the function is async, it should return a Promise resolved when done.
*/
copySubmissionData?(
assign: AddonModAssignAssign,
plugin: AddonModAssignPlugin,
pluginData: AddonModAssignSavePluginData,
userId?: number,
siteId?: string,
): void | Promise<void>;
/**
* Delete any stored data for the plugin and submission.
*
* @param assign The assignment.
* @param submission The submission.
* @param plugin The plugin object.
* @param offlineData Offline data stored.
* @param siteId Site ID. If not defined, current site.
* @return If the function is async, it should return a Promise resolved when done.
*/
deleteOfflineData?(
assign: AddonModAssignAssign,
submission: AddonModAssignSubmission,
plugin: AddonModAssignPlugin,
offlineData: AddonModAssignSubmissionsDBRecordFormatted,
siteId?: string,
): void | Promise<void>;
/**
* Return the Component to use to display the plugin data, either in read or in edit mode.
* It's recommended to return the class of the component, but you can also return an instance of the component.
*
* @param plugin The plugin object.
* @param edit Whether the user is editing.
* @return The component (or promise resolved with component) to use, undefined if not found.
*/
getComponent?(
plugin: AddonModAssignPlugin,
edit?: boolean,
): Type<unknown> | undefined | Promise<Type<unknown> | undefined>;
/**
* Get files used by this plugin.
* The files returned by this function will be prefetched when the user prefetches the assign.
*
* @param assign The assignment.
* @param submission The submission.
* @param plugin The plugin object.
* @param siteId Site ID. If not defined, current site.
* @return The files (or promise resolved with the files).
*/
getPluginFiles?(
assign: AddonModAssignAssign,
submission: AddonModAssignSubmission,
plugin: AddonModAssignPlugin,
siteId?: string,
): CoreWSExternalFile[] | Promise<CoreWSExternalFile[]>;
/**
* Get a readable name to use for the plugin.
*
* @param plugin The plugin object.
* @return The plugin name.
*/
getPluginName?(plugin: AddonModAssignPlugin): string;
/**
* Get the size of data (in bytes) this plugin will send to copy a previous submission.
*
* @param assign The assignment.
* @param plugin The plugin object.
* @return The size (or promise resolved with size).
*/
getSizeForCopy?(
assign: AddonModAssignAssign,
plugin: AddonModAssignPlugin,
): number | Promise<number>;
/**
* Get the size of data (in bytes) this plugin will send to add or edit a submission.
*
* @param assign The assignment.
* @param submission The submission.
* @param plugin The plugin object.
* @param inputData Data entered by the user for the submission.
* @return The size (or promise resolved with size).
*/
getSizeForEdit?(
assign: AddonModAssignAssign,
submission: AddonModAssignSubmission,
plugin: AddonModAssignPlugin,
inputData: Record<string, unknown>,
): number | Promise<number>;
/**
* Check if the submission data has changed for this plugin.
*
* @param assign The assignment.
* @param submission The submission.
* @param plugin The plugin object.
* @param inputData Data entered by the user for the submission.
* @return Boolean (or promise resolved with boolean): whether the data has changed.
*/
hasDataChanged?(
assign: AddonModAssignAssign,
submission: AddonModAssignSubmission,
plugin: AddonModAssignPlugin,
inputData: Record<string, unknown>,
): boolean | Promise<boolean>;
/**
* Whether or not the handler is enabled for edit on a site level.
*
* @return Whether or not the handler is enabled for edit on a site level.
*/
isEnabledForEdit?(): boolean | Promise<boolean>;
/**
* Prefetch any required data for the plugin.
* This should NOT prefetch files. Files to be prefetched should be returned by the getPluginFiles function.
*
* @param assign The assignment.
* @param submission The submission.
* @param plugin The plugin object.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done.
*/
prefetch?(
assign: AddonModAssignAssign,
submission: AddonModAssignSubmission,
plugin: AddonModAssignPlugin,
siteId?: string,
): Promise<void>;
/**
* Prepare and add to pluginData the data to send to the server based on the input data.
*
* @param assign The assignment.
* @param submission The submission.
* @param plugin The plugin object.
* @param inputData Data entered by the user for the submission.
* @param pluginData Object where to store the data to send.
* @param offline Whether the user is editing in offline.
* @param userId User ID. If not defined, site's current user.
* @param siteId Site ID. If not defined, current site.
* @return If the function is async, it should return a Promise resolved when done.
*/
prepareSubmissionData?(
assign: AddonModAssignAssign,
submission: AddonModAssignSubmission,
plugin: AddonModAssignPlugin,
inputData: Record<string, unknown>,
pluginData: AddonModAssignSavePluginData,
offline?: boolean,
userId?: number,
siteId?: string,
): void | Promise<void>;
/**
* Prepare and add to pluginData the data to send to the server based on the offline data stored.
* This will be used when performing a synchronization.
*
* @param assign The assignment.
* @param submission The submission.
* @param plugin The plugin object.
* @param offlineData Offline data stored.
* @param pluginData Object where to store the data to send.
* @param siteId Site ID. If not defined, current site.
* @return If the function is async, it should return a Promise resolved when done.
*/
prepareSyncData?(
assign: AddonModAssignAssign,
submission: AddonModAssignSubmission,
plugin: AddonModAssignPlugin,
offlineData: AddonModAssignSubmissionsDBRecordFormatted,
pluginData: AddonModAssignSavePluginData,
siteId?: string,
): void | Promise<void>;
}
/**
* Delegate to register plugins for assign submission.
*/
@Injectable({ providedIn: 'root' })
export class AddonModAssignSubmissionDelegateService extends CoreDelegate<AddonModAssignSubmissionHandler> {
protected handlerNameProperty = 'type';
constructor(
protected defaultHandler: AddonModAssignDefaultSubmissionHandler,
) {
super('AddonModAssignSubmissionDelegate', true);
}
/**
* Whether the plugin can be edited in offline for existing submissions.
*
* @param assign The assignment.
* @param submission The submission.
* @param plugin The plugin object.
* @return Promise resolved with boolean: whether it can be edited in offline.
*/
async canPluginEditOffline(
assign: AddonModAssignAssign,
submission: AddonModAssignSubmission,
plugin: AddonModAssignPlugin,
): Promise<boolean | undefined> {
return await this.executeFunctionOnEnabled(plugin.type, 'canEditOffline', [assign, submission, plugin]);
}
/**
* Clear some temporary data for a certain plugin because a submission was cancelled.
*
* @param assign The assignment.
* @param submission The submission.
* @param plugin The plugin object.
* @param inputData Data entered by the user for the submission.
*/
clearTmpData(
assign: AddonModAssignAssign,
submission: AddonModAssignSubmission,
plugin: AddonModAssignPlugin,
inputData: Record<string, unknown>,
): void {
return this.executeFunctionOnEnabled(plugin.type, 'clearTmpData', [assign, submission, plugin, inputData]);
}
/**
* Copy the data from last submitted attempt to the current submission for a certain plugin.
*
* @param assign The assignment.
* @param plugin The plugin object.
* @param pluginData Object where to store the data to send.
* @param userId User ID. If not defined, site's current user.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when the data has been copied.
*/
async copyPluginSubmissionData(
assign: AddonModAssignAssign,
plugin: AddonModAssignPlugin,
pluginData: AddonModAssignSavePluginData,
userId?: number,
siteId?: string,
): Promise<void | undefined> {
return await this.executeFunctionOnEnabled(
plugin.type,
'copySubmissionData',
[assign, plugin, pluginData, userId, siteId],
);
}
/**
* Delete offline data stored for a certain submission and plugin.
*
* @param assign The assignment.
* @param submission The submission.
* @param plugin The plugin object.
* @param offlineData Offline data stored.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done.
*/
async deletePluginOfflineData(
assign: AddonModAssignAssign,
submission: AddonModAssignSubmission,
plugin: AddonModAssignPlugin,
offlineData: AddonModAssignSubmissionsDBRecordFormatted,
siteId?: string,
): Promise<void> {
return await this.executeFunctionOnEnabled(
plugin.type,
'deleteOfflineData',
[assign, submission, plugin, offlineData, siteId],
);
}
/**
* Get the component to use for a certain submission plugin.
*
* @param plugin The plugin object.
* @param edit Whether the user is editing.
* @return Promise resolved with the component to use, undefined if not found.
*/
async getComponentForPlugin(plugin: AddonModAssignPlugin, edit?: boolean): Promise<Type<unknown> | undefined> {
return await this.executeFunctionOnEnabled(plugin.type, 'getComponent', [plugin, edit]);
}
/**
* Get files used by this plugin.
* The files returned by this function will be prefetched when the user prefetches the assign.
*
* @param assign The assignment.
* @param submission The submission.
* @param plugin The plugin object.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with the files.
*/
async getPluginFiles(
assign: AddonModAssignAssign,
submission: AddonModAssignSubmission,
plugin: AddonModAssignPlugin,
siteId?: string,
): Promise<CoreWSExternalFile[]> {
const files: CoreWSExternalFile[] | undefined =
await this.executeFunctionOnEnabled(plugin.type, 'getPluginFiles', [assign, submission, plugin, siteId]);
return files || [];
}
/**
* Get a readable name to use for a certain submission plugin.
*
* @param plugin Plugin to get the name for.
* @return Human readable name.
*/
getPluginName(plugin: AddonModAssignPlugin): string | undefined {
return this.executeFunctionOnEnabled(plugin.type, 'getPluginName', [plugin]);
}
/**
* Get the size of data (in bytes) this plugin will send to copy a previous submission.
*
* @param assign The assignment.
* @param plugin The plugin object.
* @return Promise resolved with size.
*/
async getPluginSizeForCopy(assign: AddonModAssignAssign, plugin: AddonModAssignPlugin): Promise<number | undefined> {
return await this.executeFunctionOnEnabled(plugin.type, 'getSizeForCopy', [assign, plugin]);
}
/**
* Get the size of data (in bytes) this plugin will send to add or edit a submission.
*
* @param assign The assignment.
* @param submission The submission.
* @param plugin The plugin object.
* @param inputData Data entered by the user for the submission.
* @return Promise resolved with size.
*/
async getPluginSizeForEdit(
assign: AddonModAssignAssign,
submission: AddonModAssignSubmission,
plugin: AddonModAssignPlugin,
inputData: Record<string, unknown>,
): Promise<number | undefined> {
return await this.executeFunctionOnEnabled(
plugin.type,
'getSizeForEdit',
[assign, submission, plugin, inputData],
);
}
/**
* Check if the submission data has changed for a certain plugin.
*
* @param assign The assignment.
* @param submission The submission.
* @param plugin The plugin object.
* @param inputData Data entered by the user for the submission.
* @return Promise resolved with true if data has changed, resolved with false otherwise.
*/
async hasPluginDataChanged(
assign: AddonModAssignAssign,
submission: AddonModAssignSubmission,
plugin: AddonModAssignPlugin,
inputData: Record<string, unknown>,
): Promise<boolean | undefined> {
return await this.executeFunctionOnEnabled(
plugin.type,
'hasDataChanged',
[assign, submission, plugin, inputData],
);
}
/**
* Check if a submission plugin is supported.
*
* @param pluginType Type of the plugin.
* @return Whether it's supported.
*/
isPluginSupported(pluginType: string): boolean {
return this.hasHandler(pluginType, true);
}
/**
* Check if a submission plugin is supported for edit.
*
* @param pluginType Type of the plugin.
* @return Whether it's supported for edit.
*/
async isPluginSupportedForEdit(pluginType: string): Promise<boolean | undefined> {
return await this.executeFunctionOnEnabled(pluginType, 'isEnabledForEdit');
}
/**
* Check if a plugin has no data.
*
* @param assign The assignment.
* @param plugin The plugin object.
* @return Whether the plugin is empty.
*/
isPluginEmpty(assign: AddonModAssignAssign, plugin: AddonModAssignPlugin): boolean | undefined {
return this.executeFunctionOnEnabled(plugin.type, 'isEmpty', [assign, plugin]);
}
/**
* Prefetch any required data for a submission plugin.
*
* @param assign The assignment.
* @param submission The submission.
* @param plugin The plugin object.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done.
*/
async prefetch(
assign: AddonModAssignAssign,
submission: AddonModAssignSubmission,
plugin: AddonModAssignPlugin,
siteId?: string,
): Promise<void> {
return await this.executeFunctionOnEnabled(plugin.type, 'prefetch', [assign, submission, plugin, siteId]);
}
/**
* Prepare and add to pluginData the data to submit for a certain submission plugin.
*
* @param assign The assignment.
* @param submission The submission.
* @param plugin The plugin object.
* @param inputData Data entered by the user for the submission.
* @param pluginData Object where to store the data to send.
* @param offline Whether the user is editing in offline.
* @param userId User ID. If not defined, site's current user.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when data has been gathered.
*/
async preparePluginSubmissionData(
assign: AddonModAssignAssign,
submission: AddonModAssignSubmission,
plugin: AddonModAssignPlugin,
inputData: Record<string, unknown>,
pluginData: AddonModAssignSavePluginData,
offline?: boolean,
userId?: number,
siteId?: string,
): Promise<void | undefined> {
return await this.executeFunctionOnEnabled(
plugin.type,
'prepareSubmissionData',
[assign, submission, plugin, inputData, pluginData, offline, userId, siteId],
);
}
/**
* Prepare and add to pluginData the data to send to server to synchronize an offline submission.
*
* @param assign The assignment.
* @param submission The submission.
* @param plugin The plugin object.
* @param offlineData Offline data stored.
* @param pluginData Object where to store the data to send.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when data has been gathered.
*/
async preparePluginSyncData(
assign: AddonModAssignAssign,
submission: AddonModAssignSubmission,
plugin: AddonModAssignPlugin,
offlineData: AddonModAssignSubmissionsDBRecordFormatted,
pluginData: AddonModAssignSavePluginData,
siteId?: string,
): Promise<void> {
return this.executeFunctionOnEnabled(
plugin.type,
'prepareSyncData',
[assign, submission, plugin, offlineData, pluginData, siteId],
);
}
}
export const AddonModAssignSubmissionDelegate = makeSingleton(AddonModAssignSubmissionDelegateService);

View File

@ -0,0 +1,47 @@
// (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 { APP_INITIALIZER, NgModule } from '@angular/core';
import { AddonModAssignSubmissionCommentsHandler } from './services/handler';
import { AddonModAssignSubmissionCommentsComponent } from './component/comments';
import { CoreSharedModule } from '@/core/shared.module';
import { AddonModAssignSubmissionDelegate } from '../../services/submission-delegate';
import { CoreCommentsComponentsModule } from '@features/comments/components/components.module';
@NgModule({
declarations: [
AddonModAssignSubmissionCommentsComponent,
],
imports: [
CoreSharedModule,
CoreCommentsComponentsModule,
],
providers: [
{
provide: APP_INITIALIZER,
multi: true,
deps: [],
useFactory: () => () => {
AddonModAssignSubmissionDelegate.instance.registerHandler(AddonModAssignSubmissionCommentsHandler.instance);
},
},
],
exports: [
AddonModAssignSubmissionCommentsComponent,
],
entryComponents: [
AddonModAssignSubmissionCommentsComponent,
],
})
export class AddonModAssignSubmissionCommentsModule {}

View File

@ -0,0 +1,8 @@
<ion-item *ngIf="commentsEnabled" class="ion-text-wrap" (click)="showComments($event)" detail="false">
<ion-label>
<h2>{{plugin.name}}</h2>
<core-comments contextLevel="module" [instanceId]="assign.cmid" component="assignsubmission_comments"
[itemId]="submission.id" area="submission_comments" [title]="plugin.name" [courseId]="assign.course">
</core-comments>
</ion-label>
</ion-item>

View File

@ -0,0 +1,61 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, ViewChild } from '@angular/core';
import { AddonModAssignSubmissionPluginComponent } from '@addons/mod/assign/components/submission-plugin/submission-plugin';
import { CoreCommentsCommentsComponent } from '@features/comments/components/comments/comments';
import { CoreComments } from '@features/comments/services/comments';
/**
* Component to render a comments submission plugin.
*/
@Component({
selector: 'addon-mod-assign-submission-comments',
templateUrl: 'addon-mod-assign-submission-comments.html',
})
export class AddonModAssignSubmissionCommentsComponent extends AddonModAssignSubmissionPluginComponent {
@ViewChild(CoreCommentsCommentsComponent) commentsComponent!: CoreCommentsCommentsComponent;
commentsEnabled: boolean;
constructor() {
super();
this.commentsEnabled = !CoreComments.instance.areCommentsDisabledInSite();
}
/**
* Invalidate the data.
*
* @return Promise resolved when done.
*/
invalidate(): Promise<void> {
return CoreComments.instance.invalidateCommentsData(
'module',
this.assign.cmid,
'assignsubmission_comments',
this.submission.id,
'submission_comments',
);
}
/**
* Show the comments.
*/
showComments(e?: Event): void {
this.commentsComponent?.openComments(e);
}
}

View File

@ -0,0 +1,3 @@
{
"pluginname": "Submission comments"
}

View File

@ -0,0 +1,107 @@
// (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 { AddonModAssignAssign, AddonModAssignSubmission, AddonModAssignPlugin } from '@addons/mod/assign/services/assign';
import { AddonModAssignSubmissionHandler } from '@addons/mod/assign/services/submission-delegate';
import { Injectable, Type } from '@angular/core';
import { CoreComments } from '@features/comments/services/comments';
import { CoreUtils } from '@services/utils/utils';
import { makeSingleton } from '@singletons';
import { AddonModAssignSubmissionCommentsComponent } from '../component/comments';
/**
* Handler for comments submission plugin.
*/
@Injectable( { providedIn: 'root' })
export class AddonModAssignSubmissionCommentsHandlerService implements AddonModAssignSubmissionHandler {
name = 'AddonModAssignSubmissionCommentsHandler';
type = 'comments';
/**
* Whether the plugin can be edited in offline for existing submissions. In general, this should return false if the
* plugin uses Moodle filters. The reason is that the app only prefetches filtered data, and the user should edit
* unfiltered data.
*
* @return Boolean or promise resolved with boolean: whether it can be edited in offline.
*/
canEditOffline(): boolean {
// This plugin is read only, but return true to prevent blocking the edition.
return true;
}
/**
* Return the Component to use to display the plugin data, either in read or in edit mode.
* It's recommended to return the class of the component, but you can also return an instance of the component.
*
* @param plugin The plugin object.
* @param edit Whether the user is editing.
* @return The component (or promise resolved with component) to use, undefined if not found.
*/
getComponent(plugin: AddonModAssignPlugin, edit = false): Type<unknown> | undefined {
return edit ? undefined : AddonModAssignSubmissionCommentsComponent;
}
/**
* Whether or not the handler is enabled on a site level.
*
* @return True or promise resolved with true if enabled.
*/
async isEnabled(): Promise<boolean> {
return true;
}
/**
* Whether or not the handler is enabled for edit on a site level.
*
* @return Whether or not the handler is enabled for edit on a site level.
*/
isEnabledForEdit(): boolean{
return true;
}
/**
* Prefetch any required data for the plugin.
* This should NOT prefetch files. Files to be prefetched should be returned by the getPluginFiles function.
*
* @param assign The assignment.
* @param submission The submission.
* @param plugin The plugin object.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done.
*/
async prefetch(
assign: AddonModAssignAssign,
submission: AddonModAssignSubmission,
plugin: AddonModAssignPlugin,
siteId?: string,
): Promise<void> {
// Fail silently (Moodle < 3.1.1, 3.2)
await CoreUtils.instance.ignoreErrors(
CoreComments.instance.getComments(
'module',
assign.cmid,
'assignsubmission_comments',
submission.id,
'submission_comments',
0,
siteId,
),
);
}
}
export const AddonModAssignSubmissionCommentsHandler = makeSingleton(AddonModAssignSubmissionCommentsHandlerService);

View File

@ -0,0 +1,19 @@
<!-- Read only. -->
<ion-item class="ion-text-wrap" *ngIf="files && files.length && !edit">
<ion-label>
<h2>{{ plugin.name }}</h2>
<div lines="none">
<core-files [files]="files" [component]="component" [componentId]="assign.cmid" [alwaysDownload]="true"></core-files>
</div>
</ion-label>
</ion-item>
<!-- Edit -->
<div *ngIf="edit">
<ion-item-divider class="ion-text-wrap" sticky="true">
<ion-label><h2>{{ plugin.name }}</h2></ion-label>
</ion-item-divider>
<core-attachments [files]="files" [maxSize]="maxSize" [maxSubmissions]="maxSubmissions"
[component]="component" [componentId]="assign.cmid" [acceptedTypes]="acceptedTypes" [allowOffline]="allowOffline">
</core-attachments>
</div>

View File

@ -0,0 +1,85 @@
// (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 { AddonModAssignSubmissionPluginComponent } from '@addons/mod/assign/components/submission-plugin/submission-plugin';
import { AddonModAssign, AddonModAssignProvider } from '@addons/mod/assign/services/assign';
import { AddonModAssignHelper } from '@addons/mod/assign/services/assign-helper';
import { AddonModAssignOffline } from '@addons/mod/assign/services/assign-offline';
import { Component, OnInit } from '@angular/core';
import { CoreFileUploaderStoreFilesResult } from '@features/fileuploader/services/fileuploader';
import { CoreFileSession } from '@services/file-session';
import { CoreUtils } from '@services/utils/utils';
import { AddonModAssignSubmissionFileHandlerService } from '../services/handler';
import { FileEntry } from '@ionic-native/file/ngx';
/**
* Component to render a file submission plugin.
*/
@Component({
selector: 'addon-mod-assign-submission-file',
templateUrl: 'addon-mod-assign-submission-file.html',
})
export class AddonModAssignSubmissionFileComponent extends AddonModAssignSubmissionPluginComponent implements OnInit {
component = AddonModAssignProvider.COMPONENT;
maxSize?: number;
acceptedTypes?: string;
maxSubmissions?: number;
/**
* Component being initialized.
*/
async ngOnInit(): Promise<void> {
// Get the offline data.
const filesData = await CoreUtils.instance.ignoreErrors(
AddonModAssignOffline.instance.getSubmission(this.assign.id),
undefined,
);
this.acceptedTypes = this.data?.configs.filetypeslist;
this.maxSize = this.data?.configs.maxsubmissionsizebytes
? parseInt(this.data?.configs.maxsubmissionsizebytes, 10)
: undefined;
this.maxSubmissions = this.data?.configs.maxfilesubmissions
? parseInt(this.data?.configs.maxfilesubmissions, 10)
: undefined;
try {
if (filesData && filesData.plugindata && filesData.plugindata.files_filemanager) {
const offlineDataFiles = <CoreFileUploaderStoreFilesResult>filesData.plugindata.files_filemanager;
// It has offline data.
let offlineFiles: FileEntry[] = [];
if (offlineDataFiles.offline) {
offlineFiles = <FileEntry[]>await CoreUtils.instance.ignoreErrors(
AddonModAssignHelper.instance.getStoredSubmissionFiles(
this.assign.id,
AddonModAssignSubmissionFileHandlerService.FOLDER_NAME,
),
[],
);
}
this.files = offlineDataFiles.online || [];
this.files = this.files.concat(offlineFiles);
} else {
// No offline data, get the online files.
this.files = AddonModAssign.instance.getSubmissionPluginAttachments(this.plugin);
}
} finally {
CoreFileSession.instance.setFiles(this.component, this.assign.id, this.files);
}
}
}

View File

@ -0,0 +1,45 @@
// (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 { APP_INITIALIZER, NgModule } from '@angular/core';
import { AddonModAssignSubmissionFileHandler } from './services/handler';
import { AddonModAssignSubmissionFileComponent } from './component/file';
import { CoreSharedModule } from '@/core/shared.module';
import { AddonModAssignSubmissionDelegate } from '../../services/submission-delegate';
@NgModule({
declarations: [
AddonModAssignSubmissionFileComponent,
],
imports: [
CoreSharedModule,
],
providers: [
{
provide: APP_INITIALIZER,
multi: true,
deps: [],
useFactory: () => () => {
AddonModAssignSubmissionDelegate.instance.registerHandler(AddonModAssignSubmissionFileHandler.instance);
},
},
],
exports: [
AddonModAssignSubmissionFileComponent,
],
entryComponents: [
AddonModAssignSubmissionFileComponent,
],
})
export class AddonModAssignSubmissionFileModule {}

View File

@ -0,0 +1,3 @@
{
"pluginname": "File submissions"
}

View File

@ -0,0 +1,388 @@
// (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 {
AddonModAssignAssign,
AddonModAssignSubmission,
AddonModAssignPlugin,
AddonModAssignProvider,
AddonModAssign,
} from '@addons/mod/assign/services/assign';
import { AddonModAssignHelper } from '@addons/mod/assign/services/assign-helper';
import { AddonModAssignOffline, AddonModAssignSubmissionsDBRecordFormatted } from '@addons/mod/assign/services/assign-offline';
import { AddonModAssignSubmissionHandler } from '@addons/mod/assign/services/submission-delegate';
import { Injectable, Type } from '@angular/core';
import { CoreFileUploader, CoreFileUploaderStoreFilesResult } from '@features/fileuploader/services/fileuploader';
import { CoreFileHelper } from '@services/file-helper';
import { CoreFileSession } from '@services/file-session';
import { CoreUtils } from '@services/utils/utils';
import { CoreWSExternalFile } from '@services/ws';
import { makeSingleton } from '@singletons';
import { AddonModAssignSubmissionFileComponent } from '../component/file';
import { FileEntry } from '@ionic-native/file/ngx';
/**
* Handler for file submission plugin.
*/
@Injectable( { providedIn: 'root' })
export class AddonModAssignSubmissionFileHandlerService implements AddonModAssignSubmissionHandler {
static readonly FOLDER_NAME = 'submission_file';
name = 'AddonModAssignSubmissionFileHandler';
type = 'file';
/**
* Whether the plugin can be edited in offline for existing submissions. In general, this should return false if the
* plugin uses Moodle filters. The reason is that the app only prefetches filtered data, and the user should edit
* unfiltered data.
*
* @return Boolean or promise resolved with boolean: whether it can be edited in offline.
*/
canEditOffline(): boolean {
// This plugin doesn't use Moodle filters, it can be edited in offline.
return true;
}
/**
* Check if a plugin has no data.
*
* @param assign The assignment.
* @param plugin The plugin object.
* @return Whether the plugin is empty.
*/
isEmpty(assign: AddonModAssignAssign, plugin: AddonModAssignPlugin): boolean {
const files = AddonModAssign.instance.getSubmissionPluginAttachments(plugin);
return files.length === 0;
}
/**
* Should clear temporary data for a cancelled submission.
*
* @param assign The assignment.
*/
clearTmpData(assign: AddonModAssignAssign): void {
const files = CoreFileSession.instance.getFiles(AddonModAssignProvider.COMPONENT, assign.id);
// Clear the files in session for this assign.
CoreFileSession.instance.clearFiles(AddonModAssignProvider.COMPONENT, assign.id);
// Now delete the local files from the tmp folder.
CoreFileUploader.instance.clearTmpFiles(files);
}
/**
* This function will be called when the user wants to create a new submission based on the previous one.
* It should add to pluginData the data to send to server based in the data in plugin (previous attempt).
*
* @param assign The assignment.
* @param plugin The plugin object.
* @param pluginData Object where to store the data to send.
* @return If the function is async, it should return a Promise resolved when done.
*/
async copySubmissionData(
assign: AddonModAssignAssign,
plugin: AddonModAssignPlugin,
pluginData: AddonModAssignSubmissionFilePluginData,
): Promise<void> {
// We need to re-upload all the existing files.
const files = AddonModAssign.instance.getSubmissionPluginAttachments(plugin);
// Get the itemId.
pluginData.files_filemanager = await AddonModAssignHelper.instance.uploadFiles(assign.id, files);
}
/**
* Return the Component to use to display the plugin data, either in read or in edit mode.
* It's recommended to return the class of the component, but you can also return an instance of the component.
*
* @return The component (or promise resolved with component) to use, undefined if not found.
*/
getComponent(): Type<unknown> {
return AddonModAssignSubmissionFileComponent;
}
/**
* Delete any stored data for the plugin and submission.
*
* @param assign The assignment.
* @param submission The submission.
* @param plugin The plugin object.
* @param offlineData Offline data stored.
* @param siteId Site ID. If not defined, current site.
* @return If the function is async, it should return a Promise resolved when done.
*/
async deleteOfflineData(
assign: AddonModAssignAssign,
submission: AddonModAssignSubmission,
plugin: AddonModAssignPlugin,
offlineData: AddonModAssignSubmissionsDBRecordFormatted,
siteId?: string,
): Promise<void> {
await CoreUtils.instance.ignoreErrors(
AddonModAssignHelper.instance.deleteStoredSubmissionFiles(
assign.id,
AddonModAssignSubmissionFileHandlerService.FOLDER_NAME,
submission.userid,
siteId,
),
);
}
/**
* Get files used by this plugin.
* The files returned by this function will be prefetched when the user prefetches the assign.
*
* @param assign The assignment.
* @param submission The submission.
* @param plugin The plugin object.
* @param siteId Site ID. If not defined, current site.
* @return The files (or promise resolved with the files).
*/
getPluginFiles(
assign: AddonModAssignAssign,
submission: AddonModAssignSubmission,
plugin: AddonModAssignPlugin,
): CoreWSExternalFile[] {
return AddonModAssign.instance.getSubmissionPluginAttachments(plugin);
}
/**
* Get the size of data (in bytes) this plugin will send to copy a previous submission.
*
* @param assign The assignment.
* @param plugin The plugin object.
* @return The size (or promise resolved with size).
*/
async getSizeForCopy(assign: AddonModAssignAssign, plugin: AddonModAssignPlugin): Promise<number> {
const files = AddonModAssign.instance.getSubmissionPluginAttachments(plugin);
return CoreFileHelper.instance.getTotalFilesSize(files);
}
/**
* Get the size of data (in bytes) this plugin will send to add or edit a submission.
*
* @param assign The assignment.
* @param submission The submission.
* @param plugin The plugin object.
* @return The size (or promise resolved with size).
*/
async getSizeForEdit(
assign: AddonModAssignAssign,
submission: AddonModAssignSubmission,
plugin: AddonModAssignPlugin,
): Promise<number> {
// Check if there's any change.
if (this.hasDataChanged(assign, submission, plugin)) {
const files = CoreFileSession.instance.getFiles(AddonModAssignProvider.COMPONENT, assign.id);
return CoreFileHelper.instance.getTotalFilesSize(files);
} else {
// Nothing has changed, we won't upload any file.
return 0;
}
}
/**
* Check if the submission data has changed for this plugin.
*
* @param assign The assignment.
* @param submission The submission.
* @param plugin The plugin object.
* @return Boolean (or promise resolved with boolean): whether the data has changed.
*/
async hasDataChanged(
assign: AddonModAssignAssign,
submission: AddonModAssignSubmission,
plugin: AddonModAssignPlugin,
): Promise<boolean> {
const offlineData = await CoreUtils.instance.ignoreErrors(
// Check if there's any offline data.
AddonModAssignOffline.instance.getSubmission(assign.id, submission.userid),
undefined,
);
let numFiles: number;
if (offlineData && offlineData.plugindata && offlineData.plugindata.files_filemanager) {
const offlineDataFiles = <CoreFileUploaderStoreFilesResult>offlineData.plugindata.files_filemanager;
// Has offline data, return the number of files.
numFiles = offlineDataFiles.offline + offlineDataFiles.online.length;
} else {
// No offline data, return the number of online files.
const pluginFiles = AddonModAssign.instance.getSubmissionPluginAttachments(plugin);
numFiles = pluginFiles && pluginFiles.length;
}
const currentFiles = CoreFileSession.instance.getFiles(AddonModAssignProvider.COMPONENT, assign.id);
if (currentFiles.length != numFiles) {
// Number of files has changed.
return true;
}
const files = await this.getSubmissionFilesToSync(assign, submission, offlineData);
// Check if there is any local file added and list has changed.
return CoreFileUploader.instance.areFileListDifferent(currentFiles, files);
}
/**
* Whether or not the handler is enabled on a site level.
*
* @return True or promise resolved with true if enabled.
*/
async isEnabled(): Promise<boolean> {
return true;
}
/**
* Whether or not the handler is enabled for edit on a site level.
*
* @return Whether or not the handler is enabled for edit on a site level.
*/
isEnabledForEdit(): boolean {
return true;
}
/**
* Prepare and add to pluginData the data to send to the server based on the input data.
*
* @param assign The assignment.
* @param submission The submission.
* @param plugin The plugin object.
* @param inputData Data entered by the user for the submission.
* @param pluginData Object where to store the data to send.
* @param offline Whether the user is editing in offline.
* @param userId User ID. If not defined, site's current user.
* @param siteId Site ID. If not defined, current site.
* @return If the function is async, it should return a Promise resolved when done.
*/
async prepareSubmissionData(
assign: AddonModAssignAssign,
submission: AddonModAssignSubmission,
plugin: AddonModAssignPlugin,
inputData: AddonModAssignSubmissionFileData,
pluginData: AddonModAssignSubmissionFilePluginData,
offline?: boolean,
userId?: number,
siteId?: string,
): Promise<void> {
const changed = await this.hasDataChanged(assign, submission, plugin);
if (!changed) {
return;
}
// Data has changed, we need to upload new files and re-upload all the existing files.
const currentFiles = CoreFileSession.instance.getFiles(AddonModAssignProvider.COMPONENT, assign.id);
const error = CoreUtils.instance.hasRepeatedFilenames(currentFiles);
if (error) {
throw error;
}
pluginData.files_filemanager = await AddonModAssignHelper.instance.uploadOrStoreFiles(
assign.id,
AddonModAssignSubmissionFileHandlerService.FOLDER_NAME,
currentFiles,
offline,
userId,
siteId,
);
}
/**
* Prepare and add to pluginData the data to send to the server based on the offline data stored.
* This will be used when performing a synchronization.
*
* @param assign The assignment.
* @param submission The submission.
* @param plugin The plugin object.
* @param offlineData Offline data stored.
* @param pluginData Object where to store the data to send.
* @param siteId Site ID. If not defined, current site.
* @return If the function is async, it should return a Promise resolved when done.
*/
async prepareSyncData(
assign: AddonModAssignAssign,
submission: AddonModAssignSubmission,
plugin: AddonModAssignPlugin,
offlineData: AddonModAssignSubmissionsDBRecordFormatted,
pluginData: AddonModAssignSubmissionFilePluginData,
siteId?: string,
): Promise<void> {
const files = await this.getSubmissionFilesToSync(assign, submission, offlineData, siteId);
if (files.length == 0) {
return;
}
pluginData.files_filemanager = await AddonModAssignHelper.instance.uploadFiles(assign.id, files, siteId);
}
/**
* Get the file list to be synced.
*
* @param assign The assignment.
* @param submission The submission.
* @param offlineData Offline data stored.
* @param siteId Site ID. If not defined, current site.
* @return File entries when is all resolved.
*/
protected async getSubmissionFilesToSync(
assign: AddonModAssignAssign,
submission: AddonModAssignSubmission,
offlineData?: AddonModAssignSubmissionsDBRecordFormatted,
siteId?: string,
): Promise<(FileEntry | CoreWSExternalFile)[]> {
const filesData = <CoreFileUploaderStoreFilesResult>offlineData?.plugindata.files_filemanager;
if (!filesData) {
return [];
}
// Has some data to sync.
let files: (FileEntry | CoreWSExternalFile)[] = filesData.online || [];
if (filesData.offline) {
// Has offline files, get them and add them to the list.
const storedFiles = <FileEntry[]> await CoreUtils.instance.ignoreErrors(
AddonModAssignHelper.instance.getStoredSubmissionFiles(
assign.id,
AddonModAssignSubmissionFileHandlerService.FOLDER_NAME,
submission.userid,
siteId,
),
[],
);
files = files.concat(storedFiles);
}
return files;
}
}
export const AddonModAssignSubmissionFileHandler = makeSingleton(AddonModAssignSubmissionFileHandlerService);
// Define if ever used.
export type AddonModAssignSubmissionFileData = Record<string, unknown>;
export type AddonModAssignSubmissionFilePluginData = {
// The id of a draft area containing files for this submission. Or the offline file results.
files_filemanager: number | CoreFileUploaderStoreFilesResult; // eslint-disable-line @typescript-eslint/naming-convention
};

View File

@ -0,0 +1,34 @@
<!-- Read only -->
<ion-item class="ion-text-wrap" *ngIf="!edit && text">
<ion-label>
<h2>{{ plugin.name }}</h2>
<p *ngIf="words">{{ 'addon.mod_assign.numwords' | translate: {'$a': words} }}</p>
<p>
<core-format-text [component]="component" [componentId]="assign.cmid" [maxHeight]="80" [fullOnClick]="true"
[fullTitle]="plugin.name" [text]="text" contextLevel="module" [contextInstanceId]="assign.cmid"
[courseId]="assign.course">
</core-format-text>
</p>
</ion-label>
</ion-item>
<!-- Edit -->
<div *ngIf="edit && loaded">
<ion-item-divider class="ion-text-wrap" sticky="true">
<ion-label><h2>{{ plugin.name }}</h2></ion-label>
</ion-item-divider>
<ion-item class="ion-text-wrap" *ngIf="wordLimitEnabled && words >= 0">
<ion-label>
<h2>{{ 'addon.mod_assign.wordlimit' | translate }}</h2>
<p>{{ 'core.numwords' | translate: {'$a': words + ' / ' + wordLimit} }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label></ion-label>
<core-rich-text-editor [control]="control" [placeholder]="plugin.name"
name="onlinetext_editor_text" (contentChanged)="onChange($event)" [component]="component"
[componentId]="assign.cmid" [autoSave]="true" contextLevel="module" [contextInstanceId]="assign.cmid"
elementId="onlinetext_editor" [draftExtraParams]="{userid: currentUserId, action: 'editsubmission'}">
</core-rich-text-editor>
</ion-item>
</div>

View File

@ -0,0 +1,130 @@
// (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 { AddonModAssignSubmissionPluginComponent } from '@addons/mod/assign/components/submission-plugin/submission-plugin';
import { AddonModAssignProvider, AddonModAssign } from '@addons/mod/assign/services/assign';
import { AddonModAssignOffline } from '@addons/mod/assign/services/assign-offline';
import { Component, OnInit, ElementRef } from '@angular/core';
import { FormBuilder, FormControl } from '@angular/forms';
import { CoreSites } from '@services/sites';
import { CoreTextUtils } from '@services/utils/text';
import { CoreUtils } from '@services/utils/utils';
import { AddonModAssignSubmissionOnlineTextPluginData } from '../services/handler';
/**
* Component to render an onlinetext submission plugin.
*/
@Component({
selector: 'addon-mod-assign-submission-online-text',
templateUrl: 'addon-mod-assign-submission-onlinetext.html',
})
export class AddonModAssignSubmissionOnlineTextComponent extends AddonModAssignSubmissionPluginComponent implements OnInit {
control?: FormControl;
words = 0;
component = AddonModAssignProvider.COMPONENT;
text = '';
loaded = false;
wordLimitEnabled = false;
currentUserId: number;
wordLimit = 0;
protected wordCountTimeout?: number;
protected element: HTMLElement;
constructor(
protected fb: FormBuilder,
element: ElementRef,
) {
super();
this.element = element.nativeElement;
this.currentUserId = CoreSites.instance.getCurrentSiteUserId();
}
/**
* Component being initialized.
*/
async ngOnInit(): Promise<void> {
// Get the text. Check if we have anything offline.
const offlineData = await CoreUtils.instance.ignoreErrors(
AddonModAssignOffline.instance.getSubmission(this.assign.id),
undefined,
);
this.wordLimitEnabled = !!parseInt(this.data?.configs.wordlimitenabled || '0', 10);
this.wordLimit = parseInt(this.data?.configs.wordlimit || '0');
try {
if (offlineData && offlineData.plugindata && offlineData.plugindata.onlinetext_editor) {
this.text = (<AddonModAssignSubmissionOnlineTextPluginData>offlineData.plugindata).onlinetext_editor.text;
} else {
// No offline data found, return online text.
this.text = AddonModAssign.instance.getSubmissionPluginText(this.plugin);
}
// Set the text.
if (!this.edit) {
// Not editing, see full text when clicked.
this.element.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
if (this.text) {
// Open a new state with the interpolated contents.
CoreTextUtils.instance.viewText(this.plugin.name, this.text, {
component: this.component,
componentId: this.assign.cmid,
filter: true,
contextLevel: 'module',
instanceId: this.assign.cmid,
courseId: this.assign.course,
});
}
});
} else {
// Create and add the control.
this.control = this.fb.control(this.text);
}
// Calculate initial words.
if (this.wordLimitEnabled) {
this.words = CoreTextUtils.instance.countWords(this.text);
}
} finally {
this.loaded = true;
}
}
/**
* Text changed.
*
* @param text The new text.
*/
onChange(text: string): void {
// Count words if needed.
if (this.wordLimitEnabled) {
// Cancel previous wait.
clearTimeout(this.wordCountTimeout);
// Wait before calculating, if the user keeps inputing we won't calculate.
// This is to prevent slowing down devices, this calculation can be slow if the text is long.
this.wordCountTimeout = window.setTimeout(() => {
this.words = CoreTextUtils.instance.countWords(text);
}, 1500);
}
}
}

View File

@ -0,0 +1,4 @@
{
"pluginname": "Online text submissions",
"wordlimitexceeded": "The word limit for this assignment is {{$a.limit}} words and you are attempting to submit {{$a.count}} words. Please review your submission and try again."
}

View File

@ -0,0 +1,47 @@
// (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 { APP_INITIALIZER, NgModule } from '@angular/core';
import { AddonModAssignSubmissionOnlineTextHandler } from './services/handler';
import { AddonModAssignSubmissionOnlineTextComponent } from './component/onlinetext';
import { CoreEditorComponentsModule } from '@features/editor/components/components.module';
import { CoreSharedModule } from '@/core/shared.module';
import { AddonModAssignSubmissionDelegate } from '../../services/submission-delegate';
@NgModule({
declarations: [
AddonModAssignSubmissionOnlineTextComponent,
],
imports: [
CoreSharedModule,
CoreEditorComponentsModule,
],
providers: [
{
provide: APP_INITIALIZER,
multi: true,
deps: [],
useFactory: () => () => {
AddonModAssignSubmissionDelegate.instance.registerHandler(AddonModAssignSubmissionOnlineTextHandler.instance);
},
},
],
exports: [
AddonModAssignSubmissionOnlineTextComponent,
],
entryComponents: [
AddonModAssignSubmissionOnlineTextComponent,
],
})
export class AddonModAssignSubmissionOnlineTextModule {}

View File

@ -0,0 +1,323 @@
// (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 {
AddonModAssignAssign,
AddonModAssignSubmission,
AddonModAssignPlugin,
AddonModAssign,
} from '@addons/mod/assign/services/assign';
import { AddonModAssignHelper } from '@addons/mod/assign/services/assign-helper';
import { AddonModAssignOffline, AddonModAssignSubmissionsDBRecordFormatted } from '@addons/mod/assign/services/assign-offline';
import { AddonModAssignSubmissionHandler } from '@addons/mod/assign/services/submission-delegate';
import { Injectable, Type } from '@angular/core';
import { CoreError } from '@classes/errors/error';
import { CoreFileHelper } from '@services/file-helper';
import { CoreSites } from '@services/sites';
import { CoreTextUtils } from '@services/utils/text';
import { CoreUtils } from '@services/utils/utils';
import { CoreWSExternalFile } from '@services/ws';
import { makeSingleton, Translate } from '@singletons';
import { AddonModAssignSubmissionOnlineTextComponent } from '../component/onlinetext';
/**
* Handler for online text submission plugin.
*/
@Injectable( { providedIn: 'root' })
export class AddonModAssignSubmissionOnlineTextHandlerService implements AddonModAssignSubmissionHandler {
name = 'AddonModAssignSubmissionOnlineTextHandler';
type = 'onlinetext';
/**
* Whether the plugin can be edited in offline for existing submissions. In general, this should return false if the
* plugin uses Moodle filters. The reason is that the app only prefetches filtered data, and the user should edit
* unfiltered data.
*
* @return Boolean or promise resolved with boolean: whether it can be edited in offline.
*/
canEditOffline(): boolean {
// This plugin uses Moodle filters, it cannot be edited in offline.
return false;
}
/**
* Check if a plugin has no data.
*
* @param assign The assignment.
* @param plugin The plugin object.
* @return Whether the plugin is empty.
*/
isEmpty(assign: AddonModAssignAssign, plugin: AddonModAssignPlugin): boolean {
const text = AddonModAssign.instance.getSubmissionPluginText(plugin, true);
// If the text is empty, we can ignore files because they won't be visible anyways.
return text.trim().length === 0;
}
/**
* This function will be called when the user wants to create a new submission based on the previous one.
* It should add to pluginData the data to send to server based in the data in plugin (previous attempt).
*
* @param assign The assignment.
* @param plugin The plugin object.
* @param pluginData Object where to store the data to send.
* @param userId User ID. If not defined, site's current user.
* @param siteId Site ID. If not defined, current site.
* @return If the function is async, it should return a Promise resolved when done.
*/
async copySubmissionData(
assign: AddonModAssignAssign,
plugin: AddonModAssignPlugin,
pluginData: AddonModAssignSubmissionOnlineTextPluginData,
userId?: number,
siteId?: string,
): Promise<void> {
const text = AddonModAssign.instance.getSubmissionPluginText(plugin, true);
const files = AddonModAssign.instance.getSubmissionPluginAttachments(plugin);
let itemId = 0;
if (files.length) {
// Re-upload the files.
itemId = await AddonModAssignHelper.instance.uploadFiles(assign.id, files, siteId);
}
pluginData.onlinetext_editor = {
text: text,
format: 1,
itemid: itemId,
};
}
/**
* Return the Component to use to display the plugin data, either in read or in edit mode.
* It's recommended to return the class of the component, but you can also return an instance of the component.
*
* @return The component (or promise resolved with component) to use, undefined if not found.
*/
getComponent(): Type<unknown> {
return AddonModAssignSubmissionOnlineTextComponent;
}
/**
* Get files used by this plugin.
* The files returned by this function will be prefetched when the user prefetches the assign.
*
* @param assign The assignment.
* @param submission The submission.
* @param plugin The plugin object.
* @return The files (or promise resolved with the files).
*/
getPluginFiles(
assign: AddonModAssignAssign,
submission: AddonModAssignSubmission,
plugin: AddonModAssignPlugin,
): CoreWSExternalFile[] {
return AddonModAssign.instance.getSubmissionPluginAttachments(plugin);
}
/**
* Get the size of data (in bytes) this plugin will send to copy a previous submission.
*
* @param assign The assignment.
* @param plugin The plugin object.
* @return The size (or promise resolved with size).
*/
async getSizeForCopy(assign: AddonModAssignAssign, plugin: AddonModAssignPlugin): Promise<number> {
const text = AddonModAssign.instance.getSubmissionPluginText(plugin, true);
const files = AddonModAssign.instance.getSubmissionPluginAttachments(plugin);
const filesSize = await CoreFileHelper.instance.getTotalFilesSize(files);
return text.length + filesSize;
}
/**
* Get the size of data (in bytes) this plugin will send to add or edit a submission.
*
* @param assign The assignment.
* @param submission The submission.
* @param plugin The plugin object.
* @param inputData Data entered by the user for the submission.
* @return The size (or promise resolved with size).
*/
getSizeForEdit(
assign: AddonModAssignAssign,
submission: AddonModAssignSubmission,
plugin: AddonModAssignPlugin,
): number {
const text = AddonModAssign.instance.getSubmissionPluginText(plugin, true);
return text.length;
}
/**
* Get the text to submit.
*
* @param plugin The plugin object.
* @param inputData Data entered by the user for the submission.
* @return Text to submit.
*/
protected getTextToSubmit(plugin: AddonModAssignPlugin, inputData: AddonModAssignSubmissionOnlineTextData): string {
const text = inputData.onlinetext_editor_text;
const files = plugin.fileareas && plugin.fileareas[0] && plugin.fileareas[0].files || [];
return CoreTextUtils.instance.restorePluginfileUrls(text, files || []);
}
/**
* Check if the submission data has changed for this plugin.
*
* @param assign The assignment.
* @param submission The submission.
* @param plugin The plugin object.
* @param inputData Data entered by the user for the submission.
* @return Boolean (or promise resolved with boolean): whether the data has changed.
*/
async hasDataChanged(
assign: AddonModAssignAssign,
submission: AddonModAssignSubmission,
plugin: AddonModAssignPlugin,
inputData: AddonModAssignSubmissionOnlineTextData,
): Promise<boolean> {
// Get the original text from plugin or offline.
const offlineData =
await CoreUtils.instance.ignoreErrors(AddonModAssignOffline.instance.getSubmission(assign.id, submission.userid));
let initialText = '';
if (offlineData && offlineData.plugindata && offlineData.plugindata.onlinetext_editor) {
initialText = (<AddonModAssignSubmissionOnlineTextPluginData>offlineData.plugindata).onlinetext_editor.text;
} else {
// No offline data found, get text from plugin.
initialText = plugin.editorfields && plugin.editorfields[0] ? plugin.editorfields[0].text : '';
}
// Check if text has changed.
return initialText != this.getTextToSubmit(plugin, inputData);
}
/**
* Whether or not the handler is enabled on a site level.
*
* @return True or promise resolved with true if enabled.
*/
async isEnabled(): Promise<boolean> {
return true;
}
/**
* Whether or not the handler is enabled for edit on a site level.
*
* @return Whether or not the handler is enabled for edit on a site level.
*/
isEnabledForEdit(): boolean {
// There's a bug in Moodle 3.1.0 that doesn't allow submitting HTML, so we'll disable this plugin in that case.
// Bug was fixed in 3.1.1 minor release and in 3.2.
const currentSite = CoreSites.instance.getCurrentSite();
return !!currentSite?.isVersionGreaterEqualThan('3.1.1') || !!currentSite?.checkIfAppUsesLocalMobile();
}
/**
* Prepare and add to pluginData the data to send to the server based on the input data.
*
* @param assign The assignment.
* @param submission The submission.
* @param plugin The plugin object.
* @param inputData Data entered by the user for the submission.
* @param pluginData Object where to store the data to send.
* @param offline Whether the user is editing in offline.
* @param userId User ID. If not defined, site's current user.
* @param siteId Site ID. If not defined, current site.
* @return If the function is async, it should return a Promise resolved when done.
*/
prepareSubmissionData(
assign: AddonModAssignAssign,
submission: AddonModAssignSubmission,
plugin: AddonModAssignPlugin,
inputData: AddonModAssignSubmissionOnlineTextData,
pluginData: AddonModAssignSubmissionOnlineTextPluginData,
): void | Promise<void> {
let text = this.getTextToSubmit(plugin, inputData);
// Check word limit.
const configs = AddonModAssignHelper.instance.getPluginConfig(assign, 'assignsubmission', plugin.type);
if (parseInt(configs.wordlimitenabled, 10)) {
const words = CoreTextUtils.instance.countWords(text);
const wordlimit = parseInt(configs.wordlimit, 10);
if (words > wordlimit) {
const params = { $a: { count: words, limit: wordlimit } };
const message = Translate.instance.instant('addon.mod_assign_submission_onlinetext.wordlimitexceeded', params);
throw new CoreError(message);
}
}
// Add some HTML to the text if needed.
text = CoreTextUtils.instance.formatHtmlLines(text);
pluginData.onlinetext_editor = {
text: text,
format: 1,
itemid: 0, // Can't add new files yet, so we use a fake itemid.
};
}
/**
* Prepare and add to pluginData the data to send to the server based on the offline data stored.
* This will be used when performing a synchronization.
*
* @param assign The assignment.
* @param submission The submission.
* @param plugin The plugin object.
* @param offlineData Offline data stored.
* @param pluginData Object where to store the data to send.
* @param siteId Site ID. If not defined, current site.
* @return If the function is async, it should return a Promise resolved when done.
*/
prepareSyncData(
assign: AddonModAssignAssign,
submission: AddonModAssignSubmission,
plugin: AddonModAssignPlugin,
offlineData: AddonModAssignSubmissionsDBRecordFormatted,
pluginData: AddonModAssignSubmissionOnlineTextPluginData,
): void | Promise<void> {
const offlinePluginData = <AddonModAssignSubmissionOnlineTextPluginData>(offlineData && offlineData.plugindata);
const textData = offlinePluginData.onlinetext_editor;
if (textData) {
// Has some data to sync.
pluginData.onlinetext_editor = textData;
}
}
}
export const AddonModAssignSubmissionOnlineTextHandler = makeSingleton(AddonModAssignSubmissionOnlineTextHandlerService);
export type AddonModAssignSubmissionOnlineTextData = {
// The text for this submission.
onlinetext_editor_text: string; // eslint-disable-line @typescript-eslint/naming-convention
};
export type AddonModAssignSubmissionOnlineTextPluginData = {
// Editor structure.
onlinetext_editor: { // eslint-disable-line @typescript-eslint/naming-convention
text: string; // The text for this submission.
format: number; // The format for this submission.
itemid: number; // The draft area id for files attached to the submission.
};
};

View File

@ -0,0 +1,27 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule } from '@angular/core';
import { AddonModAssignSubmissionCommentsModule } from './comments/comments.module';
import { AddonModAssignSubmissionFileModule } from './file/file.module';
import { AddonModAssignSubmissionOnlineTextModule } from './onlinetext/onlinetext.module';
@NgModule({
imports: [
AddonModAssignSubmissionCommentsModule,
AddonModAssignSubmissionFileModule,
AddonModAssignSubmissionOnlineTextModule,
],
})
export class AddonModAssignSubmissionModule { }

View File

@ -17,7 +17,7 @@ import { RouterModule, Routes } from '@angular/router';
const routes: Routes = [ const routes: Routes = [
{ {
path: ':courseId/:cmdId', path: ':courseId/:cmId',
loadChildren: () => import('./pages/index/index.module').then( m => m.AddonModBookIndexPageModule), loadChildren: () => import('./pages/index/index.module').then( m => m.AddonModBookIndexPageModule),
}, },
]; ];

View File

@ -8,7 +8,7 @@
(action)="expandDescription()" iconAction="fas-arrow-right"> (action)="expandDescription()" iconAction="fas-arrow-right">
</core-context-menu-item> </core-context-menu-item>
<core-context-menu-item *ngIf="blog" [priority]="750" content="{{'addon.blog.blog' | translate}}" <core-context-menu-item *ngIf="blog" [priority]="750" content="{{'addon.blog.blog' | translate}}"
[iconAction]="'far-newspaper'" (action)="gotoBlog()"> iconAction="far-newspaper" (action)="gotoBlog()">
</core-context-menu-item> </core-context-menu-item>
<core-context-menu-item *ngIf="loaded && !hasOffline && isOnline" [priority]="700" [content]="'core.refresh' | translate" <core-context-menu-item *ngIf="loaded && !hasOffline && isOnline" [priority]="700" [content]="'core.refresh' | translate"
(action)="doRefresh(null, $event)" [iconAction]="refreshIcon" [closeOnClick]="false"> (action)="doRefresh(null, $event)" [iconAction]="refreshIcon" [closeOnClick]="false">

View File

@ -17,7 +17,7 @@ import { RouterModule, Routes } from '@angular/router';
const routes: Routes = [ const routes: Routes = [
{ {
path: ':courseId/:cmdId', path: ':courseId/:cmId',
loadChildren: () => import('./pages/index/index.module').then( m => m.AddonModLessonIndexPageModule), loadChildren: () => import('./pages/index/index.module').then( m => m.AddonModLessonIndexPageModule),
}, },
{ {

View File

@ -257,7 +257,7 @@ export class AddonModLessonPrefetchHandlerService extends CoreCourseActivityPref
*/ */
protected async prefetchLesson(module: CoreCourseAnyModuleData, courseId?: number, single?: boolean): Promise<void> { protected async prefetchLesson(module: CoreCourseAnyModuleData, courseId?: number, single?: boolean): Promise<void> {
const siteId = CoreSites.instance.getCurrentSiteId(); const siteId = CoreSites.instance.getCurrentSiteId();
courseId = courseId || module.course || 1; courseId = courseId || module.course || CoreSites.instance.getCurrentSiteHomeId();
const commonOptions = { const commonOptions = {
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,

View File

@ -14,7 +14,7 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { CoreError } from '@classes/errors/error'; import { CoreSyncBlockedError } from '@classes/base-sync';
import { CoreNetworkError } from '@classes/errors/network-error'; import { CoreNetworkError } from '@classes/errors/network-error';
import { CoreCourseActivitySyncBaseProvider } from '@features/course/classes/activity-sync'; import { CoreCourseActivitySyncBaseProvider } from '@features/course/classes/activity-sync';
import { CoreCourse } from '@features/course/services/course'; import { CoreCourse } from '@features/course/services/course';
@ -122,7 +122,7 @@ export class AddonModLessonSyncProvider extends CoreCourseActivitySyncBaseProvid
* @param force Wether to force sync not depending on last execution. * @param force Wether to force sync not depending on last execution.
* @return Promise resolved if sync is successful, rejected if sync fails. * @return Promise resolved if sync is successful, rejected if sync fails.
*/ */
syncAllLessons(siteId?: string, force?: boolean): Promise<void> { syncAllLessons(siteId?: string, force = false): Promise<void> {
return this.syncOnSites('all lessons', this.syncAllLessonsFunc.bind(this, !!force), siteId); return this.syncOnSites('all lessons', this.syncAllLessonsFunc.bind(this, !!force), siteId);
} }
@ -163,7 +163,7 @@ export class AddonModLessonSyncProvider extends CoreCourseActivitySyncBaseProvid
*/ */
async syncLessonIfNeeded( async syncLessonIfNeeded(
lessonId: number, lessonId: number,
askPassword?: boolean, askPassword = false,
siteId?: string, siteId?: string,
): Promise<AddonModLessonSyncResult | undefined> { ): Promise<AddonModLessonSyncResult | undefined> {
const needed = await this.isSyncNeeded(lessonId, siteId); const needed = await this.isSyncNeeded(lessonId, siteId);
@ -184,8 +184,8 @@ export class AddonModLessonSyncProvider extends CoreCourseActivitySyncBaseProvid
*/ */
async syncLesson( async syncLesson(
lessonId: number, lessonId: number,
askPassword?: boolean, askPassword = false,
ignoreBlock?: boolean, ignoreBlock = false,
siteId?: string, siteId?: string,
): Promise<AddonModLessonSyncResult> { ): Promise<AddonModLessonSyncResult> {
siteId = siteId || CoreSites.instance.getCurrentSiteId(); siteId = siteId || CoreSites.instance.getCurrentSiteId();
@ -201,7 +201,7 @@ export class AddonModLessonSyncProvider extends CoreCourseActivitySyncBaseProvid
if (!ignoreBlock && CoreSync.instance.isBlocked(AddonModLessonProvider.COMPONENT, lessonId, siteId)) { if (!ignoreBlock && CoreSync.instance.isBlocked(AddonModLessonProvider.COMPONENT, lessonId, siteId)) {
this.logger.debug('Cannot sync lesson ' + lessonId + ' because it is blocked.'); this.logger.debug('Cannot sync lesson ' + lessonId + ' because it is blocked.');
throw new CoreError(Translate.instance.instant('core.errorsyncblocked', { $a: this.componentTranslate })); throw new CoreSyncBlockedError(Translate.instance.instant('core.errorsyncblocked', { $a: this.componentTranslate }));
} }
this.logger.debug('Try to sync lesson ' + lessonId + ' in site ' + siteId); this.logger.debug('Try to sync lesson ' + lessonId + ' in site ' + siteId);
@ -222,8 +222,8 @@ export class AddonModLessonSyncProvider extends CoreCourseActivitySyncBaseProvid
*/ */
protected async performSyncLesson( protected async performSyncLesson(
lessonId: number, lessonId: number,
askPassword?: boolean, askPassword = false,
ignoreBlock?: boolean, ignoreBlock = false,
siteId?: string, siteId?: string,
): Promise<AddonModLessonSyncResult> { ): Promise<AddonModLessonSyncResult> {
// Sync offline logs. // Sync offline logs.
@ -270,7 +270,7 @@ export class AddonModLessonSyncProvider extends CoreCourseActivitySyncBaseProvid
protected async syncAttempts( protected async syncAttempts(
lessonId: number, lessonId: number,
result: AddonModLessonSyncResult, result: AddonModLessonSyncResult,
askPassword?: boolean, askPassword = false,
siteId?: string, siteId?: string,
): Promise<AddonModLessonGetPasswordResult | undefined> { ): Promise<AddonModLessonGetPasswordResult | undefined> {
let attempts = await AddonModLessonOffline.instance.getLessonAttempts(lessonId, siteId); let attempts = await AddonModLessonOffline.instance.getLessonAttempts(lessonId, siteId);
@ -408,8 +408,8 @@ export class AddonModLessonSyncProvider extends CoreCourseActivitySyncBaseProvid
lessonId: number, lessonId: number,
result: AddonModLessonSyncResult, result: AddonModLessonSyncResult,
passwordData?: AddonModLessonGetPasswordResult, passwordData?: AddonModLessonGetPasswordResult,
askPassword?: boolean, askPassword = false,
ignoreBlock?: boolean, ignoreBlock = false,
siteId?: string, siteId?: string,
): Promise<void> { ): Promise<void> {
// Attempts sent or there was none. If there is a finished retake, send it. // Attempts sent or there was none. If there is a finished retake, send it.

View File

@ -14,6 +14,7 @@
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { AddonModAssignModule } from './assign/assign.module';
import { AddonModBookModule } from './book/book.module'; import { AddonModBookModule } from './book/book.module';
import { AddonModLessonModule } from './lesson/lesson.module'; import { AddonModLessonModule } from './lesson/lesson.module';
import { AddonModPageModule } from './page/page.module'; import { AddonModPageModule } from './page/page.module';
@ -21,6 +22,7 @@ import { AddonModPageModule } from './page/page.module';
@NgModule({ @NgModule({
declarations: [], declarations: [],
imports: [ imports: [
AddonModAssignModule,
AddonModBookModule, AddonModBookModule,
AddonModLessonModule, AddonModLessonModule,
AddonModPageModule, AddonModPageModule,

View File

@ -17,7 +17,7 @@ import { RouterModule, Routes } from '@angular/router';
const routes: Routes = [ const routes: Routes = [
{ {
path: ':courseId/:cmdId', path: ':courseId/:cmId',
loadChildren: () => import('./pages/index/index.module').then( m => m.AddonModPageIndexPageModule), loadChildren: () => import('./pages/index/index.module').then( m => m.AddonModPageIndexPageModule),
}, },
]; ];

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 B

View File

@ -231,7 +231,7 @@ export class CoreSyncBaseProvider<T = void> {
* @param time Time to set. If not defined, current time. * @param time Time to set. If not defined, current time.
* @return Promise resolved when the time is set. * @return Promise resolved when the time is set.
*/ */
async setSyncTime(id: string, siteId?: string, time?: number): Promise<void> { async setSyncTime(id: string | number, siteId?: string, time?: number): Promise<void> {
time = typeof time != 'undefined' ? time : Date.now(); time = typeof time != 'undefined' ? time : Date.now();
await CoreSync.instance.insertOrUpdateSyncRecord(this.component, id, { time: time }, siteId); await CoreSync.instance.insertOrUpdateSyncRecord(this.component, id, { time: time }, siteId);
@ -245,7 +245,7 @@ export class CoreSyncBaseProvider<T = void> {
* @param siteId Site ID. If not defined, current site. * @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done. * @return Promise resolved when done.
*/ */
async setSyncWarnings(id: string, warnings: string[], siteId?: string): Promise<void> { async setSyncWarnings(id: string | number, warnings: string[], siteId?: string): Promise<void> {
const warningsText = JSON.stringify(warnings || []); const warningsText = JSON.stringify(warnings || []);
await CoreSync.instance.insertOrUpdateSyncRecord(this.component, id, { warnings: warningsText }, siteId); await CoreSync.instance.insertOrUpdateSyncRecord(this.component, id, { warnings: warningsText }, siteId);

View File

@ -42,7 +42,7 @@ export class CoreIonLoadingElement {
* Present the loading. * Present the loading.
*/ */
async present(): Promise<void> { async present(): Promise<void> {
// Wait a bit before presenting the modal, to prevent it being displayed if dissmiss is called fast. // Wait a bit before presenting the modal, to prevent it being displayed if dismiss is called fast.
await CoreUtils.instance.wait(40); await CoreUtils.instance.wait(40);
if (!this.isDismissed) { if (!this.isDismissed) {

View File

@ -55,8 +55,12 @@ export abstract class CorePageItemsListManager<Item> {
/** /**
* Process page started operations. * Process page started operations.
*
* @param splitView Split view component.
*/ */
async start(): Promise<void> { async start(splitView: CoreSplitViewComponent): Promise<void> {
this.watchSplitViewOutlet(splitView);
// Calculate current selected item. // Calculate current selected item.
const route = CoreNavigator.instance.getCurrentRoute({ pageComponent: this.pageComponent }); const route = CoreNavigator.instance.getCurrentRoute({ pageComponent: this.pageComponent });
if (route !== null && route.firstChild) { if (route !== null && route.firstChild) {

View File

@ -71,6 +71,7 @@ export class CoreFaIconDirective implements OnChanges {
if (library != 'ionic') { if (library != 'ionic') {
const src = `assets/fonts/font-awesome/${library}/${iconName}.svg`; const src = `assets/fonts/font-awesome/${library}/${iconName}.svg`;
this.element.setAttribute('src', src); this.element.setAttribute('src', src);
this.element.classList.add('faicon');
if (CoreConstants.BUILD.isDevelopment || CoreConstants.BUILD.isTesting) { if (CoreConstants.BUILD.isDevelopment || CoreConstants.BUILD.isTesting) {
try { try {

View File

@ -20,7 +20,7 @@ import { CoreCourseModulePrefetchHandlerBase } from './module-prefetch-handler';
/** /**
* Base class to create activity sync providers. It provides some common functions. * Base class to create activity sync providers. It provides some common functions.
*/ */
export class CoreCourseActivitySyncBaseProvider<T> extends CoreSyncBaseProvider<T> { export class CoreCourseActivitySyncBaseProvider<T = void> extends CoreSyncBaseProvider<T> {
/** /**
* Conveniece function to prefetch data after an update. * Conveniece function to prefetch data after an update.

View File

@ -522,7 +522,14 @@ export class CoreCourseProvider {
* @param siteId Site ID. If not defined, current site. * @param siteId Site ID. If not defined, current site.
* @return Promise resolved with the module's grade info. * @return Promise resolved with the module's grade info.
*/ */
async getModuleBasicGradeInfo(moduleId: number, siteId?: string): Promise<CoreCourseModuleGradeInfo | false> { async getModuleBasicGradeInfo(moduleId: number, siteId?: string): Promise<CoreCourseModuleGradeInfo | undefined> {
const site = await CoreSites.instance.getSite(siteId);
if (!site || !site.isVersionGreaterEqualThan('3.2')) {
// On 3.1 won't get grading info and will return undefined. See check bellow.
return;
}
const info = await this.getModuleBasicInfo(moduleId, siteId); const info = await this.getModuleBasicInfo(moduleId, siteId);
const grade: CoreCourseModuleGradeInfo = { const grade: CoreCourseModuleGradeInfo = {
@ -539,10 +546,11 @@ export class CoreCourseProvider {
typeof grade.advancedgrading != 'undefined' || typeof grade.advancedgrading != 'undefined' ||
typeof grade.outcomes != 'undefined' typeof grade.outcomes != 'undefined'
) { ) {
// On 3.1 won't get grading info and will return undefined.
return grade; return grade;
} }
return false;
} }
/** /**
@ -1461,22 +1469,32 @@ export type CoreCourseModuleContentFile = {
}; };
/** /**
* Course module basic info type. * Course module basic info type. 3.2 onwards.
*/ */
export type CoreCourseModuleGradeInfo = { export type CoreCourseModuleGradeInfo = {
grade?: number; // Grade (max value or scale id). grade?: number; // Grade (max value or scale id).
scale?: string; // Scale items (if used). scale?: string; // Scale items (if used).
gradepass?: string; // Grade to pass (float). gradepass?: string; // Grade to pass (float).
gradecat?: number; // Grade category. gradecat?: number; // Grade category.
advancedgrading?: { // Advanced grading settings. advancedgrading?: CoreCourseModuleAdvancedGradingSetting[]; // Advanced grading settings.
outcomes?: CoreCourseModuleGradeOutcome[];
};
/**
* Advanced grading settings.
*/
export type CoreCourseModuleAdvancedGradingSetting = {
area: string; // Gradable area name. area: string; // Gradable area name.
method: string; // Grading method. method: string; // Grading method.
}[]; };
outcomes?: { // Outcomes information.
/**
* Grade outcome information.
*/
export type CoreCourseModuleGradeOutcome = {
id: string; // Outcome id. id: string; // Outcome id.
name: string; // Outcome full name. name: string; // Outcome full name.
scale: string; // Scale items. scale: string; // Scale items.
}[];
}; };
/** /**

View File

@ -1,3 +1,5 @@
@import "~theme/globals";
:host { :host {
.course-icon { .course-icon {
color: white; color: white;
@ -10,35 +12,10 @@
transition: all 50ms ease-in-out; transition: all 50ms ease-in-out;
} }
ion-icon[course-color="0"] { @for $i from 0 to length($core-course-image-background) {
color: var(--core-course-color-0); ion-icon[course-color="#{$i}"] {
color: nth($core-course-image-background, $i + 1);
} }
ion-icon[course-color="1"] {
color: var(--core-course-color-1);
}
ion-icon[course-color="2"] {
color: var(--core-course-color-2);
}
ion-icon[course-color="3"] {
color: var(--core-course-color-3);
}
ion-icon[course-color="4"] {
color: var(--core-course-color-4);
}
ion-icon[course-color="5"] {
color: var(--core-course-color-5);
}
ion-icon[course-color="6"] {
color: var(--core-course-color-6);
}
ion-icon[course-color="7"] {
color: var(--core-course-color-7);
}
ion-icon[course-color="8"] {
color: var(--core-course-color-8);
}
ion-icon[course-color="9"] {
color: var(--core-course-color-9);
} }
ion-avatar { ion-avatar {

View File

@ -7,35 +7,10 @@
align-self: stretch; align-self: stretch;
height: calc(100% - 20px); height: calc(100% - 20px);
&[course-color="0"] .core-course-thumb { @for $i from 0 to length($core-course-image-background) {
background: var(--core-course-color-0); &[course-color="#{$i}"] .core-course-thumb {
background: nth($core-course-image-background, $i + 1);
} }
&[course-color="1"] .core-course-thumb {
background: var(--core-course-color-1);
}
&[course-color="2"] .core-course-thumb {
background: var(--core-course-color-2);
}
&[course-color="3"] .core-course-thumb {
background: var(--core-course-color-3);
}
&[course-color="4"] .core-course-thumb {
background: var(--core-course-color-4);
}
&[course-color="5"] .core-course-thumb {
background: var(--core-course-color-5);
}
&[course-color="6"] .core-course-thumb {
background: var(--core-course-color-6);
}
&[course-color="7"] .core-course-thumb {
background: var(--core-course-color-7);
}
&[course-color="8"] .core-course-thumb {
background: var(--core-course-color-8);
}
&[course-color="9"] .core-course-thumb {
background: var(--core-course-color-9);
} }
.core-course-thumb { .core-course-thumb {

View File

@ -63,8 +63,7 @@ export class CoreGradesCoursePage implements AfterViewInit, OnDestroy {
async ngAfterViewInit(): Promise<void> { async ngAfterViewInit(): Promise<void> {
await this.fetchInitialGrades(); await this.fetchInitialGrades();
this.grades.watchSplitViewOutlet(this.splitView); this.grades.start(this.splitView);
this.grades.start();
} }
/** /**

View File

@ -42,8 +42,7 @@ export class CoreGradesCoursesPage implements OnDestroy, AfterViewInit {
async ngAfterViewInit(): Promise<void> { async ngAfterViewInit(): Promise<void> {
await this.fetchInitialCourses(); await this.fetchInitialCourses();
this.courses.watchSplitViewOutlet(this.splitView); this.courses.start(this.splitView);
this.courses.start();
} }
/** /**

View File

@ -31,6 +31,7 @@ import { CoreMenuItem, CoreUtils } from '@services/utils/utils';
import { CoreDomUtils } from '@services/utils/dom'; import { CoreDomUtils } from '@services/utils/dom';
import { CoreNavigator } from '@services/navigator'; import { CoreNavigator } from '@services/navigator';
import { makeSingleton } from '@singletons'; import { makeSingleton } from '@singletons';
import { CoreError } from '@classes/errors/error';
/** /**
* Service that provides some features regarding grades information. * Service that provides some features regarding grades information.
@ -51,16 +52,18 @@ export class CoreGradesHelperProvider {
* @return Formatted row object. * @return Formatted row object.
*/ */
protected formatGradeRow(tableRow: CoreGradesTableRow): CoreGradesFormattedRow { protected formatGradeRow(tableRow: CoreGradesTableRow): CoreGradesFormattedRow {
const row = {}; const row: CoreGradesFormattedRow = {
rowclass: '',
};
for (const name in tableRow) { for (const name in tableRow) {
if (typeof tableRow[name].content != 'undefined' && tableRow[name].content !== null) { if (typeof tableRow[name].content != 'undefined' && tableRow[name].content !== null) {
let content = String(tableRow[name].content); let content = String(tableRow[name].content);
if (name == 'itemname') { if (name == 'itemname') {
this.setRowIcon(row, content); this.setRowIcon(row, content);
row['link'] = this.getModuleLink(content); row.link = this.getModuleLink(content);
row['rowclass'] += tableRow[name]!.class.indexOf('hidden') >= 0 ? ' hidden' : ''; row.rowclass += tableRow[name]!.class.indexOf('hidden') >= 0 ? ' hidden' : '';
row['rowclass'] += tableRow[name]!.class.indexOf('dimmed_text') >= 0 ? ' dimmed_text' : ''; row.rowclass += tableRow[name]!.class.indexOf('dimmed_text') >= 0 ? ' dimmed_text' : '';
content = content.replace(/<\/span>/gi, '\n'); content = content.replace(/<\/span>/gi, '\n');
content = CoreTextUtils.instance.cleanTags(content); content = CoreTextUtils.instance.cleanTags(content);
@ -86,20 +89,20 @@ export class CoreGradesHelperProvider {
* @return Formatted row object. * @return Formatted row object.
*/ */
protected formatGradeRowForTable(tableRow: CoreGradesTableRow): CoreGradesFormattedRowForTable { protected formatGradeRowForTable(tableRow: CoreGradesTableRow): CoreGradesFormattedRowForTable {
const row = {}; const row: CoreGradesFormattedRowForTable = {};
for (let name in tableRow) { for (let name in tableRow) {
if (typeof tableRow[name].content != 'undefined' && tableRow[name].content !== null) { if (typeof tableRow[name].content != 'undefined' && tableRow[name].content !== null) {
let content = String(tableRow[name].content); let content = String(tableRow[name].content);
if (name == 'itemname') { if (name == 'itemname') {
row['id'] = parseInt(tableRow[name]!.id.split('_')[1], 10); row.id = parseInt(tableRow[name]!.id.split('_')[1], 10);
row['colspan'] = tableRow[name]!.colspan; row.colspan = tableRow[name]!.colspan;
row['rowspan'] = (tableRow['leader'] && tableRow['leader'].rowspan) || 1; row.rowspan = (tableRow.leader && tableRow.leader.rowspan) || 1;
this.setRowIcon(row, content); this.setRowIcon(row, content);
row['rowclass'] = tableRow[name]!.class.indexOf('leveleven') < 0 ? 'odd' : 'even'; row.rowclass = tableRow[name]!.class.indexOf('leveleven') < 0 ? 'odd' : 'even';
row['rowclass'] += tableRow[name]!.class.indexOf('hidden') >= 0 ? ' hidden' : ''; row.rowclass += tableRow[name]!.class.indexOf('hidden') >= 0 ? ' hidden' : '';
row['rowclass'] += tableRow[name]!.class.indexOf('dimmed_text') >= 0 ? ' dimmed_text' : ''; row.rowclass += tableRow[name]!.class.indexOf('dimmed_text') >= 0 ? ' dimmed_text' : '';
content = content.replace(/<\/span>/gi, '\n'); content = content.replace(/<\/span>/gi, '\n');
content = CoreTextUtils.instance.cleanTags(content); content = CoreTextUtils.instance.cleanTags(content);
@ -202,14 +205,14 @@ export class CoreGradesHelperProvider {
*/ */
async getGradesCourseData(grades: CoreGradesGradeOverview[]): Promise<CoreGradesGradeOverviewWithCourseData[]> { async getGradesCourseData(grades: CoreGradesGradeOverview[]): Promise<CoreGradesGradeOverviewWithCourseData[]> {
// Obtain courses from cache to prevent network requests. // Obtain courses from cache to prevent network requests.
let coursesWereMissing; let coursesWereMissing = false;
try { try {
const courses = await CoreCourses.instance.getUserCourses(undefined, undefined, CoreSitesReadingStrategy.OnlyCache); const courses = await CoreCourses.instance.getUserCourses(undefined, undefined, CoreSitesReadingStrategy.OnlyCache);
const coursesMap = CoreUtils.instance.arrayToObject(courses, 'id'); const coursesMap = CoreUtils.instance.arrayToObject(courses, 'id');
coursesWereMissing = this.addCourseData(grades, coursesMap); coursesWereMissing = this.addCourseData(grades, coursesMap);
} catch (error) { } catch {
coursesWereMissing = true; coursesWereMissing = true;
} }
@ -278,7 +281,7 @@ export class CoreGradesHelperProvider {
const grades = await CoreGrades.instance.getCourseGradesTable(courseId, userId, siteId, ignoreCache); const grades = await CoreGrades.instance.getCourseGradesTable(courseId, userId, siteId, ignoreCache);
if (!grades) { if (!grades) {
throw new Error('Couldn\'t get grade item'); throw new CoreError('Couldn\'t get grade item');
} }
return this.getGradesTableRow(grades, gradeId); return this.getGradesTableRow(grades, gradeId);
@ -325,15 +328,15 @@ export class CoreGradesHelperProvider {
groupId?: number, groupId?: number,
siteId?: string, siteId?: string,
ignoreCache: boolean = false, ignoreCache: boolean = false,
): Promise<CoreGradesFormattedItem> { ): Promise<CoreGradesFormattedItem[] | CoreGradesFormattedRow[]> {
const grades = await CoreGrades.instance.getGradeItems(courseId, userId, groupId, siteId, ignoreCache); const grades = await CoreGrades.instance.getGradeItems(courseId, userId, groupId, siteId, ignoreCache);
if (!grades) { if (!grades) {
throw new Error('Couldn\'t get grade module items'); throw new CoreError('Couldn\'t get grade module items');
} }
if ('tabledata' in grades) { if ('tabledata' in grades) {
// Table format. // 3.1 Table format.
return this.getModuleGradesTableRows(grades, moduleId); return this.getModuleGradesTableRows(grades, moduleId);
} }
@ -347,18 +350,16 @@ export class CoreGradesHelperProvider {
* @param selectedGrade Selected grade label. * @param selectedGrade Selected grade label.
* @return Selected grade value. * @return Selected grade value.
*/ */
getGradeValueFromLabel(grades: CoreMenuItem[], selectedGrade: string): number { getGradeValueFromLabel(grades: CoreMenuItem[], selectedGrade?: string): number {
if (!grades || !selectedGrade) { if (!grades || !selectedGrade) {
return 0; return 0;
} }
for (const x in grades) { const grade = grades.find((grade) => grade.label == selectedGrade);
if (grades[x].label == selectedGrade) {
return grades[x].value < 0 ? 0 : grades[x].value;
}
}
return 0; return !grade || grade.value < 0
? 0
: grade.value;
} }
/** /**
@ -457,15 +458,15 @@ export class CoreGradesHelperProvider {
siteId = site.id; siteId = site.id;
currentUserId = site.getUserId(); currentUserId = site.getUserId();
if (moduleId) { if (!moduleId) {
throw new CoreError('Invalid moduleId');
}
// Try to open the module grade directly. Check if it's possible. // Try to open the module grade directly. Check if it's possible.
const grades = await CoreGrades.instance.isGradeItemsAvalaible(siteId); const grades = await CoreGrades.instance.isGradeItemsAvalaible(siteId);
if (!grades) { if (!grades) {
throw new Error(); throw new CoreError('No grades found.');
}
} else {
throw new Error();
} }
try { try {
@ -476,7 +477,7 @@ export class CoreGradesHelperProvider {
const item = Array.isArray(items) && items.find((item) => moduleId == item.cmid); const item = Array.isArray(items) && items.find((item) => moduleId == item.cmid);
if (!item) { if (!item) {
throw new Error(); throw new CoreError('Grade item not found.');
} }
// Open the item directly. // Open the item directly.
@ -560,46 +561,49 @@ export class CoreGradesHelperProvider {
* @param text HTML where the image will be rendered. * @param text HTML where the image will be rendered.
* @return Row object with the image. * @return Row object with the image.
*/ */
protected setRowIcon(row: CoreGradesFormattedRowForTable, text: string): CoreGradesFormattedRowForTable { protected setRowIcon(
row: CoreGradesFormattedRowForTable | CoreGradesFormattedRow,
text: string,
): CoreGradesFormattedRowForTable {
text = text.replace('%2F', '/').replace('%2f', '/'); text = text.replace('%2F', '/').replace('%2f', '/');
if (text.indexOf('/agg_mean') > -1) { if (text.indexOf('/agg_mean') > -1) {
row['itemtype'] = 'agg_mean'; row.itemtype = 'agg_mean';
row['image'] = 'assets/img/grades/agg_mean.png'; row.image = 'assets/img/grades/agg_mean.png';
} else if (text.indexOf('/agg_sum') > -1) { } else if (text.indexOf('/agg_sum') > -1) {
row['itemtype'] = 'agg_sum'; row.itemtype = 'agg_sum';
row['image'] = 'assets/img/grades/agg_sum.png'; row.image = 'assets/img/grades/agg_sum.png';
} else if (text.indexOf('/outcomes') > -1 || text.indexOf('fa-tasks') > -1) { } else if (text.indexOf('/outcomes') > -1 || text.indexOf('fa-tasks') > -1) {
row['itemtype'] = 'outcome'; row.itemtype = 'outcome';
row['icon'] = 'fa-tasks'; row.icon = 'fas-chart-pie';
} else if (text.indexOf('i/folder') > -1 || text.indexOf('fa-folder') > -1) { } else if (text.indexOf('i/folder') > -1 || text.indexOf('fa-folder') > -1) {
row['itemtype'] = 'category'; row.itemtype = 'category';
row['icon'] = 'fa-folder'; row.icon = 'fas-cubes';
} else if (text.indexOf('/manual_item') > -1 || text.indexOf('fa-square-o') > -1) { } else if (text.indexOf('/manual_item') > -1 || text.indexOf('fa-square-o') > -1) {
row['itemtype'] = 'manual'; row.itemtype = 'manual';
row['icon'] = 'fa-square-o'; row.icon = 'far-square';
} else if (text.indexOf('/mod/') > -1) { } else if (text.indexOf('/mod/') > -1) {
const module = text.match(/mod\/([^/]*)\//); const module = text.match(/mod\/([^/]*)\//);
if (typeof module?.[1] != 'undefined') { if (typeof module?.[1] != 'undefined') {
row['itemtype'] = 'mod'; row.itemtype = 'mod';
row['itemmodule'] = module[1]; row.itemmodule = module[1];
row['image'] = CoreCourse.instance.getModuleIconSrc( row.image = CoreCourse.instance.getModuleIconSrc(
module[1], module[1],
CoreDomUtils.instance.convertToElement(text).querySelector('img')?.getAttribute('src') ?? undefined, CoreDomUtils.instance.convertToElement(text).querySelector('img')?.getAttribute('src') ?? undefined,
); );
} }
} else { } else {
if (row['rowspan'] && row['rowspan'] > 1) { if (row.rowspan && row.rowspan > 1) {
row['itemtype'] = 'category'; row.itemtype = 'category';
row['icon'] = 'fa-folder'; row.icon = 'fas-cubes';
} else if (text.indexOf('src=') > -1) { } else if (text.indexOf('src=') > -1) {
row['itemtype'] = 'unknown'; row.itemtype = 'unknown';
const src = text.match(/src="([^"]*)"/); const src = text.match(/src="([^"]*)"/);
row['image'] = src?.[1]; row.image = src?.[1];
} else if (text.indexOf('<i ') > -1) { } else if (text.indexOf('<i ') > -1) {
row['itemtype'] = 'unknown'; row.itemtype = 'unknown';
const src = text.match(/<i class="(?:[^"]*?\s)?(fa-[a-z0-9-]+)/); const src = text.match(/<i class="(?:[^"]*?\s)?(fa-[a-z0-9-]+)/);
row['icon'] = src ? src[1] : ''; row.icon = src ? src[1] : '';
} }
} }
@ -665,15 +669,53 @@ export class CoreGradesHelperProvider {
return Promise.resolve([]); return Promise.resolve([]);
} }
/**
* Type guard to check if the param is a CoreGradesGradeItem.
*
* @param item Param to check.
* @return Whether the param is a CoreGradesGradeItem.
*/
isGradeItem(item: CoreGradesGradeItem | CoreGradesFormattedRow): item is CoreGradesGradeItem {
return 'outcomeid' in item;
}
} }
export class CoreGradesHelper extends makeSingleton(CoreGradesHelperProvider) {} export class CoreGradesHelper extends makeSingleton(CoreGradesHelperProvider) {}
// @todo formatted data types. // @todo formatted data types.
export type CoreGradesFormattedRow = any;
export type CoreGradesFormattedRowForTable = any; export type CoreGradesFormattedRowForTable = any;
export type CoreGradesFormattedItem = any;
export type CoreGradesFormattedTableColumn = any; export type CoreGradesFormattedTableColumn = any;
export type CoreGradesFormattedItem = CoreGradesGradeItem & {
weight?: string; // Weight.
grade?: string; // The grade formatted.
range?: string; // Range formatted.
percentage?: string; // Percentage.
lettergrade?: string; // Letter grade.
average?: string; // Grade average.
};
export type CoreGradesFormattedRow = {
icon?: string;
link?: string | false;
rowclass?: string;
itemtype?: string;
image?: string;
itemmodule?: string;
rowspan?: number;
itemname?: string; // The item returned data.
weight?: string; // Weight column.
grade?: string; // Grade column.
range?: string;// Range column.
percentage?: string; // Percentage column.
lettergrade?: string; // Lettergrade column.
rank?: string; // Rank column.
average?: string; // Average column.
feedback?: string; // Feedback column.
contributiontocoursetotal?: string; // Contributiontocoursetotal column.
};
export type CoreGradesFormattedTableRow = CoreGradesFormattedTableRowFilled | CoreGradesFormattedTableRowEmpty; export type CoreGradesFormattedTableRow = CoreGradesFormattedTableRowFilled | CoreGradesFormattedTableRowEmpty;
export type CoreGradesFormattedTable = { export type CoreGradesFormattedTable = {
columns: CoreGradesFormattedTableColumn[]; columns: CoreGradesFormattedTableColumn[];

View File

@ -339,8 +339,10 @@ export class CoreGradesProvider {
* @return True if ws is avalaible, false otherwise. * @return True if ws is avalaible, false otherwise.
* @since Moodle 3.2 * @since Moodle 3.2
*/ */
isGradeItemsAvalaible(siteId?: string): Promise<boolean> { async isGradeItemsAvalaible(siteId?: string): Promise<boolean> {
return CoreSites.instance.getSite(siteId).then((site) => site.wsAvailable('gradereport_user_get_grade_items')); const site = await CoreSites.instance.getSite(siteId);
return site.wsAvailable('gradereport_user_get_grade_items');
} }
/** /**

View File

@ -33,8 +33,7 @@ export class CoreSettingsIndexPage implements AfterViewInit, OnDestroy {
*/ */
ngAfterViewInit(): void { ngAfterViewInit(): void {
this.sections.setItems(CoreSettingsConstants.SECTIONS); this.sections.setItems(CoreSettingsConstants.SECTIONS);
this.sections.watchSplitViewOutlet(this.splitView); this.sections.start(this.splitView);
this.sections.start();
} }
/** /**

View File

@ -62,8 +62,7 @@ export class CoreUserParticipantsPage implements OnInit, AfterViewInit, OnDestro
async ngAfterViewInit(): Promise<void> { async ngAfterViewInit(): Promise<void> {
await this.fetchInitialParticipants(); await this.fetchInitialParticipants();
this.participants.watchSplitViewOutlet(this.splitView); this.participants.start(this.splitView);
this.participants.start();
} }
/** /**

View File

@ -267,7 +267,7 @@ export class CoreCronDelegateService {
* @return True if handler uses network or not defined, false otherwise. * @return True if handler uses network or not defined, false otherwise.
*/ */
protected handlerUsesNetwork(name: string): boolean { protected handlerUsesNetwork(name: string): boolean {
if (!this.handlers[name] || this.handlers[name].usesNetwork) { if (!this.handlers[name] || !this.handlers[name].usesNetwork) {
// Invalid, return default. // Invalid, return default.
return true; return true;
} }

View File

@ -412,7 +412,7 @@ export class CoreGroupsProvider {
* @param groupInfo Group info. * @param groupInfo Group info.
* @return Group ID to use. * @return Group ID to use.
*/ */
validateGroupId(groupId: number, groupInfo: CoreGroupInfo): number { validateGroupId(groupId = 0, groupInfo: CoreGroupInfo): number {
if (groupId > 0 && groupInfo && groupInfo.groups && groupInfo.groups.length > 0) { if (groupId > 0 && groupInfo && groupInfo.groups && groupInfo.groups.length > 0) {
// Check if the group is in the list of groups. // Check if the group is in the list of groups.
if (groupInfo.groups.some((group) => groupId == group.id)) { if (groupInfo.groups.some((group) => groupId == group.id)) {

View File

@ -243,12 +243,34 @@ export class CoreNavigatorService {
return CoreUrlUtils.instance.removeUrlParams(this.previousPath || ''); return CoreUrlUtils.instance.removeUrlParams(this.previousPath || '');
} }
/**
* Iterately get the params checking parent routes.
*
* @param route Current route.
* @param name Name of the parameter.
* @return Value of the parameter, undefined if not found.
*/
protected getRouteSnapshotParam<T = unknown>(name: string, route?: ActivatedRoute): T | undefined {
if (!route?.snapshot) {
return;
}
const value = route.snapshot.queryParams[name] ?? route.snapshot.params[name];
if (typeof value != 'undefined') {
return value;
}
return this.getRouteSnapshotParam(name, route.parent || undefined);
}
/** /**
* Get a parameter for the current route. * Get a parameter for the current route.
* Please notice that objects can only be retrieved once. You must call this function only once per page and parameter, * Please notice that objects can only be retrieved once. You must call this function only once per page and parameter,
* unless there's a new navigation to the page. * unless there's a new navigation to the page.
* *
* @param name Name of the parameter. * @param name Name of the parameter.
* @param params Optional params to get the value from. If missing, it will autodetect.
* @return Value of the parameter, undefined if not found. * @return Value of the parameter, undefined if not found.
*/ */
getRouteParam<T = unknown>(name: string, params?: Params): T | undefined { getRouteParam<T = unknown>(name: string, params?: Params): T | undefined {
@ -256,15 +278,16 @@ export class CoreNavigatorService {
if (!params) { if (!params) {
const route = this.getCurrentRoute(); const route = this.getCurrentRoute();
if (!route.snapshot) {
return;
}
value = route.snapshot.queryParams[name] ?? route.snapshot.params[name]; value = this.getRouteSnapshotParam(name, route);
} else { } else {
value = params[name]; value = params[name];
} }
if (typeof value == 'undefined') {
return;
}
let storedParam = this.storedParams[value]; let storedParam = this.storedParams[value];
// Remove the parameter from our map if it's in there. // Remove the parameter from our map if it's in there.
@ -286,6 +309,7 @@ export class CoreNavigatorService {
* Angular router automatically converts numbers to string, this function automatically converts it back to number. * Angular router automatically converts numbers to string, this function automatically converts it back to number.
* *
* @param name Name of the parameter. * @param name Name of the parameter.
* @param params Optional params to get the value from. If missing, it will autodetect.
* @return Value of the parameter, undefined if not found. * @return Value of the parameter, undefined if not found.
*/ */
getRouteNumberParam(name: string, params?: Params): number | undefined { getRouteNumberParam(name: string, params?: Params): number | undefined {
@ -299,6 +323,7 @@ export class CoreNavigatorService {
* Angular router automatically converts booleans to string, this function automatically converts it back to boolean. * Angular router automatically converts booleans to string, this function automatically converts it back to boolean.
* *
* @param name Name of the parameter. * @param name Name of the parameter.
* @param params Optional params to get the value from. If missing, it will autodetect.
* @return Value of the parameter, undefined if not found. * @return Value of the parameter, undefined if not found.
*/ */
getRouteBooleanParam(name: string, params?: Params): boolean | undefined { getRouteBooleanParam(name: string, params?: Params): boolean | undefined {
@ -355,6 +380,11 @@ export class CoreNavigatorService {
// IonTabs checks the URL to determine which path to load for deep linking, so we clear the URL. // IonTabs checks the URL to determine which path to load for deep linking, so we clear the URL.
// @todo this.location.replaceState(''); // @todo this.location.replaceState('');
options = {
preferCurrentTab: true,
...options,
};
path = path.replace(/^(\.|\/main)?\//, ''); path = path.replace(/^(\.|\/main)?\//, '');
const pathRoot = /^[^/]+/.exec(path)?.[0] ?? ''; const pathRoot = /^[^/]+/.exec(path)?.[0] ?? '';
@ -364,7 +394,7 @@ export class CoreNavigatorService {
false, false,
); );
if (options.preferCurrentTab === false && isMainMenuTab) { if (!options.preferCurrentTab && isMainMenuTab) {
return this.navigate(`/main/${path}`, options); return this.navigate(`/main/${path}`, options);
} }

Some files were not shown because too many files have changed in this diff Show More