diff --git a/src/addons/mod/assign/assign-lazy.module.ts b/src/addons/mod/assign/assign-lazy.module.ts
new file mode 100644
index 000000000..1107997d6
--- /dev/null
+++ b/src/addons/mod/assign/assign-lazy.module.ts
@@ -0,0 +1,42 @@
+// (C) Copyright 2015 Moodle Pty Ltd.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { CoreSharedModule } from '@/core/shared.module';
+import { NgModule } from '@angular/core';
+import { RouterModule, Routes } from '@angular/router';
+import { AddonModAssignComponentsModule } from './components/components.module';
+import { AddonModAssignIndexPage } from './pages/index/index.page';
+
+const routes: Routes = [
+ {
+ path: ':courseId/:cmId',
+ component: AddonModAssignIndexPage,
+ },
+ {
+ path: ':courseId/:cmId/submission-list',
+ component: AddonModAssignSubmissionListPage,
+ },
+];
+
+@NgModule({
+ imports: [
+ RouterModule.forChild(routes),
+ CoreSharedModule,
+ AddonModAssignComponentsModule,
+ ],
+ declarations: [
+ AddonModAssignIndexPage,
+ ],
+})
+export class AddonModAssignLazyModule {}
diff --git a/src/addons/mod/assign/assign.module.ts b/src/addons/mod/assign/assign.module.ts
new file mode 100644
index 000000000..1a7565342
--- /dev/null
+++ b/src/addons/mod/assign/assign.module.ts
@@ -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 { 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 { 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';
+
+const routes: Routes = [
+ {
+ path: AddonModAssignModuleHandlerService.PAGE_NAME,
+ loadChildren: () => import('./assign-lazy.module').then(m => m.AddonModAssignLazyModule),
+ },
+];
+
+@NgModule({
+ imports: [
+ CoreMainMenuTabRoutingModule.forChild(routes),
+ AddonModAssignComponentsModule,
+ ],
+ 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 {}
diff --git a/src/addons/mod/assign/components/components.module.ts b/src/addons/mod/assign/components/components.module.ts
new file mode 100644
index 000000000..bdc46ebfb
--- /dev/null
+++ b/src/addons/mod/assign/components/components.module.ts
@@ -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 { NgModule } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { IonicModule } from '@ionic/angular';
+import { TranslateModule } from '@ngx-translate/core';
+
+import { CoreSharedModule } from '@/core/shared.module';
+import { CoreCourseComponentsModule } from '@features/course/components/components.module';
+import { AddonModAssignIndexComponent } from './index/index';
+
+@NgModule({
+ declarations: [
+ AddonModAssignIndexComponent,
+ /* AddonModAssignSubmissionComponent,
+ AddonModAssignSubmissionPluginComponent,
+ AddonModAssignFeedbackPluginComponent*/
+ ],
+ imports: [
+ CommonModule,
+ IonicModule,
+ TranslateModule.forChild(),
+ FormsModule,
+ CoreSharedModule,
+ CoreCourseComponentsModule,
+ ],
+ exports: [
+ AddonModAssignIndexComponent,
+ /* AddonModAssignSubmissionComponent,
+ AddonModAssignSubmissionPluginComponent,
+ AddonModAssignFeedbackPluginComponent */
+ ],
+})
+export class AddonModAssignComponentsModule {}
diff --git a/src/addons/mod/assign/components/index/addon-mod-assign-index.html b/src/addons/mod/assign/components/index/addon-mod-assign-index.html
new file mode 100644
index 000000000..67c2169e9
--- /dev/null
+++ b/src/addons/mod/assign/components/index/addon-mod-assign-index.html
@@ -0,0 +1,142 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ 'core.hasdatatosync' | translate: {$a: moduleName} }}
+
+
+
+
+
+
+
+
+ {{'core.groupsseparate' | translate }}
+ {{'core.groupsvisible' | translate }}
+
+
+
+ {{groupOpt.name}}
+
+
+
+
+
+
+ {{ 'addon.mod_assign.timeremaining' | translate }}
+ {{ timeRemaining }}
+
+
+
+
+ {{ 'addon.mod_assign.latesubmissions' | translate }}
+ {{ lateSubmissions }}
+
+
+
+
+
+
+ {{ 'addon.mod_assign.numberofteams' | translate }}
+ {{ 'addon.mod_assign.numberofparticipants' | translate }}
+
+
+ {{ summary.participantcount }}
+
+
+
+
+
+ {{ 'addon.mod_assign.numberofdraftsubmissions' | translate }}
+
+ {{ summary.submissiondraftscount }}
+
+
+
+
+
+ {{ 'addon.mod_assign.numberofsubmittedassignments' | translate }}
+
+ {{ summary.submissionssubmittedcount }}
+
+
+
+
+
+ {{ 'addon.mod_assign.numberofsubmissionsneedgrading' | translate }}
+
+ {{ summary.submissionsneedgradingcount }}
+
+
+
+
+
+
+
+
+ {{ 'addon.mod_assign.'+summary.warnofungroupedusers | translate }}
+
+
+
+
+
+
+
+
diff --git a/src/addons/mod/assign/components/index/index.ts b/src/addons/mod/assign/components/index/index.ts
new file mode 100644
index 000000000..d62267888
--- /dev/null
+++ b/src/addons/mod/assign/components/index/index.ts
@@ -0,0 +1,414 @@
+// (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,
+} from '../../services/assign';
+import { AddonModAssignOffline } from '../../services/assign-offline';
+import {
+ AddonModAssignAutoSyncData,
+ AddonModAssignSync,
+ AddonModAssignSyncProvider,
+ AddonModAssignSyncResult,
+} from '../../services/assign-sync';
+
+/**
+ * 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 {
+
+ // @todo @ViewChild(AddonModAssignSubmissionComponent) submissionComponent?: AddonModAssignSubmissionComponent;
+ submissionComponent?: any;
+
+ 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 {
+ super.ngOnInit();
+
+ this.currentUserId = CoreSites.instance.getCurrentSiteUserId();
+ this.currentSite = CoreSites.instance.getCurrentSite();
+
+ // Listen to events.
+ this.savedObserver = CoreEvents.on(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(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(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 {
+
+ // 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: number): Promise {
+ 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 count Number of submissions with the status.
+ */
+ goToSubmissionList(status: string, count: number): void {
+ if (typeof status != 'undefined' && !count && this.showNumbers) {
+ return;
+ }
+
+ const params: Params = {
+ groupId: this.group || 0,
+ moduleName: this.moduleName,
+ };
+ if (typeof status != 'undefined') {
+ params.status = status;
+ }
+ CoreNavigator.instance.navigate('submission-list', {
+ 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 {
+ const promises: Promise[] = [];
+
+ 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 {
+ await AddonModAssignSync.instance.syncAssign(this.assign!.id);
+ }
+
+ /**
+ * Component being destroyed.
+ */
+ ngOnDestroy(): void {
+ super.ngOnDestroy();
+
+ this.savedObserver?.off();
+ this.submittedObserver?.off();
+ this.gradedObserver?.off();
+ }
+
+}
diff --git a/src/addons/mod/assign/lang.json b/src/addons/mod/assign/lang.json
new file mode 100644
index 000000000..5311cdf8a
--- /dev/null
+++ b/src/addons/mod/assign/lang.json
@@ -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 {{$a}}",
+ "allowsubmissionsanddescriptionfromdatesummary": "The assignment details and submission form will be available from {{$a}}",
+ "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": "Assignment is overdue by: {{$a}}",
+ "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"
+}
\ No newline at end of file
diff --git a/src/addons/mod/assign/pages/index/index.html b/src/addons/mod/assign/pages/index/index.html
new file mode 100644
index 000000000..4a3c8499d
--- /dev/null
+++ b/src/addons/mod/assign/pages/index/index.html
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/addons/mod/assign/pages/index/index.page.ts b/src/addons/mod/assign/pages/index/index.page.ts
new file mode 100644
index 000000000..3a7635582
--- /dev/null
+++ b/src/addons/mod/assign/pages/index/index.page.ts
@@ -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();
+ }
+
+}
diff --git a/src/addons/mod/assign/services/assign-helper.ts b/src/addons/mod/assign/services/assign-helper.ts
new file mode 100644
index 000000000..08cc4c45f
--- /dev/null
+++ b/src/addons/mod/assign/services/assign-helper.ts
@@ -0,0 +1,727 @@
+// (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,
+} 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 {
+ 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, inputData: any): void {
+ 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 {
+ const pluginData: any = {};
+ 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: undefined,
+ gradeddate: undefined,
+ };
+ }
+
+ /**
+ * Create an empty submission object.
+ *
+ * @return Submission.
+ */
+ createEmptySubmission(): AddonModAssignSubmissionFormatted {
+ return {
+ id: undefined,
+ userid: undefined,
+ attemptnumber: undefined,
+ timecreated: undefined,
+ timemodified: undefined,
+ status: undefined,
+ groupid: undefined,
+ };
+ }
+
+ /**
+ * 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 {
+ 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 {
+
+ 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 {
+
+ 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): AddonModAssignPluginsEnabled {
+ const enabled: AddonModAssignPluginsEnabled = [];
+
+ 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,
+ });
+ }
+ });
+
+ 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 {
+ 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: any,
+ ): Promise {
+
+ 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 {
+ // 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[] = [];
+ 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,
+ feedback: AddonModAssignSubmissionFeedback,
+ userId: number,
+ ): Promise {
+
+ let hasChanged = false;
+
+ const promises = feedback.plugins
+ ? 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,
+ inputData: any,
+ ): Promise {
+ 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 {
+
+ const pluginData = {};
+ 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,
+ inputData: any,
+ offline = false,
+ ): Promise {
+
+ const pluginData = {};
+ const promises = submission.plugins
+ ? 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 {
+ // 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 {
+ 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 {
+ 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.
+ */
+ uploadOrStoreFiles(
+ assignId: number,
+ folderName: string,
+ files: (CoreWSExternalFile | FileEntry)[],
+ offline = false,
+ userId?: number,
+ siteId?: string,
+ ): Promise {
+
+ if (offline) {
+ return this.storeSubmissionFiles(assignId, folderName, files, userId, siteId);
+ }
+
+ return this.uploadFiles(assignId, files, siteId);
+ }
+
+}
+export const AddonModAssignHelper = makeSingleton(AddonModAssignHelperProvider);
+
+
+/**
+ * Assign submission with some calculated data.
+ */
+export type AddonModAssignSubmissionFormatted =
+ Omit & {
+ id?: number; // Submission id.
+ userid?: number; // Student id.
+ attemptnumber?: number; // Attempt number.
+ timecreated?: number; // Submission creation time.
+ timemodified?: number; // Submission last modified time.
+ status?: string; // Submission status.
+ groupid?: number; // Group 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.
+ };
+
+/**
+ * Assingment subplugins type enabled.
+ */
+export type AddonModAssignPluginsEnabled = {
+ type: string; // Plugin type.
+}[];
+
+/**
+ * Assingment plugin config.
+ */
+export type AddonModAssignPluginConfig = {[name: string]: string};
diff --git a/src/addons/mod/assign/services/assign-offline.ts b/src/addons/mod/assign/services/assign-offline.ts
new file mode 100644
index 000000000..78da7092e
--- /dev/null
+++ b/src/addons/mod/assign/services/assign-offline.ts
@@ -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 {
+ 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 {
+ 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 {
+ const promises:
+ Promise[] = [];
+
+ 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 {
+ 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 {
+ return this.getAssignSubmissionsFormatted({ assingid: 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 {
+ 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(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 {
+ 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 {
+ return this.getAssignSubmissionsGradeFormatted({ assingid: 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 {
+ 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(submission.outcomes, {}),
+ plugindata: CoreTextUtils.instance.parseJSON(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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ const promises:
+ Promise[] = [];
+
+
+ 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 {
+ 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 {
+ 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 {
+ 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 & {
+ plugindata: AddonModAssignSavePluginData;
+};
+
+export type AddonModAssignSubmissionsGradingDBRecordFormatted =
+ Omit & {
+ plugindata: AddonModAssignSavePluginData;
+ outcomes: AddonModAssignOutcomes;
+ };
diff --git a/src/addons/mod/assign/services/assign-sync.ts b/src/addons/mod/assign/services/assign-sync.ts
new file mode 100644
index 000000000..5335cd234
--- /dev/null
+++ b/src/addons/mod/assign/services/assign-sync.ts
@@ -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 {
+
+ 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 {
+ 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 {
+ 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 {
+ // 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(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 {
+ 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 {
+ 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 {
+ // 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[] = [];
+
+ 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 {
+ // 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 {
+ // 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 {
+
+ 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 {
+
+ // 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 {
+
+ 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 || '') || undefined;
+ }
+ } 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[] = [];
+ 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;
+};
diff --git a/src/addons/mod/assign/services/assign.ts b/src/addons/mod/assign/services/assign.ts
new file mode 100644
index 000000000..8ac694051
--- /dev/null
+++ b/src/addons/mod/assign/services/assign.ts
@@ -0,0 +1,1855 @@
+// (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 { CoreSite, CoreSiteWSPreSets } from '@classes/site';
+import { CoreInterceptor } from '@classes/interceptor';
+import { CoreWSExternalWarning, CoreWSExternalFile } from '@services/ws';
+import { makeSingleton } from '@singletons';
+import { CoreCourseCommonModWSOptions } from '@features/course/services/course';
+import { CoreTextUtils } from '@services/utils/text';
+import { CoreGrades } from '@features/grades/services/grades';
+import { CoreFilepool } from '@services/filepool';
+import { CoreTimeUtils } from '@services/utils/time';
+import { CoreCourseLogHelper } from '@features/course/services/log-helper';
+import { CoreError } from '@classes/errors/error';
+import { CoreApp } from '@services/app';
+import { CoreUtils } from '@services/utils/utils';
+import { AddonModAssignOffline } from './assign-offline';
+import { AddonModAssignSubmissionDelegate } from './submission-delegate';
+
+const ROOT_CACHE_KEY = 'mmaModAssign:';
+
+/**
+ * Service that provides some functions for assign.
+ */
+@Injectable({ providedIn: 'root' })
+export class AddonModAssignProvider {
+
+ static readonly COMPONENT = 'mmaModAssign';
+ static readonly SUBMISSION_COMPONENT = 'mmaModAssignSubmission';
+ static readonly UNLIMITED_ATTEMPTS = -1;
+
+ // Submission status.
+ static readonly SUBMISSION_STATUS_NEW = 'new';
+ static readonly SUBMISSION_STATUS_REOPENED = 'reopened';
+ static readonly SUBMISSION_STATUS_DRAFT = 'draft';
+ static readonly SUBMISSION_STATUS_SUBMITTED = 'submitted';
+
+ // "Re-open" methods (to retry the assign).
+ static readonly ATTEMPT_REOPEN_METHOD_NONE = 'none';
+ static readonly ATTEMPT_REOPEN_METHOD_MANUAL = 'manual';
+
+ // Grading status.
+ static readonly GRADING_STATUS_GRADED = 'graded';
+ static readonly GRADING_STATUS_NOT_GRADED = 'notgraded';
+ static readonly MARKING_WORKFLOW_STATE_RELEASED = 'released';
+ static readonly NEED_GRADING = 'needgrading';
+ static readonly GRADED_FOLLOWUP_SUBMIT = 'gradedfollowupsubmit';
+
+ // Group submissions warnings.
+ static readonly WARN_GROUPS_REQUIRED = 'warnrequired';
+ static readonly WARN_GROUPS_OPTIONAL = 'warnoptional';
+
+ // Events.
+ static readonly SUBMISSION_SAVED_EVENT = 'addon_mod_assign_submission_saved';
+ static readonly SUBMITTED_FOR_GRADING_EVENT = 'addon_mod_assign_submitted_for_grading';
+ static readonly GRADED_EVENT = 'addon_mod_assign_graded';
+
+ protected gradingOfflineEnabled: {[siteId: string]: boolean} = {};
+
+ /**
+ * Check if the user can submit in offline. This should only be used if submissionStatus.lastattempt.cansubmit cannot
+ * be used (offline usage).
+ * This function doesn't check if the submission is empty, it should be checked before calling this function.
+ *
+ * @param assign Assignment instance.
+ * @param submissionStatus Submission status returned by getSubmissionStatus.
+ * @return Whether it can submit.
+ */
+ canSubmitOffline(assign: AddonModAssignAssign, submissionStatus: AddonModAssignGetSubmissionStatusWSResponse): boolean {
+ if (!this.isSubmissionOpen(assign, submissionStatus)) {
+ return false;
+ }
+
+ const userSubmission = submissionStatus.lastattempt?.submission;
+ const teamSubmission = submissionStatus.lastattempt?.teamsubmission;
+
+ if (teamSubmission) {
+ if (teamSubmission.status === AddonModAssignProvider.SUBMISSION_STATUS_SUBMITTED) {
+ // The assignment submission has been completed.
+ return false;
+ } else if (userSubmission && userSubmission.status === AddonModAssignProvider.SUBMISSION_STATUS_SUBMITTED) {
+ // The user has already clicked the submit button on the team submission.
+ return false;
+ } else if (assign.preventsubmissionnotingroup && !submissionStatus.lastattempt?.submissiongroup) {
+ return false;
+ }
+ } else if (userSubmission) {
+ if (userSubmission.status === AddonModAssignProvider.SUBMISSION_STATUS_SUBMITTED) {
+ // The assignment submission has been completed.
+ return false;
+ }
+ } else {
+ // No valid submission or team submission.
+ return false;
+ }
+
+ // Last check is that this instance allows drafts.
+ return !!assign.submissiondrafts;
+ }
+
+ /**
+ * Fix some submission status params.
+ *
+ * @param site Site to use.
+ * @param userId User Id (empty for current user).
+ * @param groupId Group Id (empty for all participants).
+ * @param isBlind If blind marking is enabled or not.
+ * @return Object with fixed params.
+ */
+ protected fixSubmissionStatusParams(
+ site: CoreSite,
+ userId?: number,
+ groupId?: number,
+ isBlind = false,
+ ): AddonModAssignFixedSubmissionParams {
+
+ return {
+ isBlind: !userId ? false : !!isBlind,
+ groupId: site.isVersionGreaterEqualThan('3.5') ? groupId || 0 : 0,
+ userId: userId || site.getUserId(),
+ };
+ }
+
+ /**
+ * Get an assignment by course module ID.
+ *
+ * @param courseId Course ID the assignment belongs to.
+ * @param cmId Assignment module ID.
+ * @param options Other options.
+ * @return Promise resolved with the assignment.
+ */
+ getAssignment(courseId: number, cmId: number, options: CoreSitesCommonWSOptions = {}): Promise {
+ return this.getAssignmentByField(courseId, 'cmid', cmId, options);
+ }
+
+ /**
+ * Get an assigment with key=value. If more than one is found, only the first will be returned.
+ *
+ * @param courseId Course ID.
+ * @param key Name of the property to check.
+ * @param value Value to search.
+ * @param options Other options.
+ * @return Promise resolved when the assignment is retrieved.
+ */
+ protected async getAssignmentByField(
+ courseId: number,
+ key: string,
+ value: number,
+ options: CoreSitesCommonWSOptions = {},
+ ): Promise {
+ const site = await CoreSites.instance.getSite(options.siteId);
+
+ const params: AddonModAssignGetAssignmentsWSParams = {
+ courseids: [courseId],
+ includenotenrolledcourses: true,
+ };
+
+ const preSets: CoreSiteWSPreSets = {
+ cacheKey: this.getAssignmentCacheKey(courseId),
+ updateFrequency: CoreSite.FREQUENCY_RARELY,
+ component: AddonModAssignProvider.COMPONENT,
+ ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
+ };
+
+ let response: AddonModAssignGetAssignmentsWSResponse;
+
+ try {
+ response = await site.read('mod_assign_get_assignments', params, preSets);
+ } catch {
+ // In 3.6 we added a new parameter includenotenrolledcourses that could cause offline data not to be found.
+ // Retry again without the param to check if the request is already cached.
+ delete params.includenotenrolledcourses;
+
+ response = await site.read('mod_assign_get_assignments', params, preSets);
+ }
+
+ // Search the assignment to return.
+ if (response.courses.length) {
+ const assignment = response.courses[0].assignments.find((assignment) => assignment[key] == value);
+
+ if (assignment) {
+ return assignment;
+ }
+ }
+
+ throw new CoreError('Assignment not found');
+ }
+
+ /**
+ * Get an assignment by instance ID.
+ *
+ * @param courseId Course ID the assignment belongs to.
+ * @param id Assignment instance ID.
+ * @param options Other options.
+ * @return Promise resolved with the assignment.
+ */
+ getAssignmentById(courseId: number, id: number, options: CoreSitesCommonWSOptions = {}): Promise {
+ return this.getAssignmentByField(courseId, 'id', id, options);
+ }
+
+ /**
+ * Get cache key for assignment data WS calls.
+ *
+ * @param courseId Course ID.
+ * @return Cache key.
+ */
+ protected getAssignmentCacheKey(courseId: number): string {
+ return ROOT_CACHE_KEY + 'assignment:' + courseId;
+ }
+
+ /**
+ * Get an assignment user mapping for blind marking.
+ *
+ * @param assignId Assignment Id.
+ * @param userId User Id to be blinded.
+ * @param options Other options.
+ * @return Promise resolved with the user blind id.
+ */
+ async getAssignmentUserMappings(assignId: number, userId: number, options: CoreCourseCommonModWSOptions = {}): Promise {
+ if (!userId || userId < 0) {
+ // User not valid, stop.
+ return -1;
+ }
+
+ const site = await CoreSites.instance.getSite(options.siteId);
+
+ const params: AddonModAssignGetUserMappingsWSParams = {
+ assignmentids: [assignId],
+ };
+ const preSets: CoreSiteWSPreSets = {
+ cacheKey: this.getAssignmentUserMappingsCacheKey(assignId),
+ updateFrequency: CoreSite.FREQUENCY_OFTEN,
+ component: AddonModAssignProvider.COMPONENT,
+ componentId: options.cmId,
+ ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy),
+ };
+
+ const response = await site.read('mod_assign_get_user_mappings', params, preSets);
+
+ // Search the user.
+ if (response.assignments.length && response.assignments[0].assignmentid == assignId) {
+ const mapping = response.assignments[0].mappings.find((mapping) => mapping.userid == userId);
+
+ if (mapping) {
+ return mapping.id;
+ }
+ } else if (response.warnings && response.warnings.length) {
+ throw response.warnings[0];
+ }
+
+ throw new CoreError('Assignment user mappings not found');
+ }
+
+ /**
+ * Get cache key for assignment user mappings data WS calls.
+ *
+ * @param assignId Assignment ID.
+ * @return Cache key.
+ */
+ protected getAssignmentUserMappingsCacheKey(assignId: number): string {
+ return ROOT_CACHE_KEY + 'usermappings:' + assignId;
+ }
+
+ /**
+ * Returns grade information from assign_grades for the requested assignment id
+ *
+ * @param assignId Assignment Id.
+ * @param options Other options.
+ * @return Resolved with requested info when done.
+ */
+ async getAssignmentGrades(assignId: number, options: CoreCourseCommonModWSOptions = {}): Promise {
+ const site = await CoreSites.instance.getSite(options.siteId);
+
+ const params: AddonModAssignGetGradesWSParams = {
+ assignmentids: [assignId],
+ };
+ const preSets: CoreSiteWSPreSets = {
+ cacheKey: this.getAssignmentGradesCacheKey(assignId),
+ component: AddonModAssignProvider.COMPONENT,
+ componentId: options.cmId,
+ ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy),
+ };
+
+ const response = await site.read('mod_assign_get_grades', params, preSets);
+
+ // Search the assignment.
+ if (response.assignments.length && response.assignments[0].assignmentid == assignId) {
+ return response.assignments[0].grades;
+ } else if (response.warnings && response.warnings.length) {
+ if (response.warnings[0].warningcode == '3') {
+ // No grades found.
+ return [];
+ }
+
+ throw response.warnings[0];
+ }
+
+ throw new CoreError('Assignment grades not found.');
+ }
+
+ /**
+ * Get cache key for assignment grades data WS calls.
+ *
+ * @param assignId Assignment ID.
+ * @return Cache key.
+ */
+ protected getAssignmentGradesCacheKey(assignId: number): string {
+ return ROOT_CACHE_KEY + 'assigngrades:' + assignId;
+ }
+
+ /**
+ * Returns the color name for a given grading status name.
+ *
+ * @param status Grading status name
+ * @return The color name.
+ */
+ getSubmissionGradingStatusColor(status: string): string {
+ if (!status) {
+ return '';
+ }
+
+ if (status == AddonModAssignProvider.GRADING_STATUS_GRADED ||
+ status == AddonModAssignProvider.MARKING_WORKFLOW_STATE_RELEASED) {
+ return 'success';
+ }
+
+ return 'danger';
+ }
+
+ /**
+ * Returns the translation id for a given grading status name.
+ *
+ * @param status Grading Status name
+ * @return The status translation identifier.
+ */
+ getSubmissionGradingStatusTranslationId(status?: string): string | undefined {
+ if (!status) {
+ return;
+ }
+
+ if (status == AddonModAssignProvider.GRADING_STATUS_GRADED || status == AddonModAssignProvider.GRADING_STATUS_NOT_GRADED
+ || status == AddonModAssignProvider.GRADED_FOLLOWUP_SUBMIT) {
+ return 'addon.mod_assign.' + status;
+ }
+
+ return 'addon.mod_assign.markingworkflowstate' + status;
+ }
+
+ /**
+ * Get the submission object from an attempt.
+ *
+ * @param assign Assign.
+ * @param attempt Attempt.
+ * @return Submission object or null.
+ */
+ getSubmissionObjectFromAttempt(
+ assign: AddonModAssignAssign,
+ attempt: AddonModAssignSubmissionAttempt | undefined,
+ ): AddonModAssignSubmission | undefined {
+ if (!attempt) {
+ return;
+ }
+
+ return assign.teamsubmission ? attempt.teamsubmission : attempt.submission;
+ }
+
+ /**
+ * Get attachments of a submission plugin.
+ *
+ * @param submissionPlugin Submission plugin.
+ * @return Submission plugin attachments.
+ */
+ getSubmissionPluginAttachments(submissionPlugin: AddonModAssignPlugin): CoreWSExternalFile[] {
+ if (!submissionPlugin.fileareas) {
+ return [];
+ }
+
+ const files: CoreWSExternalFile[] = [];
+
+ submissionPlugin.fileareas.forEach((filearea) => {
+ if (!filearea || !filearea.files) {
+ // No files to get.
+ return;
+ }
+
+ filearea.files.forEach((file) => {
+ if (!file.filename) {
+ // We don't have filename, extract it from the path.
+ file.filename = file.filepath?.charAt(0) == '/' ? file.filepath.substr(1) : file.filepath;
+ }
+
+ files.push(file);
+ });
+ });
+
+ return files;
+ }
+
+ /**
+ * Get text of a submission plugin.
+ *
+ * @param submissionPlugin Submission plugin.
+ * @param keepUrls True if it should keep original URLs, false if they should be replaced.
+ * @return Submission text.
+ */
+ getSubmissionPluginText(submissionPlugin: AddonModAssignPlugin, keepUrls = false): string {
+ if (!submissionPlugin.editorfields) {
+ return '';
+ }
+ let text = '';
+
+ submissionPlugin.editorfields.forEach((field) => {
+ text += field.text;
+ });
+
+ if (!keepUrls && submissionPlugin.fileareas && submissionPlugin.fileareas[0]) {
+ text = CoreTextUtils.instance.replacePluginfileUrls(text, submissionPlugin.fileareas[0].files || []);
+ }
+
+ return text;
+ }
+
+ /**
+ * Get an assignment submissions.
+ *
+ * @param assignId Assignment id.
+ * @param options Other options.
+ * @return Promise resolved when done.
+ */
+ async getSubmissions(
+ assignId: number,
+ options: CoreCourseCommonModWSOptions = {},
+ ): Promise<{ canviewsubmissions: boolean; submissions?: AddonModAssignSubmission[] }> {
+ const site = await CoreSites.instance.getSite(options.siteId);
+
+ const params: ModAssignGetSubmissionsWSParams = {
+ assignmentids: [assignId],
+ };
+ const preSets: CoreSiteWSPreSets = {
+ cacheKey: this.getSubmissionsCacheKey(assignId),
+ updateFrequency: CoreSite.FREQUENCY_OFTEN,
+ component: AddonModAssignProvider.COMPONENT,
+ componentId: options.cmId,
+ ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy),
+ };
+ const response = await site.read('mod_assign_get_submissions', params, preSets);
+
+ // Check if we can view submissions, with enough permissions.
+ if (response.warnings?.length && response.warnings[0].warningcode == '1') {
+ return { canviewsubmissions: false };
+ }
+
+ if (response.assignments && response.assignments.length) {
+ return {
+ canviewsubmissions: true,
+ submissions: response.assignments[0].submissions,
+ };
+ }
+
+ throw new CoreError('Assignment submissions not found');
+ }
+
+ /**
+ * Get cache key for assignment submissions data WS calls.
+ *
+ * @param assignId Assignment id.
+ * @return Cache key.
+ */
+ protected getSubmissionsCacheKey(assignId: number): string {
+ return ROOT_CACHE_KEY + 'submissions:' + assignId;
+ }
+
+ /**
+ * Get information about an assignment submission status for a given user.
+ *
+ * @param assignId Assignment instance id.
+ * @param options Other options.
+ * @return Promise always resolved with the user submission status.
+ */
+ async getSubmissionStatus(
+ assignId: number,
+ options: AddonModAssignSubmissionStatusOptions = {},
+ ): Promise {
+ const site = await CoreSites.instance.getSite(options.siteId);
+
+ options = {
+ filter: true,
+ ...options,
+ };
+
+ const fixedParams = this.fixSubmissionStatusParams(site, options.userId, options.groupId, options.isBlind);
+ const params: AddonModAssignGetSubmissionStatusWSParams = {
+ assignid: assignId,
+ userid: fixedParams.userId,
+ };
+ if (fixedParams.groupId) {
+ params.groupid = fixedParams.groupId;
+ }
+
+ const preSets: CoreSiteWSPreSets = {
+ cacheKey: this.getSubmissionStatusCacheKey(
+ assignId,
+ fixedParams.userId,
+ fixedParams.groupId,
+ fixedParams.isBlind,
+ ),
+ getCacheUsingCacheKey: true,
+ filter: options.filter,
+ rewriteurls: options.filter,
+ component: AddonModAssignProvider.COMPONENT,
+ componentId: options.cmId,
+ // Don't cache when getting text without filters.
+ // @todo Change this to support offline editing.
+ saveToCache: options.filter,
+ ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy),
+ };
+
+ return site.read('mod_assign_get_submission_status', params, preSets);
+ }
+
+ /**
+ * Get information about an assignment submission status for a given user.
+ * If the data doesn't include the user submission, retry ignoring cache.
+ *
+ * @param assign Assignment.
+ * @param options Other options.
+ * @return Promise always resolved with the user submission status.
+ */
+ async getSubmissionStatusWithRetry(
+ assign: AddonModAssignAssign,
+ options: AddonModAssignSubmissionStatusOptions = {},
+ ): Promise {
+ options.cmId = options.cmId || assign.cmid;
+
+ const response = await this.getSubmissionStatus(assign.id, options);
+
+ const userSubmission = this.getSubmissionObjectFromAttempt(assign, response.lastattempt);
+ if (userSubmission) {
+ return response;
+ }
+ // Try again, ignoring cache.
+ const newOptions = {
+ ...options,
+ readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
+ };
+
+ try {
+ return this.getSubmissionStatus(assign.id, newOptions);
+ } catch {
+ // Error, return the first result even if it doesn't have the user submission.
+ return response;
+ }
+ }
+
+ /**
+ * Get cache key for get submission status data WS calls.
+ *
+ * @param assignId Assignment instance id.
+ * @param userId User id (empty for current user).
+ * @param groupId Group Id (empty for all participants).
+ * @param isBlind If blind marking is enabled or not.
+ * @return Cache key.
+ */
+ protected getSubmissionStatusCacheKey(assignId: number, userId: number, groupId?: number, isBlind = false): string {
+ return this.getSubmissionsCacheKey(assignId) + ':' + userId + ':' + (isBlind ? 1 : 0) + ':' + groupId;
+ }
+
+ /**
+ * Returns the color name for a given status name.
+ *
+ * @param status Status name
+ * @return The color name.
+ */
+ getSubmissionStatusColor(status: string): string {
+ switch (status) {
+ case 'submitted':
+ return 'success';
+ case 'draft':
+ return 'info';
+ case 'new':
+ case 'noattempt':
+ case 'noonlinesubmissions':
+ case 'nosubmission':
+ case 'gradedfollowupsubmit':
+ return 'danger';
+ default:
+ return 'light';
+ }
+ }
+
+ /**
+ * Given a list of plugins, returns the plugin names that aren't supported for editing.
+ *
+ * @param plugins Plugins to check.
+ * @return Promise resolved with unsupported plugin names.
+ */
+ async getUnsupportedEditPlugins(plugins: AddonModAssignPlugin[]): Promise {
+ const notSupported: string[] = [];
+ const promises = plugins.map((plugin) =>
+ AddonModAssignSubmissionDelegate.instance.isPluginSupportedForEdit(plugin.type).then((enabled) => {
+ if (!enabled) {
+ notSupported.push(plugin.name);
+ }
+
+ return;
+ }));
+
+ await Promise.all(promises);
+
+ return notSupported;
+ }
+
+ /**
+ * List the participants for a single assignment, with some summary info about their submissions.
+ *
+ * @param assignId Assignment id.
+ * @param groupId Group id. If not defined, 0.
+ * @param options Other options.
+ * @return Promise resolved with the list of participants and summary of submissions.
+ */
+ async listParticipants(
+ assignId: number,
+ groupId?: number,
+ options: CoreCourseCommonModWSOptions = {},
+ ): Promise {
+
+ groupId = groupId || 0;
+
+ const site = await CoreSites.instance.getSite(options.siteId);
+ if (!site.wsAvailable('mod_assign_list_participants')) {
+ // Silently fail if is not available. (needs Moodle version >= 3.2)
+ throw new CoreError('mod_assign_list_participants WS is only available 3.2 onwards');
+ }
+
+ const params: AddonModAssignListParticipantsWSParams = {
+ assignid: assignId,
+ groupid: groupId,
+ filter: '',
+ };
+
+ const preSets: CoreSiteWSPreSets = {
+ cacheKey: this.listParticipantsCacheKey(assignId, groupId),
+ updateFrequency: CoreSite.FREQUENCY_OFTEN,
+ component: AddonModAssignProvider.COMPONENT,
+ componentId: options.cmId,
+ ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy),
+ };
+
+ return site.read('mod_assign_list_participants', params, preSets);
+ }
+
+ /**
+ * Get cache key for assignment list participants data WS calls.
+ *
+ * @param assignId Assignment id.
+ * @param groupId Group id.
+ * @return Cache key.
+ */
+ protected listParticipantsCacheKey(assignId: number, groupId: number): string {
+ return this.listParticipantsPrefixCacheKey(assignId) + ':' + groupId;
+ }
+
+ /**
+ * Get prefix cache key for assignment list participants data WS calls.
+ *
+ * @param assignId Assignment id.
+ * @return Cache key.
+ */
+ protected listParticipantsPrefixCacheKey(assignId: number): string {
+ return ROOT_CACHE_KEY + 'participants:' + assignId;
+ }
+
+ /**
+ * Invalidates all submission status data.
+ *
+ * @param assignId Assignment instance id.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when the data is invalidated.
+ */
+ async invalidateAllSubmissionData(assignId: number, siteId?: string): Promise {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ await site.invalidateWsCacheForKeyStartingWith(this.getSubmissionsCacheKey(assignId));
+ }
+
+ /**
+ * Invalidates assignment data WS calls.
+ *
+ * @param courseId Course ID.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when the data is invalidated.
+ */
+ async invalidateAssignmentData(courseId: number, siteId?: string): Promise {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ await site.invalidateWsCacheForKey(this.getAssignmentCacheKey(courseId));
+ }
+
+ /**
+ * Invalidates assignment user mappings data WS calls.
+ *
+ * @param assignId Assignment ID.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when the data is invalidated.
+ */
+ async invalidateAssignmentUserMappingsData(assignId: number, siteId?: string): Promise {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ await site.invalidateWsCacheForKey(this.getAssignmentUserMappingsCacheKey(assignId));
+ }
+
+ /**
+ * Invalidates assignment grades data WS calls.
+ *
+ * @param assignId Assignment ID.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when the data is invalidated.
+ */
+ async invalidateAssignmentGradesData(assignId: number, siteId?: string): Promise {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ await site.invalidateWsCacheForKey(this.getAssignmentGradesCacheKey(assignId));
+ }
+
+ /**
+ * Invalidate the prefetched content except files.
+ * To invalidate files, use AddonModAssignProvider.invalidateFiles.
+ *
+ * @param moduleId The module ID.
+ * @param courseId Course ID.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when the data is invalidated.
+ */
+ async invalidateContent(moduleId: number, courseId: number, siteId?: string): Promise {
+ siteId = siteId || CoreSites.instance.getCurrentSiteId();
+
+ const assign = await this.getAssignment(courseId, moduleId, { siteId });
+ const promises: Promise[] = [];
+ // Do not invalidate assignment data before getting assignment info, we need it!
+ promises.push(this.invalidateAllSubmissionData(assign.id, siteId));
+ promises.push(this.invalidateAssignmentUserMappingsData(assign.id, siteId));
+ promises.push(this.invalidateAssignmentGradesData(assign.id, siteId));
+ promises.push(this.invalidateListParticipantsData(assign.id, siteId));
+ // @todo promises.push(CoreComments.instance.invalidateCommentsByInstance('module', assign.id, siteId));
+ promises.push(this.invalidateAssignmentData(courseId, siteId));
+ promises.push(CoreGrades.instance.invalidateAllCourseGradesData(courseId));
+
+
+ await Promise.all(promises);
+ }
+
+ /**
+ * Invalidate the prefetched files.
+ *
+ * @param moduleId The module ID.
+ * @return Promise resolved when the files are invalidated.
+ */
+ async invalidateFiles(moduleId: number): Promise {
+ await CoreFilepool.instance.invalidateFilesByComponent(
+ CoreSites.instance.getCurrentSiteId(),
+ AddonModAssignProvider.COMPONENT,
+ moduleId,
+ );
+ }
+
+ /**
+ * Invalidates assignment submissions data WS calls.
+ *
+ * @param assignId Assignment instance id.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when the data is invalidated.
+ */
+ async invalidateSubmissionData(assignId: number, siteId?: string): Promise {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ await site.invalidateWsCacheForKey(this.getSubmissionsCacheKey(assignId));
+ }
+
+ /**
+ * Invalidates submission status data.
+ *
+ * @param assignId Assignment instance id.
+ * @param userId User id (empty for current user).
+ * @param groupId Group Id (empty for all participants).
+ * @param isBlind Whether blind marking is enabled or not.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when the data is invalidated.
+ */
+ async invalidateSubmissionStatusData(
+ assignId: number,
+ userId?: number,
+ groupId?: number,
+ isBlind = false,
+ siteId?: string,
+ ): Promise {
+ const site = await CoreSites.instance.getSite(siteId);
+ const fixedParams = this.fixSubmissionStatusParams(site, userId, groupId, isBlind);
+
+ await site.invalidateWsCacheForKey(this.getSubmissionStatusCacheKey(
+ assignId,
+ fixedParams.userId,
+ fixedParams.groupId,
+ fixedParams.isBlind,
+ ));
+ }
+
+ /**
+ * Invalidates assignment participants data.
+ *
+ * @param assignId Assignment instance id.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when the data is invalidated.
+ */
+ async invalidateListParticipantsData(assignId: number, siteId?: string): Promise {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ await site.invalidateWsCacheForKeyStartingWith(this.listParticipantsPrefixCacheKey(assignId));
+ }
+
+ /**
+ * Convenience function to check if grading offline is enabled.
+ *
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved with boolean: whether grading offline is enabled.
+ */
+ protected async isGradingOfflineEnabled(siteId?: string): Promise {
+ siteId = siteId || CoreSites.instance.getCurrentSiteId();
+
+ if (typeof this.gradingOfflineEnabled[siteId] != 'undefined') {
+ return this.gradingOfflineEnabled[siteId];
+ }
+
+ this.gradingOfflineEnabled[siteId] = await CoreGrades.instance.isGradeItemsAvalaible(siteId);
+
+ return this.gradingOfflineEnabled[siteId];
+ }
+
+ /**
+ * Outcomes only can be edited if mod_assign_submit_grading_form is avalaible.
+ *
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved with true if outcomes edit is enabled, rejected or resolved with false otherwise.
+ * @since 3.2
+ */
+ async isOutcomesEditEnabled(siteId?: string): Promise {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ return site.wsAvailable('mod_assign_submit_grading_form');
+ }
+
+ /**
+ * Check if assignments plugin is enabled in a certain site.
+ *
+ * @param siteId Site ID. If not defined, current site.
+ * @return Whether the plugin is enabled.
+ */
+ isPluginEnabled(): boolean {
+ return true;
+ }
+
+ /**
+ * Check if a submission is open. This function is based on Moodle's submissions_open.
+ *
+ * @param assign Assignment instance.
+ * @param submissionStatus Submission status returned by getSubmissionStatus.
+ * @return Whether submission is open.
+ */
+ isSubmissionOpen(assign: AddonModAssignAssign, submissionStatus?: AddonModAssignGetSubmissionStatusWSResponse): boolean {
+ if (!assign || !submissionStatus) {
+ return false;
+ }
+
+ const time = CoreTimeUtils.instance.timestamp();
+ const lastAttempt = submissionStatus.lastattempt;
+ const submission = this.getSubmissionObjectFromAttempt(assign, lastAttempt);
+
+ let dateOpen = true;
+ let finalDate: number | undefined;
+
+ if (assign.cutoffdate) {
+ finalDate = assign.cutoffdate;
+ }
+
+ if (lastAttempt && lastAttempt.locked) {
+ return false;
+ }
+
+ // User extensions.
+ if (finalDate) {
+ if (lastAttempt && lastAttempt.extensionduedate) {
+ // Extension can be before cut off date.
+ if (lastAttempt.extensionduedate > finalDate) {
+ finalDate = lastAttempt.extensionduedate;
+ }
+ }
+ }
+
+ if (finalDate) {
+ dateOpen = assign.allowsubmissionsfromdate <= time && time <= finalDate;
+ } else {
+ dateOpen = assign.allowsubmissionsfromdate <= time;
+ }
+
+ if (!dateOpen) {
+ return false;
+ }
+
+ if (submission) {
+ if (assign.submissiondrafts && submission.status == AddonModAssignProvider.SUBMISSION_STATUS_SUBMITTED) {
+ // Drafts are tracked and the student has submitted the assignment.
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Report an assignment submission as being viewed.
+ *
+ * @param assignid Assignment ID.
+ * @param name Name of the assign.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when the WS call is successful.
+ */
+ async logSubmissionView(assignid: number, name?: string, siteId?: string): Promise {
+ const params: AddonModAssignViewSubmissionStatusWSParams = {
+ assignid,
+ };
+
+ await CoreCourseLogHelper.instance.logSingle(
+ 'mod_assign_view_submission_status',
+ params,
+ AddonModAssignProvider.COMPONENT,
+ assignid,
+ name,
+ 'assign',
+ {},
+ siteId,
+ );
+ }
+
+ /**
+ * Report an assignment grading table is being viewed.
+ *
+ * @param assignid Assignment ID.
+ * @param name Name of the assign.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when the WS call is successful.
+ */
+ async logGradingView(assignid: number, name?: string, siteId?: string): Promise {
+ const params: AddonModAssignViewGradingTableWSParams = {
+ assignid,
+ };
+
+ await CoreCourseLogHelper.instance.logSingle(
+ 'mod_assign_view_grading_table',
+ params,
+ AddonModAssignProvider.COMPONENT,
+ assignid,
+ name,
+ 'assign',
+ {},
+ siteId,
+ );
+ }
+
+ /**
+ * Report an assign as being viewed.
+ *
+ * @param assignid Assignment ID.
+ * @param name Name of the assign.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when the WS call is successful.
+ */
+ async logView(assignid: number, name?: string, siteId?: string): Promise {
+ const params: AddonModAssignViewAssignWSParams = {
+ assignid,
+ };
+
+ await CoreCourseLogHelper.instance.logSingle(
+ 'mod_assign_view_assign',
+ params,
+ AddonModAssignProvider.COMPONENT,
+ assignid,
+ name,
+ 'assign',
+ {},
+ siteId,
+ );
+ }
+
+ /**
+ * Returns if a submissions needs to be graded.
+ *
+ * @param submission Submission.
+ * @param assignId Assignment ID.
+ * @return Promise resolved with boolean: whether it needs to be graded or not.
+ */
+ async needsSubmissionToBeGraded(submission: any, assignId: number): Promise {
+ if (!submission.gradingstatus) {
+ // This should not happen, but it's better to show rather than not showing any of the submissions.
+ return true;
+ }
+
+ if (submission.gradingstatus != AddonModAssignProvider.GRADING_STATUS_GRADED &&
+ submission.gradingstatus != AddonModAssignProvider.MARKING_WORKFLOW_STATE_RELEASED) {
+ // Not graded.
+ return true;
+ }
+
+ // We need more data to decide that.
+ const response = await this.getSubmissionStatus(assignId, {
+ userId: submission.submitid,
+ isBlind: !!submission.blindid,
+ });
+
+ if (!response.feedback || !response.feedback.gradeddate) {
+ // Not graded.
+ return true;
+ }
+
+ return response.feedback.gradeddate < submission.timemodified;
+ }
+
+ /**
+ * Save current user submission for a certain assignment.
+ *
+ * @param assignId Assign ID.
+ * @param courseId Course ID the assign belongs to.
+ * @param pluginData Data to save.
+ * @param allowOffline Whether to allow offline usage.
+ * @param timemodified The time the submission was last modified in online.
+ * @param allowsDrafts Whether the assignment allows submission drafts.
+ * @param userId User ID. If not defined, site's current user.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved with true if sent to server, resolved with false if stored in offline.
+ */
+ async saveSubmission(
+ assignId: number,
+ courseId: number,
+ pluginData: AddonModAssignSavePluginData,
+ allowOffline: boolean,
+ timemodified: number,
+ allowsDrafts = false,
+ userId?: number,
+ siteId?: string,
+ ): Promise {
+
+ siteId = siteId || CoreSites.instance.getCurrentSiteId();
+
+ // Function to store the submission to be synchronized later.
+ const storeOffline = async (): Promise => {
+ await AddonModAssignOffline.instance.saveSubmission(
+ assignId,
+ courseId,
+ pluginData,
+ timemodified,
+ !allowsDrafts,
+ userId,
+ siteId,
+ );
+
+ return false;
+ };
+
+ if (allowOffline && !CoreApp.instance.isOnline()) {
+ // App is offline, store the action.
+ return storeOffline();
+ }
+
+ try {
+ // If there's already a submission to be sent to the server, discard it first.
+ await AddonModAssignOffline.instance.deleteSubmission(assignId, userId, siteId);
+ await this.saveSubmissionOnline(assignId, pluginData, siteId);
+
+ return true;
+ } catch (error) {
+ if (allowOffline && error && !CoreUtils.instance.isWebServiceError(error)) {
+ // Couldn't connect to server, store in offline.
+ return storeOffline();
+ } else {
+ // The WebService has thrown an error or offline not supported, reject.
+ throw error;
+ }
+ }
+ }
+
+ /**
+ * Save current user submission for a certain assignment. It will fail if offline or cannot connect.
+ *
+ * @param assignId Assign ID.
+ * @param pluginData Data to save.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when saved, rejected otherwise.
+ */
+ async saveSubmissionOnline(assignId: number, pluginData: AddonModAssignSavePluginData, siteId?: string): Promise {
+ const site = await CoreSites.instance.getSite(siteId);
+ const params: AddonModAssignSaveSubmissionWSParams = {
+ assignmentid: assignId,
+ plugindata: pluginData,
+ };
+ const warnings = await site.write('mod_assign_save_submission', params);
+
+ if (warnings.length) {
+ // The WebService returned warnings, reject.
+ throw warnings[0];
+ }
+ }
+
+ /**
+ * Submit the current user assignment for grading.
+ *
+ * @param assignId Assign ID.
+ * @param courseId Course ID the assign belongs to.
+ * @param acceptStatement True if submission statement is accepted, false otherwise.
+ * @param timemodified The time the submission was last modified in online.
+ * @param forceOffline True to always mark it in offline.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved with true if sent to server, resolved with false if stored in offline.
+ */
+ async submitForGrading(
+ assignId: number,
+ courseId: number,
+ acceptStatement: boolean,
+ timemodified: number,
+ forceOffline = false,
+ siteId?: string,
+ ): Promise {
+
+ siteId = siteId || CoreSites.instance.getCurrentSiteId();
+
+ // Function to store the submission to be synchronized later.
+ const storeOffline = async (): Promise => {
+ await AddonModAssignOffline.instance.markSubmitted(
+ assignId,
+ courseId,
+ true,
+ acceptStatement,
+ timemodified,
+ undefined,
+ siteId,
+ );
+
+ return false;
+ };
+
+ if (forceOffline || !CoreApp.instance.isOnline()) {
+ // App is offline, store the action.
+ return storeOffline();
+ }
+
+ try {
+ // If there's already a submission to be sent to the server, discard it first.
+ await AddonModAssignOffline.instance.deleteSubmission(assignId, undefined, siteId);
+ await this.submitForGradingOnline(assignId, acceptStatement, siteId);
+
+ return true;
+ } catch (error) {
+ if (error && !CoreUtils.instance.isWebServiceError(error)) {
+ // Couldn't connect to server, store in offline.
+ return storeOffline();
+ } else {
+ // The WebService has thrown an error, reject.
+ throw error;
+ }
+ }
+ }
+
+ /**
+ * Submit the current user assignment for grading. It will fail if offline or cannot connect.
+ *
+ * @param assignId Assign ID.
+ * @param acceptStatement True if submission statement is accepted, false otherwise.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when submitted, rejected otherwise.
+ */
+ async submitForGradingOnline(assignId: number, acceptStatement: boolean, siteId?: string): Promise {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ const params: AddonModAssignSubmitForGradingWSParams = {
+ assignmentid: assignId,
+ acceptsubmissionstatement: acceptStatement,
+ };
+
+ const warnings = await site.write('mod_assign_submit_for_grading', params);
+
+ if (warnings.length) {
+ // The WebService returned warnings, reject.
+ throw warnings[0];
+ }
+ }
+
+ /**
+ * Submit the grading for the current user and assignment. It will use old or new WS depending on availability.
+ *
+ * @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 Feedback plugin data to save.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved with true if sent to server, resolved with false if stored offline.
+ */
+ 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 {
+
+ siteId = siteId || CoreSites.instance.getCurrentSiteId();
+
+ // Function to store the grading to be synchronized later.
+ const storeOffline = async (): Promise => {
+ await AddonModAssignOffline.instance.submitGradingForm(
+ assignId,
+ userId,
+ courseId,
+ grade,
+ attemptNumber,
+ addAttempt,
+ workflowState,
+ applyToAll,
+ outcomes,
+ pluginData,
+ siteId,
+ );
+
+ return false;
+ };
+
+ // Grading offline is only allowed if WS of grade items is enabled to avoid inconsistency.
+ const enabled = await this.isGradingOfflineEnabled(siteId);
+ if (!enabled) {
+ await this.submitGradingFormOnline(
+ assignId,
+ userId,
+ grade,
+ attemptNumber,
+ addAttempt,
+ workflowState,
+ applyToAll,
+ outcomes,
+ pluginData,
+ siteId,
+ );
+
+ return true;
+ }
+
+ if (!CoreApp.instance.isOnline()) {
+ // App is offline, store the action.
+ return storeOffline();
+ }
+
+ try {
+ // If there's already a grade to be sent to the server, discard it first.
+ await AddonModAssignOffline.instance.deleteSubmissionGrade(assignId, userId, siteId);
+ await this.submitGradingFormOnline(
+ assignId,
+ userId,
+ grade,
+ attemptNumber,
+ addAttempt,
+ workflowState,
+ applyToAll,
+ outcomes,
+ pluginData,
+ siteId,
+ );
+
+ return true;
+ } catch (error) {
+ if (error && !CoreUtils.instance.isWebServiceError(error)) {
+ // Couldn't connect to server, store in offline.
+ return storeOffline();
+ } else {
+ // The WebService has thrown an error, reject.
+ throw error;
+ }
+ }
+ }
+
+ /**
+ * Submit the grading for the current user and assignment. It will use old or new WS depending on availability.
+ * It will fail if offline or cannot connect.
+ *
+ * @param assignId Assign ID.
+ * @param userId User ID.
+ * @param grade Grade to submit.
+ * @param attemptNumber Number of the attempt being graded.
+ * @param addAttempt Allow the user to attempt again.
+ * @param workflowState Next workflow State.
+ * @param applyToAll If it's a team submission, if the grade applies to all group members.
+ * @param outcomes Object including all outcomes values. If empty, any of them will be sent.
+ * @param pluginData Feedback plugin data to save.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when submitted, rejected otherwise.
+ */
+ async submitGradingFormOnline(
+ assignId: number,
+ userId: number,
+ grade: number | undefined,
+ attemptNumber: number,
+ addAttempt: boolean,
+ workflowState: string,
+ applyToAll: boolean,
+ outcomes: AddonModAssignOutcomes,
+ pluginData: AddonModAssignSavePluginData,
+ siteId?: string,
+ ): Promise {
+
+ const site = await CoreSites.instance.getSite(siteId);
+ userId = userId || site.getUserId();
+
+ if (site.wsAvailable('mod_assign_submit_grading_form')) {
+ // WS available @since 3.2.
+
+ const jsonData = {
+ grade,
+ attemptnumber: attemptNumber,
+ addattempt: addAttempt ? 1 : 0,
+ workflowstate: workflowState,
+ applytoall: applyToAll ? 1 : 0,
+ };
+
+ for (const index in outcomes) {
+ jsonData['outcome_' + index + '[' + userId + ']'] = outcomes[index];
+ }
+
+ for (const index in pluginData) {
+ jsonData[index] = pluginData[index];
+ }
+
+ const serialized = CoreInterceptor.serialize(jsonData, true);
+ const params: AddonModAssignSubmitGradingFormWSParams = {
+ assignmentid: assignId,
+ userid: userId,
+ jsonformdata: JSON.stringify(serialized),
+ };
+
+ const warnings = await site.write('mod_assign_submit_grading_form', params);
+
+ if (warnings.length) {
+ // The WebService returned warnings, reject.
+ throw warnings[0];
+ }
+ }
+
+ // WS not available, fallback to save_grade.
+ const params: AddonModAssignSaveGradeWSParams = {
+ assignmentid: assignId,
+ userid: userId,
+ grade: grade,
+ attemptnumber: attemptNumber,
+ addattempt: addAttempt,
+ workflowstate: workflowState,
+ applytoall: applyToAll,
+ plugindata: pluginData,
+ };
+ const preSets: CoreSiteWSPreSets = {
+ responseExpected: false,
+ };
+
+ await site.write('mod_assign_save_grade', params, preSets);
+ }
+
+}
+export const AddonModAssign = makeSingleton(AddonModAssignProvider);
+
+/**
+ * Options to pass to get submission status.
+ */
+export type AddonModAssignSubmissionStatusOptions = CoreCourseCommonModWSOptions & {
+ userId?: number; // User Id (empty for current user).
+ groupId?: number; // Group Id (empty for all participants).
+ isBlind?: boolean; // If blind marking is enabled or not.
+ filter?: boolean; // True to filter WS response and rewrite URLs, false otherwise. Defaults to true.
+};
+
+/**
+ * Assign data returned by mod_assign_get_assignments.
+ */
+export type AddonModAssignAssign = {
+ id: number; // Assignment id.
+ cmid: number; // Course module id.
+ course: number; // Course id.
+ name: string; // Assignment name.
+ nosubmissions: number; // No submissions.
+ submissiondrafts: number; // Submissions drafts.
+ sendnotifications: number; // Send notifications.
+ sendlatenotifications: number; // Send notifications.
+ sendstudentnotifications: number; // Send student notifications (default).
+ duedate: number; // Assignment due date.
+ allowsubmissionsfromdate: number; // Allow submissions from date.
+ grade: number; // Grade type.
+ timemodified: number; // Last time assignment was modified.
+ completionsubmit: number; // If enabled, set activity as complete following submission.
+ cutoffdate: number; // Date after which submission is not accepted without an extension.
+ gradingduedate?: number; // @since 3.3. The expected date for marking the submissions.
+ teamsubmission: number; // If enabled, students submit as a team.
+ requireallteammemberssubmit: number; // If enabled, all team members must submit.
+ teamsubmissiongroupingid: number; // The grouping id for the team submission groups.
+ blindmarking: number; // If enabled, hide identities until reveal identities actioned.
+ hidegrader?: number; // @since 3.7. If enabled, hide grader to student.
+ revealidentities: number; // Show identities for a blind marking assignment.
+ attemptreopenmethod: string; // Method used to control opening new attempts.
+ maxattempts: number; // Maximum number of attempts allowed.
+ markingworkflow: number; // Enable marking workflow.
+ markingallocation: number; // Enable marking allocation.
+ requiresubmissionstatement: number; // Student must accept submission statement.
+ preventsubmissionnotingroup?: number; // @since 3.2. Prevent submission not in group.
+ submissionstatement?: string; // @since 3.2. Submission statement formatted.
+ submissionstatementformat?: number; // @since 3.2. Submissionstatement format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN).
+ configs: AddonModAssignConfig[]; // Configuration settings.
+ intro?: string; // Assignment intro, not allways returned because it deppends on the activity configuration.
+ introformat?: number; // Intro format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN).
+ introfiles?: CoreWSExternalFile[]; // @since 3.2.
+ introattachments?: CoreWSExternalFile[];
+};
+
+/**
+ * Config setting in an assign.
+ */
+export type AddonModAssignConfig = {
+ id?: number; // Assign_plugin_config id.
+ assignment?: number; // Assignment id.
+ plugin: string; // Plugin.
+ subtype: string; // Subtype.
+ name: string; // Name.
+ value: string; // Value.
+};
+
+/**
+ * Grade of an assign, returned by mod_assign_get_grades.
+ */
+export type AddonModAssignGrade = {
+ id: number; // Grade id.
+ assignment?: number; // Assignment id.
+ userid: number; // Student id.
+ attemptnumber: number; // Attempt number.
+ timecreated: number; // Grade creation time.
+ timemodified: number; // Grade last modified time.
+ grader: number; // Grader, -1 if grader is hidden.
+ grade: string; // Grade.
+ gradefordisplay?: string; // Grade rendered into a format suitable for display.
+};
+
+/**
+ * Assign submission returned by mod_assign_get_submissions.
+ */
+export type AddonModAssignSubmission = {
+ id: number; // Submission id.
+ userid: number; // Student id.
+ attemptnumber: number; // Attempt number.
+ timecreated: number; // Submission creation time.
+ timemodified: number; // Submission last modified time.
+ status: string; // Submission status.
+ groupid: number; // Group id.
+ assignment?: number; // Assignment id.
+ latest?: number; // Latest attempt.
+ plugins?: AddonModAssignPlugin[]; // Plugins.
+ gradingstatus?: string; // @since 3.2. Grading status.
+};
+
+/**
+ * Assign plugin.
+ */
+export type AddonModAssignPlugin = {
+ type: string; // Submission plugin type.
+ name: string; // Submission plugin name.
+ fileareas?: { // Fileareas.
+ area: string; // File area.
+ files?: CoreWSExternalFile[];
+ }[];
+ editorfields?: { // Editorfields.
+ name: string; // Field name.
+ description: string; // Field description.
+ text: string; // Field value.
+ format: number; // Text format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN).
+ }[];
+};
+
+/**
+ * Grading summary of an assign submission.
+ */
+export type AddonModAssignSubmissionGradingSummary = {
+ participantcount: number; // Number of users who can submit.
+ submissiondraftscount: number; // Number of submissions in draft status.
+ submissionsenabled: boolean; // Whether submissions are enabled or not.
+ submissionssubmittedcount: number; // Number of submissions in submitted status.
+ submissionsneedgradingcount: number; // Number of submissions that need grading.
+ warnofungroupedusers: string | boolean; // Whether we need to warn people about groups.
+};
+
+/**
+ * Attempt of an assign submission.
+ */
+export type AddonModAssignSubmissionAttempt = {
+ submission?: AddonModAssignSubmission; // Submission info.
+ teamsubmission?: AddonModAssignSubmission; // Submission info.
+ submissiongroup?: number; // The submission group id (for group submissions only).
+ submissiongroupmemberswhoneedtosubmit?: number[]; // List of users who still need to submit (for group submissions only).
+ submissionsenabled: boolean; // Whether submissions are enabled or not.
+ locked: boolean; // Whether new submissions are locked.
+ graded: boolean; // Whether the submission is graded.
+ canedit: boolean; // Whether the user can edit the current submission.
+ caneditowner?: boolean; // @since 3.2. Whether the owner of the submission can edit it.
+ cansubmit: boolean; // Whether the user can submit.
+ extensionduedate: number; // Extension due date.
+ blindmarking: boolean; // Whether blind marking is enabled.
+ gradingstatus: string; // Grading status.
+ usergroups: number[]; // User groups in the course.
+};
+
+/**
+ * Previous attempt of an assign submission.
+ */
+export type AddonModAssignSubmissionPreviousAttempt = {
+ attemptnumber: number; // Attempt number.
+ submission?: AddonModAssignSubmission; // Submission info.
+ grade?: AddonModAssignGrade; // Grade information.
+ feedbackplugins?: AddonModAssignPlugin[]; // Feedback info.
+};
+
+/**
+ * Feedback of an assign submission.
+ */
+export type AddonModAssignSubmissionFeedback = {
+ grade: AddonModAssignGrade; // Grade information.
+ gradefordisplay: string; // Grade rendered into a format suitable for display.
+ gradeddate: number; // The date the user was graded.
+ plugins?: AddonModAssignPlugin[]; // Plugins info.
+};
+
+
+/**
+ * Params of mod_assign_list_participants WS.
+ */
+type AddonModAssignListParticipantsWSParams = {
+ assignid: number; // Assign instance id.
+ groupid: number; // Group id.
+ filter: string; // Search string to filter the results.
+ skip?: number; // Number of records to skip.
+ limit?: number; // Maximum number of records to return.
+ onlyids?: boolean; // Do not return all user fields.
+ includeenrolments?: boolean; // Do return courses where the user is enrolled.
+ tablesort?: boolean; // Apply current user table sorting preferences.
+};
+
+/**
+ * Data returned by mod_assign_list_participants WS.
+ */
+type AddonModAssignListParticipantsWSResponse = AddonModAssignParticipant[];
+
+/**
+ * Participant returned by mod_assign_list_participants.
+ */
+export type AddonModAssignParticipant = {
+ id: number; // ID of the user.
+ username?: string; // The username.
+ firstname?: string; // The first name(s) of the user.
+ lastname?: string; // The family name of the user.
+ fullname: string; // The fullname of the user.
+ email?: string; // Email address.
+ address?: string; // Postal address.
+ phone1?: string; // Phone 1.
+ phone2?: string; // Phone 2.
+ icq?: string; // Icq number.
+ skype?: string; // Skype id.
+ yahoo?: string; // Yahoo id.
+ aim?: string; // Aim id.
+ msn?: string; // Msn number.
+ department?: string; // Department.
+ institution?: string; // Institution.
+ idnumber?: string; // The idnumber of the user.
+ interests?: string; // User interests (separated by commas).
+ firstaccess?: number; // First access to the site (0 if never).
+ lastaccess?: number; // Last access to the site (0 if never).
+ suspended?: boolean; // @since 3.2. Suspend user account, either false to enable user login or true to disable it.
+ description?: string; // User profile description.
+ descriptionformat?: number; // Int format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN).
+ city?: string; // Home city of the user.
+ url?: string; // URL of the user.
+ country?: string; // Home country code of the user, such as AU or CZ.
+ profileimageurlsmall?: string; // User image profile URL - small version.
+ profileimageurl?: string; // User image profile URL - big version.
+ customfields?: { // User custom fields (also known as user profile fields).
+ type: string; // The type of the custom field - text field, checkbox...
+ value: string; // The value of the custom field.
+ name: string; // The name of the custom field.
+ shortname: string; // The shortname of the custom field - to be able to build the field class in the code.
+ }[];
+ preferences?: { // Users preferences.
+ name: string; // The name of the preferences.
+ value: string; // The value of the preference.
+ }[];
+ recordid?: number; // @since 3.7. Record id.
+ groups?: { // User groups.
+ id: number; // Group id.
+ name: string; // Group name.
+ description: string; // Group description.
+ }[];
+ roles?: { // User roles.
+ roleid: number; // Role id.
+ name: string; // Role name.
+ shortname: string; // Role shortname.
+ sortorder: number; // Role sortorder.
+ }[];
+ enrolledcourses?: { // Courses where the user is enrolled - limited by which courses the user is able to see.
+ id: number; // Id of the course.
+ fullname: string; // Fullname of the course.
+ shortname: string; // Shortname of the course.
+ }[];
+ submitted: boolean; // Have they submitted their assignment.
+ requiregrading: boolean; // Is their submission waiting for grading.
+ grantedextension?: boolean; // @since 3.3. Have they been granted an extension.
+ groupid?: number; // For group assignments this is the group id.
+ groupname?: string; // For group assignments this is the group name.
+};
+
+/**
+ * Result of WS mod_assign_get_assignments.
+ */
+export type AddonModAssignGetAssignmentsWSResponse = {
+ courses: { // List of courses.
+ id: number; // Course id.
+ fullname: string; // Course full name.
+ shortname: string; // Course short name.
+ timemodified: number; // Last time modified.
+ assignments: AddonModAssignAssign[]; // Assignment info.
+ }[];
+ warnings?: CoreWSExternalWarning[];
+};
+
+/**
+ * Params of mod_assign_get_submissions WS.
+ */
+type ModAssignGetSubmissionsWSParams = {
+ assignmentids: number[]; // 1 or more assignment ids.
+ status?: string; // Status.
+ since?: number; // Submitted since.
+ before?: number; // Submitted before.
+};
+
+/**
+ * Data returned by mod_assign_get_submissions WS.
+ */
+export type AddonModAssignGetSubmissionsWSResponse = {
+ assignments: { // Assignment submissions.
+ assignmentid: number; // Assignment id.
+ submissions: AddonModAssignSubmission[];
+ }[];
+ warnings?: CoreWSExternalWarning[];
+};
+
+/**
+ * Params of mod_assign_get_submission_status WS.
+ */
+type AddonModAssignGetSubmissionStatusWSParams = {
+ assignid: number; // Assignment instance id.
+ userid?: number; // User id (empty for current user).
+ groupid?: number; // Filter by users in group (used for generating the grading summary). Empty or 0 for all groups information.
+};
+
+
+/**
+ * Result of WS mod_assign_get_submission_status.
+ */
+export type AddonModAssignGetSubmissionStatusWSResponse = {
+ gradingsummary?: AddonModAssignSubmissionGradingSummary; // Grading information.
+ lastattempt?: AddonModAssignSubmissionAttempt; // Last attempt information.
+ feedback?: AddonModAssignSubmissionFeedback; // Feedback for the last attempt.
+ previousattempts?: AddonModAssignSubmissionPreviousAttempt[]; // List all the previous attempts did by the user.
+ warnings?: CoreWSExternalWarning[];
+};
+
+/**
+ * Params of mod_assign_view_submission_status WS.
+ */
+type AddonModAssignViewSubmissionStatusWSParams = {
+ assignid: number; // Assign instance id.
+};
+
+/**
+ * Params of mod_assign_view_grading_table WS.
+ */
+type AddonModAssignViewGradingTableWSParams = {
+ assignid: number; // Assign instance id.
+};
+
+/**
+ * Params of mod_assign_view_assign WS.
+ */
+type AddonModAssignViewAssignWSParams = {
+ assignid: number; // Assign instance id.
+};
+
+type AddonModAssignFixedSubmissionParams = {
+ userId: number;
+ groupId: number;
+ isBlind: boolean;
+};
+
+/**
+ * Params of mod_assign_get_assignments WS.
+ */
+type AddonModAssignGetAssignmentsWSParams = {
+ courseids?: number[]; // 0 or more course ids.
+ capabilities?: string[]; // List of capabilities used to filter courses.
+ includenotenrolledcourses?: boolean; // Whether to return courses that the user can see even if is not enroled in.
+ // This requires the parameter courseids to not be empty.
+
+};
+
+/**
+ * Params of mod_assign_get_user_mappings WS.
+ */
+type AddonModAssignGetUserMappingsWSParams = {
+ assignmentids: number[]; // 1 or more assignment ids.
+};
+
+/**
+ * Data returned by mod_assign_get_user_mappings WS.
+ */
+export type AddonModAssignGetUserMappingsWSResponse = {
+ assignments: { // List of assign user mapping data.
+ assignmentid: number; // Assignment id.
+ mappings: {
+ id: number; // User mapping id.
+ userid: number; // Student id.
+ }[];
+ }[];
+ warnings?: CoreWSExternalWarning[];
+};
+
+/**
+ * Params of mod_assign_get_grades WS.
+ */
+type AddonModAssignGetGradesWSParams = {
+ assignmentids: number[]; // 1 or more assignment ids.
+ since?: number; // Timestamp, only return records where timemodified >= since.
+};
+
+/**
+ * Data returned by mod_assign_get_grades WS.
+ */
+export type AddonModAssignGetGradesWSResponse = {
+ assignments: { // List of assignment grade information.
+ assignmentid: number; // Assignment id.
+ grades: AddonModAssignGrade[];
+ }[];
+ warnings?: CoreWSExternalWarning[];
+};
+
+/**
+ * Params of mod_assign_save_submission WS.
+ */
+type AddonModAssignSaveSubmissionWSParams = {
+ assignmentid: number; // The assignment id to operate on.
+ plugindata: AddonModAssignSavePluginData;
+};
+
+/**
+ * All subplugins will decide what to add here.
+ */
+export type AddonModAssignSavePluginData = Record;
+
+/**
+ * Params of mod_assign_submit_for_grading WS.
+ */
+type AddonModAssignSubmitForGradingWSParams = {
+ assignmentid: number; // The assignment id to operate on.
+ acceptsubmissionstatement: boolean; // Accept the assignment submission statement.
+};
+
+/**
+ * Params of mod_assign_submit_grading_form WS.
+ */
+type AddonModAssignSubmitGradingFormWSParams = {
+ assignmentid: number; // The assignment id to operate on.
+ userid: number; // The user id the submission belongs to.
+ jsonformdata: string; // The data from the grading form, encoded as a json array.
+};
+
+
+/**
+ * Params of mod_assign_save_grade WS.
+ */
+type AddonModAssignSaveGradeWSParams = {
+ assignmentid: number; // The assignment id to operate on.
+ userid: number; // The student id to operate on.
+ grade: number; // The new grade for this user. Ignored if advanced grading used.
+ attemptnumber: number; // The attempt number (-1 means latest attempt).
+ addattempt: boolean; // Allow another attempt if the attempt reopen method is manual.
+ workflowstate: string; // The next marking workflow state.
+ applytoall: boolean; // If true, this grade will be applied to all members of the group (for group assignments).
+ plugindata?: AddonModAssignSavePluginData; // Plugin data.
+ advancedgradingdata?: {
+ guide?: {
+ criteria: {
+ criterionid: number; // Criterion id.
+ fillings?: { // Filling.
+ criterionid: number; // Criterion id.
+ levelid?: number; // Level id.
+ remark?: string; // Remark.
+ remarkformat?: number; // Remark format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN).
+ score: number; // Maximum score.
+ }[];
+ }[];
+ }; // Items.
+ rubric?: {
+ criteria: {
+ criterionid: number; // Criterion id.
+ fillings?: { // Filling.
+ criterionid: number; // Criterion id.
+ levelid?: number; // Level id.
+ remark?: string; // Remark.
+ remarkformat?: number; // Remark format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN).
+ }[];
+ }[];
+ }; // Items.
+ }; // Advanced grading data.
+};
+
+/**
+ * Assignment grade outcomes.
+ */
+export type AddonModAssignOutcomes = { [itemNumber: number]: number };
diff --git a/src/addons/mod/assign/services/database/assign.ts b/src/addons/mod/assign/services/database/assign.ts
new file mode 100644
index 000000000..703f3f27a
--- /dev/null
+++ b/src/addons/mod/assign/services/database/assign.ts
@@ -0,0 +1,150 @@
+// (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;
+};
diff --git a/src/addons/mod/assign/services/feedback-delegate.ts b/src/addons/mod/assign/services/feedback-delegate.ts
new file mode 100644
index 000000000..b21ca5f35
--- /dev/null
+++ b/src/addons/mod/assign/services/feedback-delegate.ts
@@ -0,0 +1,374 @@
+// (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 { CoreDelegate, CoreDelegateHandler } from '@classes/delegate';
+import { AddonModAssignDefaultFeedbackHandler } from './handlers/default-feedback';
+import { AddonModAssignAssign, AddonModAssignSubmission, AddonModAssignPlugin } from './assign';
+import { makeSingleton } from '@singletons';
+import { CoreWSExternalFile } from '@services/ws';
+
+/**
+ * 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;
+
+ /**
+ * 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): any | Promise;
+
+ /**
+ * 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): any | Promise;
+
+ /**
+ * 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;
+
+ /**
+ * 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: any,
+ userId: number,
+ ): boolean | Promise;
+
+ /**
+ * 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;
+
+ /**
+ * 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;
+
+ /**
+ * 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: any,
+ siteId?: string,
+ ): void | Promise;
+
+ /**
+ * 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: any, siteId?: string): void | Promise;
+}
+
+/**
+ * Delegate to register plugins for assign feedback.
+ */
+@Injectable({ providedIn: 'root' })
+export class AddonModAssignFeedbackDelegateService extends CoreDelegate {
+
+ 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 {
+ 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 {
+ 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(
+ assignId: number,
+ userId: number,
+ plugin: AddonModAssignPlugin,
+ siteId?: string,
+ ): Promise {
+ 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 {
+ 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,
+ plugin: AddonModAssignPlugin,
+ inputData: any,
+ userId: number,
+ ): Promise {
+ 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 {
+ 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 {
+ 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: any,
+ siteId?: string,
+ ): Promise {
+
+ 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: any,
+ siteId?: string,
+ ): Promise {
+ return await this.executeFunctionOnEnabled(
+ plugin.type,
+ 'saveDraft',
+ [assignId, userId, plugin, inputData, siteId],
+ );
+ }
+
+}
+export const AddonModAssignFeedbackDelegate = makeSingleton(AddonModAssignFeedbackDelegateService);
diff --git a/src/addons/mod/assign/services/handlers/default-feedback.ts b/src/addons/mod/assign/services/handlers/default-feedback.ts
new file mode 100644
index 000000000..5bfc76bd6
--- /dev/null
+++ b/src/addons/mod/assign/services/handlers/default-feedback.ts
@@ -0,0 +1,146 @@
+// (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 { 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 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(): void {
+ // Nothing to do.
+ }
+
+ /**
+ * Return the draft saved data of the feedback plugin.
+ *
+ * @return Data (or promise resolved with the data).
+ */
+ getDraft(): 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(): any[] {
+ 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 {
+ 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 {
+ 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.
+ }
+
+}
diff --git a/src/addons/mod/assign/services/handlers/default-submission.ts b/src/addons/mod/assign/services/handlers/default-submission.ts
new file mode 100644
index 000000000..a83032c1c
--- /dev/null
+++ b/src/addons/mod/assign/services/handlers/default-submission.ts
@@ -0,0 +1,210 @@
+// (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 { 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 {
+ 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.
+ }
+
+ /**
+ * 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(): 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(): any[] {
+ 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 {
+ 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 {
+ 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.
+ }
+
+}
diff --git a/src/addons/mod/assign/services/handlers/index-link.ts b/src/addons/mod/assign/services/handlers/index-link.ts
new file mode 100644
index 000000000..3162d8d15
--- /dev/null
+++ b/src/addons/mod/assign/services/handlers/index-link.ts
@@ -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);
diff --git a/src/addons/mod/assign/services/handlers/list-link.ts b/src/addons/mod/assign/services/handlers/list-link.ts
new file mode 100644
index 000000000..8778082b4
--- /dev/null
+++ b/src/addons/mod/assign/services/handlers/list-link.ts
@@ -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);
diff --git a/src/addons/mod/assign/services/handlers/module.ts b/src/addons/mod/assign/services/handlers/module.ts
new file mode 100644
index 000000000..bd74d4468
--- /dev/null
+++ b/src/addons/mod/assign/services/handlers/module.ts
@@ -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 {
+ 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 | undefined> {
+ return AddonModAssignIndexComponent;
+ }
+
+}
+export const AddonModAssignModuleHandler = makeSingleton(AddonModAssignModuleHandlerService);
diff --git a/src/addons/mod/assign/services/handlers/prefetch.ts b/src/addons/mod/assign/services/handlers/prefetch.ts
new file mode 100644
index 000000000..966e7e842
--- /dev/null
+++ b/src/addons/mod/assign/services/handlers/prefetch.ts
@@ -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 {
+ // 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 {
+ 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 {
+
+ 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[] = [];
+
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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[] = [];
+ 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 {
+ const modOptions: CoreCourseCommonModWSOptions = {
+ cmId: moduleId,
+ readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
+ siteId,
+ };
+
+ // Get submissions.
+ const submissions = await AddonModAssign.instance.getSubmissions(assign.id, modOptions);
+ const promises: Promise[] = [];
+
+ 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 {
+
+ 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[] = 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 {
+ const submission = await AddonModAssign.instance.getSubmissionStatusWithRetry(assign, options);
+ const siteId = options.siteId!;
+ const userId = options.userId;
+
+ try {
+ const promises: Promise[] = [];
+ 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 {
+ return AddonModAssignSync.instance.syncAssign(module.instance!, siteId);
+ }
+
+}
+export const AddonModAssignPrefetchHandler = makeSingleton(AddonModAssignPrefetchHandlerService);
diff --git a/src/addons/mod/assign/services/handlers/push-click.ts b/src/addons/mod/assign/services/handlers/push-click.ts
new file mode 100644
index 000000000..57a0e49b1
--- /dev/null
+++ b/src/addons/mod/assign/services/handlers/push-click.ts
@@ -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 {
+ 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 {
+ 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;
+};
diff --git a/src/addons/mod/assign/services/handlers/sync-cron.ts b/src/addons/mod/assign/services/handlers/sync-cron.ts
new file mode 100644
index 000000000..b0092185f
--- /dev/null
+++ b/src/addons/mod/assign/services/handlers/sync-cron.ts
@@ -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 {
+ 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);
diff --git a/src/addons/mod/assign/services/submission-delegate.ts b/src/addons/mod/assign/services/submission-delegate.ts
new file mode 100644
index 000000000..81394a312
--- /dev/null
+++ b/src/addons/mod/assign/services/submission-delegate.ts
@@ -0,0 +1,565 @@
+// (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 { CoreDelegate, CoreDelegateHandler } from '@classes/delegate';
+import { AddonModAssignDefaultSubmissionHandler } from './handlers/default-submission';
+import { AddonModAssignAssign, AddonModAssignSubmission, AddonModAssignPlugin } from './assign';
+import { makeSingleton } from '@singletons';
+import { CoreWSExternalFile } from '@services/ws';
+
+/**
+ * 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;
+
+ /**
+ * 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: any,
+ ): 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: any,
+ userId?: number,
+ siteId?: string,
+ ): void | Promise;
+
+ /**
+ * 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: any,
+ siteId?: string,
+ ): void | Promise;
+
+ /**
+ * 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,
+ ): any | Promise;
+
+ /**
+ * 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;
+
+ /**
+ * 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;
+
+ /**
+ * 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: any,
+ ): number | Promise;
+
+ /**
+ * 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: any,
+ ): boolean | Promise;
+
+ /**
+ * 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;
+
+ /**
+ * 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;
+
+ /**
+ * 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: any,
+ pluginData: any,
+ offline?: boolean,
+ userId?: number,
+ siteId?: string,
+ ): void | Promise;
+
+ /**
+ * 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: any,
+ pluginData: any,
+ siteId?: string,
+ ): void | Promise;
+}
+
+/**
+ * Delegate to register plugins for assign submission.
+ */
+@Injectable({ providedIn: 'root' })
+export class AddonModAssignSubmissionDelegateService extends CoreDelegate {
+
+ 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 {
+ 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: any,
+ ): 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: any,
+ userId?: number,
+ siteId?: string,
+ ): Promise {
+ 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: any,
+ siteId?: string,
+ ): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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: any,
+ ): Promise {
+ 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: any,
+ ): Promise {
+ 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 {
+ 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 {
+ 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: any,
+ pluginData: any,
+ offline?: boolean,
+ userId?: number,
+ siteId?: string,
+ ): Promise {
+
+ 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: any,
+ pluginData: any,
+ siteId?: string,
+ ): Promise {
+
+ return this.executeFunctionOnEnabled(
+ plugin.type,
+ 'prepareSyncData',
+ [assign, submission, plugin, offlineData, pluginData, siteId],
+ );
+ }
+
+}
+export class AddonModAssignSubmissionDelegate extends makeSingleton(AddonModAssignSubmissionDelegateService) {}
diff --git a/src/addons/mod/mod.module.ts b/src/addons/mod/mod.module.ts
index aafdf2906..9a8f91414 100644
--- a/src/addons/mod/mod.module.ts
+++ b/src/addons/mod/mod.module.ts
@@ -14,6 +14,7 @@
import { NgModule } from '@angular/core';
+import { AddonModAssignModule } from './assign/assign.module';
import { AddonModBookModule } from './book/book.module';
import { AddonModLessonModule } from './lesson/lesson.module';
import { AddonModPageModule } from './page/page.module';
@@ -21,6 +22,7 @@ import { AddonModPageModule } from './page/page.module';
@NgModule({
declarations: [],
imports: [
+ AddonModAssignModule,
AddonModBookModule,
AddonModLessonModule,
AddonModPageModule,
diff --git a/src/core/services/groups.ts b/src/core/services/groups.ts
index 39541f9df..46465f044 100644
--- a/src/core/services/groups.ts
+++ b/src/core/services/groups.ts
@@ -412,7 +412,7 @@ export class CoreGroupsProvider {
* @param groupInfo Group info.
* @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) {
// Check if the group is in the list of groups.
if (groupInfo.groups.some((group) => groupId == group.id)) {
diff --git a/src/core/services/navigator.ts b/src/core/services/navigator.ts
index 683a366a9..67a6291f3 100644
--- a/src/core/services/navigator.ts
+++ b/src/core/services/navigator.ts
@@ -380,6 +380,11 @@ export class CoreNavigatorService {
// IonTabs checks the URL to determine which path to load for deep linking, so we clear the URL.
// @todo this.location.replaceState('');
+ options = {
+ preferCurrentTab: true,
+ ...options,
+ };
+
path = path.replace(/^(\.|\/main)?\//, '');
const pathRoot = /^[^/]+/.exec(path)?.[0] ?? '';
@@ -389,7 +394,7 @@ export class CoreNavigatorService {
false,
);
- if (options.preferCurrentTab === false && isMainMenuTab) {
+ if (!options.preferCurrentTab && isMainMenuTab) {
return this.navigate(`/main/${path}`, options);
}