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); }