From 8e7b1482054eb59fe333e274f136b4cb92e7b365 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Wed, 31 Mar 2021 16:33:46 +0200 Subject: [PATCH] MOBILE-3657 workshop: Add workshop activity services --- .../mod/h5pactivity/services/h5pactivity.ts | 2 +- src/addons/mod/mod.module.ts | 2 + .../workshop/services/database/workshop.ts | 214 ++ .../workshop/services/handlers/index-link.ts | 39 + .../workshop/services/handlers/list-link.ts | 42 + .../mod/workshop/services/handlers/module.ts | 79 + .../workshop/services/handlers/prefetch.ts | 399 ++++ .../workshop/services/handlers/sync-cron.ts | 43 + .../mod/workshop/services/workshop-helper.ts | 638 +++++ .../mod/workshop/services/workshop-offline.ts | 684 ++++++ .../mod/workshop/services/workshop-sync.ts | 631 +++++ src/addons/mod/workshop/services/workshop.ts | 2065 +++++++++++++++++ src/addons/mod/workshop/workshop.module.ts | 79 + src/core/features/compile/services/compile.ts | 8 +- src/core/services/utils/utils.ts | 4 +- 15 files changed, 4922 insertions(+), 7 deletions(-) create mode 100644 src/addons/mod/workshop/services/database/workshop.ts create mode 100644 src/addons/mod/workshop/services/handlers/index-link.ts create mode 100644 src/addons/mod/workshop/services/handlers/list-link.ts create mode 100644 src/addons/mod/workshop/services/handlers/module.ts create mode 100644 src/addons/mod/workshop/services/handlers/prefetch.ts create mode 100644 src/addons/mod/workshop/services/handlers/sync-cron.ts create mode 100644 src/addons/mod/workshop/services/workshop-helper.ts create mode 100644 src/addons/mod/workshop/services/workshop-offline.ts create mode 100644 src/addons/mod/workshop/services/workshop-sync.ts create mode 100644 src/addons/mod/workshop/services/workshop.ts create mode 100644 src/addons/mod/workshop/workshop.module.ts diff --git a/src/addons/mod/h5pactivity/services/h5pactivity.ts b/src/addons/mod/h5pactivity/services/h5pactivity.ts index fe19c45ac..fba890efb 100644 --- a/src/addons/mod/h5pactivity/services/h5pactivity.ts +++ b/src/addons/mod/h5pactivity/services/h5pactivity.ts @@ -792,7 +792,7 @@ export type AddonModH5PActivityWSResultAnswer = { /** * User attempts data with some calculated data. */ -export type AddonModH5PActivityUserAttempts = Omit & { +export type AddonModH5PActivityUserAttempts = Omit & { attempts: AddonModH5PActivityAttempt[]; // The complete attempts list. scored?: { // Attempts used to grade the activity. title: string; // Scored attempts title. diff --git a/src/addons/mod/mod.module.ts b/src/addons/mod/mod.module.ts index 06ed2ce2c..1c3e3fe0e 100644 --- a/src/addons/mod/mod.module.ts +++ b/src/addons/mod/mod.module.ts @@ -35,6 +35,7 @@ import { AddonModScormModule } from './scorm/scorm.module'; import { AddonModSurveyModule } from './survey/survey.module'; import { AddonModUrlModule } from './url/url.module'; import { AddonModWikiModule } from './wiki/wiki.module'; +import { AddonModWorkshopModule } from './workshop/workshop.module'; @NgModule({ imports: [ @@ -59,6 +60,7 @@ import { AddonModWikiModule } from './wiki/wiki.module'; AddonModSurveyModule, AddonModUrlModule, AddonModWikiModule, + AddonModWorkshopModule, ], }) export class AddonModModule { } diff --git a/src/addons/mod/workshop/services/database/workshop.ts b/src/addons/mod/workshop/services/database/workshop.ts new file mode 100644 index 000000000..346f168c2 --- /dev/null +++ b/src/addons/mod/workshop/services/database/workshop.ts @@ -0,0 +1,214 @@ +// (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'; +import { AddonModWorkshopAction } from '../workshop'; + +/** + * Database variables for AddonModWorkshopOfflineProvider. + */ +export const SUBMISSIONS_TABLE = 'addon_mod_workshop_submissions'; +export const ASSESSMENTS_TABLE = 'addon_mod_workshop_assessments'; +export const EVALUATE_SUBMISSIONS_TABLE = 'addon_mod_workshop_evaluate_submissions'; +export const EVALUATE_ASSESSMENTS_TABLE = 'addon_mod_workshop_evaluate_assessments'; + +export const ADDON_MOD_WORKSHOP_OFFLINE_SITE_SCHEMA: CoreSiteSchema = { + name: 'AddonModWorkshopOfflineProvider', + version: 1, + tables: [ + { + name: SUBMISSIONS_TABLE, + columns: [ + { + name: 'workshopid', + type: 'INTEGER', + }, + { + name: 'action', + type: 'TEXT', + }, + { + name: 'submissionid', + type: 'INTEGER', + }, + { + name: 'courseid', + type: 'INTEGER', + }, + { + name: 'title', + type: 'TEXT', + }, + { + name: 'content', + type: 'TEXT', + }, + { + name: 'attachmentsid', + type: 'TEXT', + }, + { + name: 'timemodified', + type: 'INTEGER', + }, + ], + primaryKeys: ['workshopid', 'action'], + }, + { + name: ASSESSMENTS_TABLE, + columns: [ + { + name: 'workshopid', + type: 'INTEGER', + }, + { + name: 'assessmentid', + type: 'INTEGER', + }, + { + name: 'courseid', + type: 'INTEGER', + }, + { + name: 'inputdata', + type: 'TEXT', + }, + { + name: 'timemodified', + type: 'INTEGER', + }, + ], + primaryKeys: ['workshopid', 'assessmentid'], + }, + { + name: EVALUATE_SUBMISSIONS_TABLE, + columns: [ + { + name: 'workshopid', + type: 'INTEGER', + }, + { + name: 'submissionid', + type: 'INTEGER', + }, + { + name: 'courseid', + type: 'INTEGER', + }, + { + name: 'timemodified', + type: 'INTEGER', + }, + { + name: 'feedbacktext', + type: 'TEXT', + }, + { + name: 'published', + type: 'INTEGER', + }, + { + name: 'gradeover', + type: 'TEXT', + }, + ], + primaryKeys: ['workshopid', 'submissionid'], + }, + { + name: EVALUATE_ASSESSMENTS_TABLE, + columns: [ + { + name: 'workshopid', + type: 'INTEGER', + }, + { + name: 'assessmentid', + type: 'INTEGER', + }, + { + name: 'courseid', + type: 'INTEGER', + }, + { + name: 'timemodified', + type: 'INTEGER', + }, + { + name: 'feedbacktext', + type: 'TEXT', + }, + { + name: 'weight', + type: 'INTEGER', + }, + { + name: 'gradinggradeover', + type: 'TEXT', + }, + ], + primaryKeys: ['workshopid', 'assessmentid'], + }, + ], +}; + +/** + * Data about workshop submissions to sync. + */ +export type AddonModWorkshopSubmissionDBRecord = { + workshopid: number; // Primary key. + action: AddonModWorkshopAction; // Primary key. + submissionid: number; + courseid: number; + title: string; + content: string; + attachmentsid: string; + timemodified: number; +}; + +/** + * Data about workshop assessments to sync. + */ +export type AddonModWorkshopAssessmentDBRecord = { + workshopid: number; // Primary key. + assessmentid: number; // Primary key. + courseid: number; + inputdata: string; + timemodified: number; +}; + +/** + * Data about workshop evaluate submissions to sync. + */ +export type AddonModWorkshopEvaluateSubmissionDBRecord = { + workshopid: number; // Primary key. + submissionid: number; // Primary key. + courseid: number; + timemodified: number; + feedbacktext: string; + published: number; + gradeover: string; +}; + +/** + * Data about workshop evaluate assessments to sync. + */ +export type AddonModWorkshopEvaluateAssessmentDBRecord = { + workshopid: number; // Primary key. + assessmentid: number; // Primary key. + courseid: number; + timemodified: number; + feedbacktext: string; + weight: number; + gradinggradeover: string; +}; diff --git a/src/addons/mod/workshop/services/handlers/index-link.ts b/src/addons/mod/workshop/services/handlers/index-link.ts new file mode 100644 index 000000000..01fe2e311 --- /dev/null +++ b/src/addons/mod/workshop/services/handlers/index-link.ts @@ -0,0 +1,39 @@ +// (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'; +import { AddonModWorkshopProvider, AddonModWorkshop } from '../workshop'; +/** + * Handler to treat links to workshop. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModWorkshopIndexLinkHandlerService extends CoreContentLinksModuleIndexHandler { + + name = 'AddonModWorkshopLinkHandler'; + + constructor() { + super(AddonModWorkshopProvider.COMPONENT, 'workshop', 'w'); + } + + /** + * @inheritdoc + */ + isEnabled(siteId: string): Promise { + return AddonModWorkshop.isPluginEnabled(siteId); + } + +} +export const AddonModWorkshopIndexLinkHandler = makeSingleton(AddonModWorkshopIndexLinkHandlerService); diff --git a/src/addons/mod/workshop/services/handlers/list-link.ts b/src/addons/mod/workshop/services/handlers/list-link.ts new file mode 100644 index 000000000..7e2852f57 --- /dev/null +++ b/src/addons/mod/workshop/services/handlers/list-link.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 { Injectable } from '@angular/core'; +import { CoreContentLinksModuleListHandler } from '@features/contentlinks/classes/module-list-handler'; +import { makeSingleton } from '@singletons'; +import { AddonModWorkshop } from '../workshop'; + +/** + * Handler to treat links to workshop list page. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModWorkshopListLinkHandlerService extends CoreContentLinksModuleListHandler { + + name = 'AddonModWorkshopListLinkHandler'; + + constructor() { + super('AddonModWorkshop', 'workshop'); + } + + /** + * Check if the handler is enabled on a site level. + * + * @return Whether or not the handler is enabled on a site level. + */ + isEnabled(): Promise { + return AddonModWorkshop.isPluginEnabled(); + } + +} +export const AddonModWorkshopListLinkHandler = makeSingleton(AddonModWorkshopListLinkHandlerService); diff --git a/src/addons/mod/workshop/services/handlers/module.ts b/src/addons/mod/workshop/services/handlers/module.ts new file mode 100644 index 000000000..22e54bf0a --- /dev/null +++ b/src/addons/mod/workshop/services/handlers/module.ts @@ -0,0 +1,79 @@ +// (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 { CoreCourse, CoreCourseAnyModuleData } from '@features/course/services/course'; +import { CoreCourseModule } from '@features/course/services/course-helper'; +import { CoreCourseModuleHandler, CoreCourseModuleHandlerData } from '@features/course/services/module-delegate'; +import { CoreNavigationOptions, CoreNavigator } from '@services/navigator'; +import { makeSingleton } from '@singletons'; +import { AddonModWorkshopIndexComponent } from '../../components/index'; +import { AddonModWorkshop } from '../workshop'; + +/** + * Handler to support workshop modules. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModWorkshopModuleHandlerService implements CoreCourseModuleHandler { + + static readonly PAGE_NAME = 'mod_workshop'; + + name = 'AddonModWorkshop'; + modName = 'workshop'; + + supportedFeatures = { + [CoreConstants.FEATURE_GROUPS]: true, + [CoreConstants.FEATURE_GROUPINGS]: true, + [CoreConstants.FEATURE_MOD_INTRO]: true, + [CoreConstants.FEATURE_COMPLETION_TRACKS_VIEWS]: true, + [CoreConstants.FEATURE_GRADE_HAS_GRADE]: true, + [CoreConstants.FEATURE_BACKUP_MOODLE2]: true, + [CoreConstants.FEATURE_SHOW_DESCRIPTION]: true, + [CoreConstants.FEATURE_PLAGIARISM]: true, + }; + + /** + * @inheritdoc + */ + isEnabled(): Promise { + return AddonModWorkshop.isPluginEnabled(); + } + + /** + * @inheritdoc + */ + getData(module: CoreCourseAnyModuleData): CoreCourseModuleHandlerData { + return { + icon: CoreCourse.getModuleIconSrc(this.modName, 'modicon' in module ? module.modicon : undefined), + title: module.name, + class: 'addon-mod_workshop-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.navigateToSitePath(AddonModWorkshopModuleHandlerService.PAGE_NAME + routeParams, options); + }, + }; + } + + async getMainComponent(): Promise> { + return AddonModWorkshopIndexComponent; + } + +} +export const AddonModWorkshopModuleHandler = makeSingleton(AddonModWorkshopModuleHandlerService); diff --git a/src/addons/mod/workshop/services/handlers/prefetch.ts b/src/addons/mod/workshop/services/handlers/prefetch.ts new file mode 100644 index 000000000..d8bd7864c --- /dev/null +++ b/src/addons/mod/workshop/services/handlers/prefetch.ts @@ -0,0 +1,399 @@ +// (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 { AddonModDataSyncResult } from '@addons/mod/data/services/data-sync'; +import { Injectable } from '@angular/core'; +import { CoreCourseActivityPrefetchHandlerBase } from '@features/course/classes/activity-prefetch-handler'; +import { CoreCourse, CoreCourseAnyModuleData } from '@features/course/services/course'; +import { CoreUser } from '@features/user/services/user'; +import { CoreFilepool } from '@services/filepool'; +import { CoreGroup, CoreGroups } from '@services/groups'; +import { CoreSites, CoreSitesReadingStrategy, CoreSitesCommonWSOptions } from '@services/sites'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreWSExternalFile, CoreWSFile } from '@services/ws'; +import { makeSingleton } from '@singletons'; +import { + AddonModWorkshopProvider, + AddonModWorkshop, + AddonModWorkshopPhase, + AddonModWorkshopGradesData, + AddonModWorkshopData, + AddonModWorkshopGetWorkshopAccessInformationWSResponse, +} from '../workshop'; +import { AddonModWorkshopHelper } from '../workshop-helper'; +import { AddonModWorkshopSync } from '../workshop-sync'; + +/** + * Handler to prefetch workshops. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModWorkshopPrefetchHandlerService extends CoreCourseActivityPrefetchHandlerBase { + + name = 'AddonModWorkshop'; + modName = 'workshop'; + component = AddonModWorkshopProvider.COMPONENT; + updatesNames = new RegExp('^configuration$|^.*files$|^completion|^gradeitems$|^outcomes$|^submissions$|^assessments$' + + '|^assessmentgrades$|^usersubmissions$|^userassessments$|^userassessmentgrades$|^userassessmentgrades$'); + + /** + * @inheritdoc + */ + async getFiles(module: CoreCourseAnyModuleData, courseId: number): Promise { + const info = await this.getWorkshopInfoHelper(module, courseId, { omitFail: true }); + + return info.files; + } + + /** + * Helper function to get all workshop info just once. + * + * @param module Module to get the files. + * @param courseId Course ID the module belongs to. + * @param options Other options. + * @return Promise resolved with the info fetched. + */ + protected async getWorkshopInfoHelper( + module: CoreCourseAnyModuleData, + courseId: number, + options: AddonModWorkshopGetInfoOptions = {}, + ): Promise<{ workshop?: AddonModWorkshopData; groups: CoreGroup[]; files: CoreWSFile[]}> { + let groups: CoreGroup[] = []; + let files: CoreWSFile[] = []; + let workshop: AddonModWorkshopData | undefined; + let access: AddonModWorkshopGetWorkshopAccessInformationWSResponse | undefined; + + const modOptions = { + cmId: module.id, + ...options, // Include all options. + }; + + try { + const site = await CoreSites.getSite(options.siteId); + const userId = site.getUserId(); + const workshop = await AddonModWorkshop.getWorkshop(courseId, module.id, options); + + files = this.getIntroFilesFromInstance(module, workshop); + files = files.concat(workshop.instructauthorsfiles || []).concat(workshop.instructreviewersfiles || []); + + access = await AddonModWorkshop.getWorkshopAccessInformation(workshop.id, modOptions); + if (access.canviewallsubmissions) { + const groupInfo = await CoreGroups.getActivityGroupInfo(module.id, false, undefined, options.siteId); + if (!groupInfo.groups || groupInfo.groups.length == 0) { + groupInfo.groups = [{ id: 0, name: '' }]; + } + groups = groupInfo.groups; + } + + const phases = await AddonModWorkshop.getUserPlanPhases(workshop.id, modOptions); + + // Get submission phase info. + const submissionPhase = phases[AddonModWorkshopPhase.PHASE_SUBMISSION]; + const canSubmit = AddonModWorkshopHelper.canSubmit(workshop, access, submissionPhase.tasks); + const canAssess = AddonModWorkshopHelper.canAssess(workshop, access); + + const promises: Promise[] = []; + + if (canSubmit) { + promises.push(AddonModWorkshopHelper.getUserSubmission(workshop.id, { + userId, + cmId: module.id, + }).then((submission) => { + if (submission) { + files = files.concat(submission.contentfiles || []).concat(submission.attachmentfiles || []); + } + + return; + })); + } + + if (access.canviewallsubmissions && workshop.phase >= AddonModWorkshopPhase.PHASE_SUBMISSION) { + promises.push(AddonModWorkshop.getSubmissions(workshop.id, modOptions).then(async (submissions) => { + + await Promise.all(submissions.map(async (submission) => { + files = files.concat(submission.contentfiles || []).concat(submission.attachmentfiles || []); + + const assessments = await AddonModWorkshop.getSubmissionAssessments(workshop!.id, submission.id, { + cmId: module.id, + }); + + assessments.forEach((assessment) => { + files = files.concat(assessment.feedbackattachmentfiles) + .concat(assessment.feedbackcontentfiles); + }); + + if (workshop!.phase >= AddonModWorkshopPhase.PHASE_ASSESSMENT && canAssess) { + await Promise.all(assessments.map((assessment) => + AddonModWorkshopHelper.getReviewerAssessmentById(workshop!.id, assessment.id))); + } + })); + + return; + })); + } + + // Get assessment files. + if (workshop.phase >= AddonModWorkshopPhase.PHASE_ASSESSMENT && canAssess) { + promises.push(AddonModWorkshopHelper.getReviewerAssessments(workshop.id, modOptions).then((assessments) => { + assessments.forEach((assessment) => { + files = files.concat(assessment.feedbackattachmentfiles) + .concat(assessment.feedbackcontentfiles); + }); + + return; + })); + } + + await Promise.all(promises); + + return { + workshop, + groups, + files: files.filter((file) => typeof file !== 'undefined'), + }; + } catch (error) { + if (options.omitFail) { + // Any error, return the info we have. + return { + workshop, + groups, + files: files.filter((file) => typeof file !== 'undefined'), + }; + } + + throw error; + } + } + + /** + * @inheritdoc + */ + async invalidateContent(moduleId: number, courseId: number): Promise { + await AddonModWorkshop.invalidateContent(moduleId, courseId); + } + + /** + * Check if a module can be downloaded. If the function is not defined, we assume that all modules are downloadable. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @return Whether the module can be downloaded. The promise should never be rejected. + */ + async isDownloadable(module: CoreCourseAnyModuleData, courseId: number): Promise { + const workshop = await AddonModWorkshop.getWorkshop(courseId, module.id, { + readingStrategy: CoreSitesReadingStrategy.PreferCache, + }); + + const accessData = await AddonModWorkshop.getWorkshopAccessInformation(workshop.id, { cmId: module.id }); + + // Check if workshop is setup by phase. + return accessData.canswitchphase || workshop.phase > AddonModWorkshopPhase.PHASE_SETUP; + } + + /** + * @inheritdoc + */ + async isEnabled(): Promise { + return AddonModWorkshop.isPluginEnabled(); + } + + /** + * @inheritdoc + */ + prefetch(module: CoreCourseAnyModuleData, courseId?: number): Promise { + return this.prefetchPackage(module, courseId, this.prefetchWorkshop.bind(this, module, courseId)); + } + + /** + * Retrieves all the grades reports for all the groups and then returns only unique grades. + * + * @param workshopId Workshop ID. + * @param groups Array of groups in the activity. + * @param cmId Module ID. + * @param siteId Site ID. If not defined, current site. + * @return All unique entries. + */ + protected async getAllGradesReport( + workshopId: number, + groups: CoreGroup[], + cmId: number, + siteId: string, + ): Promise { + const promises: Promise[] = []; + + groups.forEach((group) => { + promises.push(AddonModWorkshop.fetchAllGradeReports(workshopId, { groupId: group.id, cmId, siteId })); + }); + + const grades = await Promise.all(promises); + const uniqueGrades: Record = {}; + + grades.forEach((groupGrades) => { + groupGrades.forEach((grade) => { + if (grade.submissionid) { + uniqueGrades[grade.submissionid] = grade; + } + }); + }); + + return CoreUtils.objectToArray(uniqueGrades); + } + + /** + * Prefetch a workshop. + * + * @param module The module object returned by WS. + * @param courseId Course ID the module belongs to. + * @param siteId Site ID. + * @return Promise resolved when done. + */ + protected async prefetchWorkshop(module: CoreCourseAnyModuleData, courseId: number, siteId: string): Promise { + + siteId = siteId || CoreSites.getCurrentSiteId(); + + const userIds: number[] = []; + const commonOptions = { + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + siteId, + }; + const modOptions = { + cmId: module.id, + ...commonOptions, // Include all common options. + }; + + const site = await CoreSites.getSite(siteId); + const currentUserId = site.getUserId(); + + // Prefetch the workshop data. + const info = await this.getWorkshopInfoHelper(module, courseId, commonOptions); + const workshop = info.workshop!; + const promises: Promise[] = []; + const assessmentIds: number[] = []; + + promises.push(CoreFilepool.addFilesToQueue(siteId, info.files, this.component, module.id)); + + promises.push(AddonModWorkshop.getWorkshopAccessInformation(workshop.id, modOptions).then(async (access) => { + const phases = await AddonModWorkshop.getUserPlanPhases(workshop.id, modOptions); + + // Get submission phase info. + const submissionPhase = phases[AddonModWorkshopPhase.PHASE_SUBMISSION]; + const canSubmit = AddonModWorkshopHelper.canSubmit(workshop, access, submissionPhase.tasks); + const canAssess = AddonModWorkshopHelper.canAssess(workshop, access); + const promises2: Promise[] = []; + + if (canSubmit) { + promises2.push(AddonModWorkshop.getSubmissions(workshop.id, modOptions)); + // Add userId to the profiles to prefetch. + userIds.push(currentUserId); + } + + let reportPromise: Promise = Promise.resolve(); + if (access.canviewallsubmissions && workshop.phase >= AddonModWorkshopPhase.PHASE_SUBMISSION) { + // eslint-disable-next-line promise/no-nesting + reportPromise = this.getAllGradesReport(workshop.id, info.groups, module.id, siteId).then((grades) => { + grades.forEach((grade) => { + userIds.push(grade.userid); + grade.submissiongradeoverby && userIds.push(grade.submissiongradeoverby); + + grade.reviewedby && grade.reviewedby.forEach((assessment) => { + userIds.push(assessment.userid); + assessmentIds[assessment.assessmentid] = assessment.assessmentid; + }); + + grade.reviewerof && grade.reviewerof.forEach((assessment) => { + userIds.push(assessment.userid); + assessmentIds[assessment.assessmentid] = assessment.assessmentid; + }); + }); + + return; + }); + } + + if (workshop.phase >= AddonModWorkshopPhase.PHASE_ASSESSMENT && canAssess) { + // Wait the report promise to finish to override assessments array if needed. + reportPromise = reportPromise.finally(async () => { + const revAssessments = await AddonModWorkshopHelper.getReviewerAssessments(workshop.id, { + userId: currentUserId, + cmId: module.id, + siteId, + }); + + let files: CoreWSExternalFile[] = []; // Files in each submission. + + revAssessments.forEach((assessment) => { + if (assessment.submission?.authorid == currentUserId) { + promises.push(AddonModWorkshop.getAssessment( + workshop.id, + assessment.id, + modOptions, + )); + } + userIds.push(assessment.reviewerid); + userIds.push(assessment.gradinggradeoverby); + assessmentIds[assessment.id] = assessment.id; + + files = files.concat(assessment.submission?.attachmentfiles || []) + .concat(assessment.submission?.contentfiles || []); + }); + + await CoreFilepool.addFilesToQueue(siteId, files, this.component, module.id); + }); + } + + reportPromise = reportPromise.finally(() => { + if (assessmentIds.length > 0) { + return Promise.all(assessmentIds.map((assessmentId) => + AddonModWorkshop.getAssessmentForm(workshop.id, assessmentId, modOptions))); + } + }); + promises2.push(reportPromise); + + if (workshop.phase == AddonModWorkshopPhase.PHASE_CLOSED) { + promises2.push(AddonModWorkshop.getGrades(workshop.id, modOptions)); + if (access.canviewpublishedsubmissions) { + promises2.push(AddonModWorkshop.getSubmissions(workshop.id, modOptions)); + } + } + + await Promise.all(promises2); + + return; + })); + + // Add Basic Info to manage links. + promises.push(CoreCourse.getModuleBasicInfoByInstance(workshop.id, 'workshop', siteId)); + promises.push(CoreCourse.getModuleBasicGradeInfo(module.id, siteId)); + + await Promise.all(promises); + + // Prefetch user profiles. + await CoreUser.prefetchProfiles(userIds, courseId, siteId); + } + + /** + * @inheritdoc + */ + async sync(module: CoreCourseAnyModuleData, courseId: number, siteId?: string): Promise { + return AddonModWorkshopSync.syncWorkshop(module.instance!, siteId); + } + +} +export const AddonModWorkshopPrefetchHandler = makeSingleton(AddonModWorkshopPrefetchHandlerService); + +/** + * Options to pass to getWorkshopInfoHelper. + */ +export type AddonModWorkshopGetInfoOptions = CoreSitesCommonWSOptions & { + omitFail?: boolean; // True to always return even if fails. +}; diff --git a/src/addons/mod/workshop/services/handlers/sync-cron.ts b/src/addons/mod/workshop/services/handlers/sync-cron.ts new file mode 100644 index 000000000..d23811ab3 --- /dev/null +++ b/src/addons/mod/workshop/services/handlers/sync-cron.ts @@ -0,0 +1,43 @@ +// (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 { AddonModWorkshopSync } from '../workshop-sync'; + +/** + * Synchronization cron handler. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModWorkshopSyncCronHandlerService implements CoreCronHandler { + + name = 'AddonModWorkshopSyncCronHandler'; + + /** + * @inheritdoc + */ + execute(siteId?: string, force?: boolean): Promise { + return AddonModWorkshopSync.syncAllWorkshops(siteId, force); + } + + /** + * @inheritdoc + */ + getInterval(): number { + return AddonModWorkshopSync.syncInterval; + } + +} +export const AddonModWorkshopSyncCronHandler = makeSingleton(AddonModWorkshopSyncCronHandlerService); diff --git a/src/addons/mod/workshop/services/workshop-helper.ts b/src/addons/mod/workshop/services/workshop-helper.ts new file mode 100644 index 000000000..8e8f72e37 --- /dev/null +++ b/src/addons/mod/workshop/services/workshop-helper.ts @@ -0,0 +1,638 @@ +// (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 { CoreFileUploader, CoreFileUploaderStoreFilesResult } from '@features/fileuploader/services/fileuploader'; +import { FileEntry } from '@ionic-native/file'; +import { CoreFile } from '@services/file'; +import { CoreFileEntry } from '@services/file-helper'; +import { CoreSites } from '@services/sites'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton, Translate } from '@singletons'; +import { CoreFormFields } from '@singletons/form'; +import { AddonModWorkshopAssessmentStrategyFieldErrors } from '../components/assessment-strategy/assessment-strategy'; +import { AddonWorkshopAssessmentStrategyDelegate } from './assessment-strategy-delegate'; +import { + AddonModWorkshopExampleMode, + AddonModWorkshopPhase, + AddonModWorkshopUserOptions, + AddonModWorkshopProvider, + AddonModWorkshopData, + AddonModWorkshop, + AddonModWorkshopSubmissionData, + AddonModWorkshopGetWorkshopAccessInformationWSResponse, + AddonModWorkshopPhaseTaskData, + AddonModWorkshopSubmissionAssessmentData, + AddonModWorkshopGetAssessmentFormDefinitionData, + AddonModWorkshopAction, + AddonModWorkshopOverallFeedbackMode, + AddonModWorkshopGetAssessmentFormFieldsParsedData, +} from './workshop'; +import { AddonModWorkshopOffline, AddonModWorkshopOfflineSubmission } from './workshop-offline'; + +/** + * Helper to gather some common functions for workshop. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModWorkshopHelperProvider { + + /** + * Get a task by code. + * + * @param tasks Array of tasks. + * @param taskCode Unique task code. + * @return Task requested + */ + getTask(tasks: AddonModWorkshopPhaseTaskData[], taskCode: string): AddonModWorkshopPhaseTaskData | undefined { + return tasks.find((task) => task.code == taskCode); + } + + /** + * Check is task code is done. + * + * @param tasks Array of tasks. + * @param taskCode Unique task code. + * @return True if task is completed. + */ + isTaskDone(tasks: AddonModWorkshopPhaseTaskData[], taskCode: string): boolean { + const task = this.getTask(tasks, taskCode); + + if (task) { + return !!task.completed; + } + + // Task not found, assume true. + return true; + } + + /** + * Return if a user can submit a workshop. + * + * @param workshop Workshop info. + * @param access Access information. + * @param tasks Array of tasks. + * @return True if the user can submit the workshop. + */ + canSubmit( + workshop: AddonModWorkshopData, + access: AddonModWorkshopGetWorkshopAccessInformationWSResponse, + tasks: AddonModWorkshopPhaseTaskData[], + ): boolean { + const examplesMust = workshop.useexamples && + workshop.examplesmode == AddonModWorkshopExampleMode.EXAMPLES_BEFORE_SUBMISSION; + const examplesDone = access.canmanageexamples || + workshop.examplesmode == AddonModWorkshopExampleMode.EXAMPLES_VOLUNTARY || + this.isTaskDone(tasks, 'examples'); + + return workshop.phase > AddonModWorkshopPhase.PHASE_SETUP && access.cansubmit && (!examplesMust || examplesDone); + } + + /** + * Return if a user can assess a workshop. + * + * @param workshop Workshop info. + * @param access Access information. + * @return True if the user can assess the workshop. + */ + canAssess(workshop: AddonModWorkshopData, access: AddonModWorkshopGetWorkshopAccessInformationWSResponse): boolean { + const examplesMust = workshop.useexamples && + workshop.examplesmode == AddonModWorkshopExampleMode.EXAMPLES_BEFORE_ASSESSMENT; + + const examplesDone = access.canmanageexamples; + + return !examplesMust || examplesDone; + } + + /** + * Return a particular user submission from the submission list. + * + * @param workshopId Workshop ID. + * @param options Other options. + * @return Resolved with the submission, resolved with false if not found. + */ + async getUserSubmission( + workshopId: number, + options: AddonModWorkshopUserOptions = {}, + ): Promise { + const userId = options.userId || CoreSites.getCurrentSiteUserId(); + + const submissions = await AddonModWorkshop.getSubmissions(workshopId, options); + + return submissions.find((submission) => submission.authorid == userId); + } + + /** + * Return a particular submission. It will use prefetched data if fetch fails. + * + * @param workshopId Workshop ID. + * @param submissionId Submission ID. + * @param options Other options. + * @return Resolved with the submission, resolved with false if not found. + */ + async getSubmissionById( + workshopId: number, + submissionId: number, + options: AddonModWorkshopUserOptions = {}, + ): Promise { + try { + return await AddonModWorkshop.getSubmission(workshopId, submissionId, options); + } catch { + const submissions = await AddonModWorkshop.getSubmissions(workshopId, options); + + const submission = submissions.find((submission) => submission.id == submissionId); + + if (!submission) { + throw new CoreError('Submission not found'); + } + + return submission; + } + } + + /** + * Return a particular assesment. It will use prefetched data if fetch fails. It will add assessment form data. + * + * @param workshopId Workshop ID. + * @param assessmentId Assessment ID. + * @param options Other options. + * @return Resolved with the assessment. + */ + async getReviewerAssessmentById( + workshopId: number, + assessmentId: number, + options: AddonModWorkshopUserOptions = {}, + ): Promise { + let assessment: AddonModWorkshopSubmissionAssessmentWithFormData | undefined; + + try { + assessment = await AddonModWorkshop.getAssessment(workshopId, assessmentId, options); + } catch (error) { + const assessments = await AddonModWorkshop.getReviewerAssessments(workshopId, options); + assessment = assessments.find((assessment_1) => assessment_1.id == assessmentId); + + if (!assessment) { + throw error; + } + } + + assessment.form = await AddonModWorkshop.getAssessmentForm(workshopId, assessmentId, options); + + return assessment; + } + + /** + * Retrieves the assessment of the given user and all the related data. + * + * @param workshopId Workshop ID. + * @param options Other options. + * @return Promise resolved when the workshop data is retrieved. + */ + async getReviewerAssessments( + workshopId: number, + options: AddonModWorkshopUserOptions = {}, + ): Promise { + options.siteId = options.siteId || CoreSites.getCurrentSiteId(); + + const assessments: AddonModWorkshopSubmissionAssessmentWithFormData[] = + await AddonModWorkshop.getReviewerAssessments(workshopId, options); + + const promises: Promise[] = []; + assessments.forEach((assessment) => { + promises.push(this.getSubmissionById(workshopId, assessment.submissionid, options).then((submission) => { + assessment.submission = submission; + + return; + })); + promises.push(AddonModWorkshop.getAssessmentForm(workshopId, assessment.id, options).then((assessmentForm) => { + assessment.form = assessmentForm; + + return; + })); + + }); + await Promise.all(promises); + + return assessments; + } + + /** + * Delete stored attachment files for a submission. + * + * @param workshopId Workshop ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when deleted. + */ + async deleteSubmissionStoredFiles(workshopId: number, siteId?: string): Promise { + const folderPath = await AddonModWorkshopOffline.getSubmissionFolder(workshopId, siteId); + + // Ignore any errors, CoreFileProvider.removeDir fails if folder doesn't exists. + await CoreUtils.ignoreErrors(CoreFile.removeDir(folderPath)); + } + + /** + * Given a list of files (either online files or local files), store the local files in a local folder + * to be submitted later. + * + * @param workshopId Workshop ID. + * @param files List of files. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if success, rejected otherwise. + */ + async storeSubmissionFiles( + workshopId: number, + files: CoreFileEntry[], + siteId?: string, + ): Promise { + // Get the folder where to store the files. + const folderPath = await AddonModWorkshopOffline.getSubmissionFolder(workshopId, siteId); + + return CoreFileUploader.storeFilesToUpload(folderPath, files); + } + + /** + * Upload or store some files for a submission, depending if the user is offline or not. + * + * @param workshopId Workshop ID. + * @param submissionId If not editing, it will refer to timecreated. + * @param files List of files. + * @param editing If the submission is being edited or added otherwise. + * @param offline True if files sould be stored for offline, false to upload them. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if success. + */ + uploadOrStoreSubmissionFiles( + workshopId: number, + files: CoreFileEntry[], + offline: true, + siteId?: string, + ): Promise; + uploadOrStoreSubmissionFiles( + workshopId: number, + files: CoreFileEntry[], + offline: false, + siteId?: string, + ): Promise; + uploadOrStoreSubmissionFiles( + workshopId: number, + files: CoreFileEntry[], + offline: boolean, + siteId?: string, + ): Promise { + if (offline) { + return this.storeSubmissionFiles(workshopId, files, siteId); + } + + return CoreFileUploader.uploadOrReuploadFiles(files, AddonModWorkshopProvider.COMPONENT, workshopId, siteId); + } + + /** + * Get a list of stored attachment files for a submission. See AddonModWorkshopHelperProvider#storeFiles. + * + * @param workshopId Workshop ID. + * @param submissionId If not editing, it will refer to timecreated. + * @param editing If the submission is being edited or added otherwise. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the files. + */ + async getStoredSubmissionFiles( + workshopId: number, + siteId?: string, + ): Promise { + const folderPath = await AddonModWorkshopOffline.getSubmissionFolder(workshopId, siteId); + + // Ignore not found files. + return CoreUtils.ignoreErrors(CoreFileUploader.getStoredFiles(folderPath), []); + } + + /** + * Get a list of stored attachment files for a submission and online files also. See AddonModWorkshopHelperProvider#storeFiles. + * + * @param filesObject Files object combining offline and online information. + * @param workshopId Workshop ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the files. + */ + async getSubmissionFilesFromOfflineFilesObject( + filesObject: CoreFileUploaderStoreFilesResult, + workshopId: number, + siteId?: string, + ): Promise { + const folderPath = await AddonModWorkshopOffline.getSubmissionFolder(workshopId, siteId); + + return CoreFileUploader.getStoredFilesFromOfflineFilesObject(filesObject, folderPath); + } + + /** + * Delete stored attachment files for an assessment. + * + * @param workshopId Workshop ID. + * @param assessmentId Assessment ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when deleted. + */ + async deleteAssessmentStoredFiles(workshopId: number, assessmentId: number, siteId?: string): Promise { + const folderPath = await AddonModWorkshopOffline.getAssessmentFolder(workshopId, assessmentId, siteId); + + // Ignore any errors, CoreFileProvider.removeDir fails if folder doesn't exists. + await CoreUtils.ignoreErrors(CoreFile.removeDir(folderPath)); + } + + /** + * Given a list of files (either online files or local files), store the local files in a local folder + * to be submitted later. + * + * @param workshopId Workshop ID. + * @param assessmentId Assessment ID. + * @param files List of files. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if success, rejected otherwise. + */ + async storeAssessmentFiles( + workshopId: number, + assessmentId: number, + files: CoreFileEntry[], + siteId?: string, + ): Promise { + // Get the folder where to store the files. + const folderPath = await AddonModWorkshopOffline.getAssessmentFolder(workshopId, assessmentId, siteId); + + return CoreFileUploader.storeFilesToUpload(folderPath, files); + } + + /** + * Upload or store some files for an assessment, depending if the user is offline or not. + * + * @param workshopId Workshop ID. + * @param assessmentId ID. + * @param files List of files. + * @param offline True if files sould be stored for offline, false to upload them. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if success. + */ + uploadOrStoreAssessmentFiles( + workshopId: number, + assessmentId: number, + files: CoreFileEntry[], + offline: true, + siteId?: string, + ): Promise; + uploadOrStoreAssessmentFiles( + workshopId: number, + assessmentId: number, + files: CoreFileEntry[], + offline: false, + siteId?: string, + ): Promise + uploadOrStoreAssessmentFiles( + workshopId: number, + assessmentId: number, + files: CoreFileEntry[], + offline: boolean, + siteId?: string, + ): Promise { + if (offline) { + return this.storeAssessmentFiles(workshopId, assessmentId, files, siteId); + } + + return CoreFileUploader.uploadOrReuploadFiles(files, AddonModWorkshopProvider.COMPONENT, workshopId, siteId); + } + + /** + * Get a list of stored attachment files for an assessment. See AddonModWorkshopHelperProvider#storeFiles. + * + * @param workshopId Workshop ID. + * @param assessmentId Assessment ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the files. + */ + async getStoredAssessmentFiles(workshopId: number, assessmentId: number, siteId?: string): Promise { + const folderPath = await AddonModWorkshopOffline.getAssessmentFolder(workshopId, assessmentId, siteId); + + // Ignore not found files. + return CoreUtils.ignoreErrors(CoreFileUploader.getStoredFiles(folderPath), []); + } + + /** + * Get a list of stored attachment files for an assessment and online files also. See AddonModWorkshopHelperProvider#storeFiles. + * + * @param filesObject Files object combining offline and online information. + * @param workshopId Workshop ID. + * @param assessmentId Assessment ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the files. + */ + async getAssessmentFilesFromOfflineFilesObject( + filesObject: CoreFileUploaderStoreFilesResult, + workshopId: number, + assessmentId: number, + siteId?: string, + ): Promise { + const folderPath = await AddonModWorkshopOffline.getAssessmentFolder(workshopId, assessmentId, siteId); + + return CoreFileUploader.getStoredFilesFromOfflineFilesObject(filesObject, folderPath); + } + + /** + * Applies offline data to submission. + * + * @param submission Submission object to be modified. + * @param actions Offline actions to be applied to the given submission. + * @return Promise resolved with the files. + */ + async applyOfflineData( + submission?: AddonModWorkshopSubmissionDataWithOfflineData, + actions: AddonModWorkshopOfflineSubmission[] = [], + ): Promise { + if (actions.length == 0) { + return submission; + } + + if (typeof submission == 'undefined') { + submission = { + id: 0, + workshopid: 0, + title: '', + content: '', + timemodified: 0, + example: false, + authorid: 0, + timecreated: 0, + contenttrust: 0, + attachment: 0, + published: false, + late: 0, + }; + } + + let attachmentsId: CoreFileUploaderStoreFilesResult | undefined; + const workshopId = actions[0].workshopid; + + actions.forEach((action) => { + switch (action.action) { + case AddonModWorkshopAction.ADD: + case AddonModWorkshopAction.UPDATE: + submission!.title = action.title; + submission!.content = action.content; + submission!.title = action.title; + submission!.courseid = action.courseid; + submission!.submissionmodified = action.timemodified / 1000; + submission!.offline = true; + attachmentsId = action.attachmentsid as CoreFileUploaderStoreFilesResult; + break; + case AddonModWorkshopAction.DELETE: + submission!.deleted = true; + submission!.submissionmodified = action.timemodified / 1000; + break; + default: + } + }); + + // Check offline files for latest attachmentsid. + if (attachmentsId) { + submission.attachmentfiles = + await this.getSubmissionFilesFromOfflineFilesObject(attachmentsId, workshopId); + } else { + submission.attachmentfiles = []; + } + + return submission; + } + + /** + * Prepare assessment data to be sent to the server. + * + * @param workshop Workshop object. + * @param selectedValues Assessment current values + * @param feedbackText Feedback text. + * @param feedbackFiles Feedback attachments. + * @param form Assessment form original data. + * @param attachmentsId The draft file area id for attachments. + * @return Promise resolved with the data to be sent. Or rejected with the input errors object. + */ + async prepareAssessmentData( + workshop: AddonModWorkshopData, + selectedValues: AddonModWorkshopGetAssessmentFormFieldsParsedData[], + feedbackText: string, + form: AddonModWorkshopGetAssessmentFormDefinitionData, + attachmentsId: CoreFileUploaderStoreFilesResult | number = 0, + ): Promise> { + + if (workshop.overallfeedbackmode == AddonModWorkshopOverallFeedbackMode.ENABLED_REQUIRED && !feedbackText) { + const errors: AddonModWorkshopAssessmentStrategyFieldErrors = + { feedbackauthor: Translate.instant('core.err_required') }; + throw errors; + } + + const data = + (await AddonWorkshopAssessmentStrategyDelegate.prepareAssessmentData(workshop.strategy!, selectedValues, form)) || {}; + data.feedbackauthor = feedbackText; + data.feedbackauthorattachmentsid = attachmentsId; + data.nodims = form.dimenssionscount; + + return data; + } + + /** + * Calculates the real value of a grade based on real_grade_value. + * + * @param value Percentual value from 0 to 100. + * @param max The maximal grade. + * @param decimals Decimals to show in the formatted grade. + * @return Real grade formatted. + */ + protected realGradeValueHelper(value?: number | string, max = 0, decimals = 0): string | undefined { + if (typeof value == 'string') { + // Already treated. + return value; + } + + if (value == null || typeof value == 'undefined') { + return undefined; + } + + if (max == 0) { + return '0'; + } + + value = CoreTextUtils.roundToDecimals(max * value / 100, decimals); + + return CoreUtils.formatFloat(value); + } + + /** + * Calculates the real value of a grades of an assessment. + * + * @param workshop Workshop object. + * @param assessment Assessment data. + * @return Assessment with real grades. + */ + realGradeValue( + workshop: AddonModWorkshopData, + assessment: AddonModWorkshopSubmissionAssessmentWithFormData, + ): AddonModWorkshopSubmissionAssessmentWithFormData { + assessment.grade = this.realGradeValueHelper(assessment.grade, workshop.grade, workshop.gradedecimals); + assessment.gradinggrade = this.realGradeValueHelper(assessment.gradinggrade, workshop.gradinggrade, workshop.gradedecimals); + + assessment.gradinggradeover = this.realGradeValueHelper( + assessment.gradinggradeover, + workshop.gradinggrade, + workshop.gradedecimals, + ); + + return assessment; + } + + /** + * Check grade should be shown + * + * @param grade Grade to be shown + * @return If grade should be shown or not. + */ + showGrade(grade?: number|string): boolean { + return typeof grade !== 'undefined' && grade !== null; + } + +} +export const AddonModWorkshopHelper = makeSingleton(AddonModWorkshopHelperProvider); + +export type AddonModWorkshopSubmissionAssessmentWithFormData = + Omit & { + form?: AddonModWorkshopGetAssessmentFormDefinitionData; + submission?: AddonModWorkshopSubmissionData; + offline?: boolean; + strategy?: string; + grade?: string | number; + gradinggrade?: string | number; + gradinggradeover?: string | number; + ownAssessment?: boolean; + feedbackauthor?: string; + feedbackattachmentfiles: CoreFileEntry[]; // Feedbackattachmentfiles. + }; + +export type AddonModWorkshopSubmissionDataWithOfflineData = Omit & { + courseid?: number; + submissionmodified?: number; + offline?: boolean; + deleted?: boolean; + attachmentfiles?: CoreFileEntry[]; + reviewedby?: AddonModWorkshopSubmissionAssessmentWithFormData[]; + reviewerof?: AddonModWorkshopSubmissionAssessmentWithFormData[]; + gradinggrade?: number; + reviewedbydone?: number; + reviewerofdone?: number; + reviewedbycount?: number; + reviewerofcount?: number; +}; diff --git a/src/addons/mod/workshop/services/workshop-offline.ts b/src/addons/mod/workshop/services/workshop-offline.ts new file mode 100644 index 000000000..fdf5f0152 --- /dev/null +++ b/src/addons/mod/workshop/services/workshop-offline.ts @@ -0,0 +1,684 @@ +// (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 { CoreFileUploaderStoreFilesResult } from '@features/fileuploader/services/fileuploader'; +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 { CoreFormFields } from '@singletons/form'; +import { + AddonModWorkshopAssessmentDBRecord, + AddonModWorkshopEvaluateAssessmentDBRecord, + AddonModWorkshopEvaluateSubmissionDBRecord, + AddonModWorkshopSubmissionDBRecord, + ASSESSMENTS_TABLE, + EVALUATE_ASSESSMENTS_TABLE, + EVALUATE_SUBMISSIONS_TABLE, + SUBMISSIONS_TABLE, +} from './database/workshop'; +import { AddonModWorkshopAction } from './workshop'; + +/** + * Service to handle offline workshop. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModWorkshopOfflineProvider { + + /** + * Get all the workshops ids that have something to be synced. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with workshops id that have something to be synced. + */ + async getAllWorkshops(siteId?: string): Promise { + const promiseResults = await Promise.all([ + this.getAllSubmissions(siteId), + this.getAllAssessments(siteId), + this.getAllEvaluateSubmissions(siteId), + this.getAllEvaluateAssessments(siteId), + ]); + + const workshopIds: Record = {}; + + // Get workshops from any offline object all should have workshopid. + promiseResults.forEach((offlineObjects) => { + offlineObjects.forEach((offlineObject: AddonModWorkshopOfflineSubmission | AddonModWorkshopOfflineAssessment | + AddonModWorkshopOfflineEvaluateSubmission | AddonModWorkshopOfflineEvaluateAssessment) => { + workshopIds[offlineObject.workshopid] = offlineObject.workshopid; + }); + }); + + return Object.values(workshopIds); + } + + /** + * Check if there is an offline data to be synced. + * + * @param workshopId Workshop ID to remove. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: true if has offline data, false otherwise. + */ + async hasWorkshopOfflineData(workshopId: number, siteId?: string): Promise { + try { + const results = await Promise.all([ + this.getSubmissions(workshopId, siteId), + this.getAssessments(workshopId, siteId), + this.getEvaluateSubmissions(workshopId, siteId), + this.getEvaluateAssessments(workshopId, siteId), + ]); + + return results.some((result) => result && result.length); + } catch { + // No offline data found. + return false; + } + } + + /** + * Delete workshop submission action. + * + * @param workshopId Workshop ID. + * @param submissionId Submission ID. + * @param action Action to be done. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if stored, rejected if failure. + */ + async deleteSubmissionAction( + workshopId: number, + action: AddonModWorkshopAction, + siteId?: string, + ): Promise { + const site = await CoreSites.getSite(siteId); + + const conditions: Partial = { + workshopid: workshopId, + action: action, + }; + + await site.getDb().deleteRecords(SUBMISSIONS_TABLE, conditions); + } + + /** + * Delete all workshop submission actions. + * + * @param workshopId Workshop ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if stored, rejected if failure. + */ + async deleteAllSubmissionActions(workshopId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + const conditions: Partial = { + workshopid: workshopId, + }; + + await site.getDb().deleteRecords(SUBMISSIONS_TABLE, conditions); + } + + /** + * Get the all the submissions to be synced. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the objects to be synced. + */ + async getAllSubmissions(siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + const records = await site.getDb().getRecords(SUBMISSIONS_TABLE); + + return records.map(this.parseSubmissionRecord.bind(this)); + } + + /** + * Get the submissions of a workshop to be synced. + * + * @param workshopId ID of the workshop. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the object to be synced. + */ + async getSubmissions(workshopId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + const conditions: Partial = { + workshopid: workshopId, + }; + + const records = await site.getDb().getRecords(SUBMISSIONS_TABLE, conditions); + + return records.map(this.parseSubmissionRecord.bind(this)); + } + + /** + * Get an specific action of a submission of a workshop to be synced. + * + * @param workshopId ID of the workshop. + * @param action Action to be done. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the object to be synced. + */ + async getSubmissionAction( + workshopId: number, + action: AddonModWorkshopAction, + siteId?: string, + ): Promise { + const site = await CoreSites.getSite(siteId); + + const conditions: Partial = { + workshopid: workshopId, + action: action, + }; + + const record = await site.getDb().getRecord(SUBMISSIONS_TABLE, conditions); + + return this.parseSubmissionRecord(record); + } + + /** + * Offline version for adding a submission action to a workshop. + * + * @param workshopId Workshop ID. + * @param courseId Course ID the workshop belongs to. + * @param title The submission title. + * @param content The submission text content. + * @param attachmentsId Stored attachments. + * @param submissionId Submission Id, if action is add, the time the submission was created. + * If set to 0, current time is used. + * @param action Action to be done. ['add', 'update', 'delete'] + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when submission action is successfully saved. + */ + async saveSubmission( + workshopId: number, + courseId: number, + title: string, + content: string, + attachmentsId: CoreFileUploaderStoreFilesResult | undefined, + submissionId = 0, + action: AddonModWorkshopAction, + siteId?: string, + ): Promise { + + const site = await CoreSites.getSite(siteId); + + const timemodified = CoreTimeUtils.timestamp(); + + const submission: AddonModWorkshopSubmissionDBRecord = { + workshopid: workshopId, + courseid: courseId, + title: title, + content: content, + attachmentsid: JSON.stringify(attachmentsId), + action: action, + submissionid: submissionId, + timemodified: timemodified, + }; + + await site.getDb().insertRecord(SUBMISSIONS_TABLE, submission); + } + + /** + * Parse "attachments" column of a submission record. + * + * @param record Submission record, modified in place. + */ + protected parseSubmissionRecord(record: AddonModWorkshopSubmissionDBRecord): AddonModWorkshopOfflineSubmission { + return { + ...record, + attachmentsid: CoreTextUtils.parseJSON(record.attachmentsid), + }; + } + + /** + * Delete workshop assessment. + * + * @param workshopId Workshop ID. + * @param assessmentId Assessment ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if stored, rejected if failure. + */ + async deleteAssessment(workshopId: number, assessmentId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + const conditions: Partial = { + workshopid: workshopId, + assessmentid: assessmentId, + }; + + await site.getDb().deleteRecords(ASSESSMENTS_TABLE, conditions); + } + + /** + * Get the all the assessments to be synced. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the objects to be synced. + */ + async getAllAssessments(siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + const records = await site.getDb().getRecords(ASSESSMENTS_TABLE); + + return records.map(this.parseAssessmentRecord.bind(this)); + } + + /** + * Get the assessments of a workshop to be synced. + * + * @param workshopId ID of the workshop. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the object to be synced. + */ + async getAssessments(workshopId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + const conditions: Partial = { + workshopid: workshopId, + }; + + const records = await site.getDb().getRecords(ASSESSMENTS_TABLE, conditions); + + return records.map(this.parseAssessmentRecord.bind(this)); + } + + /** + * Get an specific assessment of a workshop to be synced. + * + * @param workshopId ID of the workshop. + * @param assessmentId Assessment ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the object to be synced. + */ + async getAssessment(workshopId: number, assessmentId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + const conditions: Partial = { + workshopid: workshopId, + assessmentid: assessmentId, + }; + + const record = await site.getDb().getRecord(ASSESSMENTS_TABLE, conditions); + + return this.parseAssessmentRecord(record); + } + + /** + * Offline version for adding an assessment to a workshop. + * + * @param workshopId Workshop ID. + * @param assessmentId Assessment ID. + * @param courseId Course ID the workshop belongs to. + * @param inputData Assessment data. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when assessment is successfully saved. + */ + async saveAssessment( + workshopId: number, + assessmentId: number, + courseId: number, + inputData: CoreFormFields, + siteId?: string, + ): Promise { + const site = await CoreSites.getSite(siteId); + + const assessment: AddonModWorkshopAssessmentDBRecord = { + workshopid: workshopId, + courseid: courseId, + inputdata: JSON.stringify(inputData), + assessmentid: assessmentId, + timemodified: CoreTimeUtils.timestamp(), + }; + + await site.getDb().insertRecord(ASSESSMENTS_TABLE, assessment); + } + + /** + * Parse "inpudata" column of an assessment record. + * + * @param record Assessnent record, modified in place. + */ + protected parseAssessmentRecord(record: AddonModWorkshopAssessmentDBRecord): AddonModWorkshopOfflineAssessment { + return { + ...record, + inputdata: CoreTextUtils.parseJSON(record.inputdata), + }; + } + + /** + * Delete workshop evaluate submission. + * + * @param workshopId Workshop ID. + * @param submissionId Submission ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if stored, rejected if failure. + */ + async deleteEvaluateSubmission(workshopId: number, submissionId: number, siteId?: string): Promise { + const conditions: Partial = { + workshopid: workshopId, + submissionid: submissionId, + }; + + const site = await CoreSites.getSite(siteId); + + await site.getDb().deleteRecords(EVALUATE_SUBMISSIONS_TABLE, conditions); + } + + /** + * Get the all the evaluate submissions to be synced. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the objects to be synced. + */ + async getAllEvaluateSubmissions(siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + const records = await site.getDb().getRecords(EVALUATE_SUBMISSIONS_TABLE); + + return records.map(this.parseEvaluateSubmissionRecord.bind(this)); + } + + /** + * Get the evaluate submissions of a workshop to be synced. + * + * @param workshopId ID of the workshop. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the object to be synced. + */ + async getEvaluateSubmissions(workshopId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + const conditions: Partial = { + workshopid: workshopId, + }; + + const records = + await site.getDb().getRecords(EVALUATE_SUBMISSIONS_TABLE, conditions); + + return records.map(this.parseEvaluateSubmissionRecord.bind(this)); + } + + /** + * Get an specific evaluate submission of a workshop to be synced. + * + * @param workshopId ID of the workshop. + * @param submissionId Submission ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the object to be synced. + */ + async getEvaluateSubmission( + workshopId: number, + submissionId: number, + siteId?: string, + ): Promise { + const site = await CoreSites.getSite(siteId); + + const conditions: Partial = { + workshopid: workshopId, + submissionid: submissionId, + }; + + const record = + await site.getDb().getRecord(EVALUATE_SUBMISSIONS_TABLE, conditions); + + return this.parseEvaluateSubmissionRecord(record); + } + + /** + * Offline version for evaluation a submission to a workshop. + * + * @param workshopId Workshop ID. + * @param submissionId Submission ID. + * @param courseId Course ID the workshop belongs to. + * @param feedbackText The feedback for the author. + * @param published Whether to publish the submission for other users. + * @param gradeOver The new submission grade (empty for no overriding the grade). + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when submission evaluation is successfully saved. + */ + async saveEvaluateSubmission( + workshopId: number, + submissionId: number, + courseId: number, + feedbackText = '', + published?: boolean, + gradeOver?: string, + siteId?: string, + ): Promise { + const site = await CoreSites.getSite(siteId); + + const submission: AddonModWorkshopEvaluateSubmissionDBRecord = { + workshopid: workshopId, + courseid: courseId, + submissionid: submissionId, + timemodified: CoreTimeUtils.timestamp(), + feedbacktext: feedbackText, + published: Number(published), + gradeover: JSON.stringify(gradeOver), + }; + + await site.getDb().insertRecord(EVALUATE_SUBMISSIONS_TABLE, submission); + } + + /** + * Parse "published" and "gradeover" columns of an evaluate submission record. + * + * @param record Evaluate submission record, modified in place. + */ + protected parseEvaluateSubmissionRecord( + record: AddonModWorkshopEvaluateSubmissionDBRecord, + ): AddonModWorkshopOfflineEvaluateSubmission { + return { + ...record, + published: Boolean(record.published), + gradeover: CoreTextUtils.parseJSON(record.gradeover), + }; + } + + /** + * Delete workshop evaluate assessment. + * + * @param workshopId Workshop ID. + * @param assessmentId Assessment ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if stored, rejected if failure. + */ + async deleteEvaluateAssessment(workshopId: number, assessmentId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + const conditions: Partial = { + workshopid: workshopId, + assessmentid: assessmentId, + }; + + await site.getDb().deleteRecords(EVALUATE_ASSESSMENTS_TABLE, conditions); + } + + /** + * Get the all the evaluate assessments to be synced. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the objects to be synced. + */ + async getAllEvaluateAssessments(siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + const records = await site.getDb().getRecords(EVALUATE_ASSESSMENTS_TABLE); + + return records.map(this.parseEvaluateAssessmentRecord.bind(this)); + } + + /** + * Get the evaluate assessments of a workshop to be synced. + * + * @param workshopId ID of the workshop. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the object to be synced. + */ + async getEvaluateAssessments(workshopId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + const conditions: Partial = { + workshopid: workshopId, + }; + + const records = + await site.getDb().getRecords(EVALUATE_ASSESSMENTS_TABLE, conditions); + + return records.map(this.parseEvaluateAssessmentRecord.bind(this)); + } + + /** + * Get an specific evaluate assessment of a workshop to be synced. + * + * @param workshopId ID of the workshop. + * @param assessmentId Assessment ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the object to be synced. + */ + async getEvaluateAssessment( + workshopId: number, + assessmentId: number, + siteId?: string, + ): Promise { + const site = await CoreSites.getSite(siteId); + + const conditions: Partial = { + workshopid: workshopId, + assessmentid: assessmentId, + }; + + const record = + await site.getDb().getRecord(EVALUATE_ASSESSMENTS_TABLE, conditions); + + return this.parseEvaluateAssessmentRecord(record); + } + + /** + * Offline version for evaluating an assessment to a workshop. + * + * @param workshopId Workshop ID. + * @param assessmentId Assessment ID. + * @param courseId Course ID the workshop belongs to. + * @param feedbackText The feedback for the reviewer. + * @param weight The new weight for the assessment. + * @param gradingGradeOver The new grading grade (empty for no overriding the grade). + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when assessment evaluation is successfully saved. + */ + async saveEvaluateAssessment( + workshopId: number, + assessmentId: number, + courseId: number, + feedbackText?: string, + weight = 0, + gradingGradeOver?: string, + siteId?: string, + ): Promise { + const site = await CoreSites.getSite(siteId); + + const assessment: AddonModWorkshopEvaluateAssessmentDBRecord = { + workshopid: workshopId, + courseid: courseId, + assessmentid: assessmentId, + timemodified: CoreTimeUtils.timestamp(), + feedbacktext: feedbackText || '', + weight: weight, + gradinggradeover: JSON.stringify(gradingGradeOver), + }; + + await site.getDb().insertRecord(EVALUATE_ASSESSMENTS_TABLE, assessment); + } + + /** + * Parse "gradinggradeover" column of an evaluate assessment record. + * + * @param record Evaluate assessment record, modified in place. + */ + protected parseEvaluateAssessmentRecord( + record: AddonModWorkshopEvaluateAssessmentDBRecord, + ): AddonModWorkshopOfflineEvaluateAssessment { + return { + ...record, + gradinggradeover: CoreTextUtils.parseJSON(record.gradinggradeover), + }; + } + + /** + * Get the path to the folder where to store files for offline attachments in a workshop. + * + * @param workshopId Workshop ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the path. + */ + async getWorkshopFolder(workshopId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + const siteFolderPath = CoreFile.getSiteFolder(site.getId()); + const workshopFolderPath = 'offlineworkshop/' + workshopId + '/'; + + return CoreTextUtils.concatenatePaths(siteFolderPath, workshopFolderPath); + } + + /** + * Get the path to the folder where to store files for offline submissions. + * + * @param workshopId Workshop ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the path. + */ + async getSubmissionFolder(workshopId: number, siteId?: string): Promise { + const folderPath = await this.getWorkshopFolder(workshopId, siteId); + + return CoreTextUtils.concatenatePaths(folderPath, 'submission'); + } + + /** + * Get the path to the folder where to store files for offline assessment. + * + * @param workshopId Workshop ID. + * @param assessmentId Assessment ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the path. + */ + async getAssessmentFolder(workshopId: number, assessmentId: number, siteId?: string): Promise { + let folderPath = await this.getWorkshopFolder(workshopId, siteId); + + folderPath += 'assessment/'; + + return CoreTextUtils.concatenatePaths(folderPath, String(assessmentId)); + } + +} +export const AddonModWorkshopOffline = makeSingleton(AddonModWorkshopOfflineProvider); + +export type AddonModWorkshopOfflineSubmission = Omit & { + attachmentsid?: CoreFileUploaderStoreFilesResult; +}; + +export type AddonModWorkshopOfflineAssessment = Omit & { + inputdata: CoreFormFields; +}; + +export type AddonModWorkshopOfflineEvaluateSubmission = + Omit & { + published: boolean; + gradeover: string; + }; + +export type AddonModWorkshopOfflineEvaluateAssessment = + Omit & { + gradinggradeover: string; + }; diff --git a/src/addons/mod/workshop/services/workshop-sync.ts b/src/addons/mod/workshop/services/workshop-sync.ts new file mode 100644 index 000000000..73ab2e22c --- /dev/null +++ b/src/addons/mod/workshop/services/workshop-sync.ts @@ -0,0 +1,631 @@ +// (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 { CoreSyncBaseProvider, CoreSyncBlockedError } from '@classes/base-sync'; +import { CoreNetworkError } from '@classes/errors/network-error'; +import { CoreCourseLogHelper } from '@features/course/services/log-helper'; +import { CoreFileUploaderStoreFilesResult } from '@features/fileuploader/services/fileuploader'; +import { CoreApp } from '@services/app'; +import { CoreFileEntry } from '@services/file-helper'; +import { CoreSites } from '@services/sites'; +import { CoreSync } from '@services/sync'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { Translate, makeSingleton } from '@singletons'; +import { CoreEvents } from '@singletons/events'; +import { AddonModWorkshop, + AddonModWorkshopAction, + AddonModWorkshopData, + AddonModWorkshopProvider, + AddonModWorkshopSubmissionType, +} from './workshop'; +import { AddonModWorkshopHelper } from './workshop-helper'; +import { AddonModWorkshopOffline, + AddonModWorkshopOfflineAssessment, + AddonModWorkshopOfflineEvaluateAssessment, + AddonModWorkshopOfflineEvaluateSubmission, + AddonModWorkshopOfflineSubmission, +} from './workshop-offline'; + +/** + * Service to sync workshops. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModWorkshopSyncProvider extends CoreSyncBaseProvider { + + static readonly AUTO_SYNCED = 'addon_mod_workshop_autom_synced'; + static readonly MANUAL_SYNCED = 'addon_mod_workshop_manual_synced'; + + protected componentTranslatableString = 'workshop'; + + constructor() { + super('AddonModWorkshopSyncProvider'); + } + + /** + * Check if an workshop has data to synchronize. + * + * @param workshopId Workshop ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: true if has data to sync, false otherwise. + */ + hasDataToSync(workshopId: number, siteId?: string): Promise { + return AddonModWorkshopOffline.hasWorkshopOfflineData(workshopId, siteId); + } + + /** + * Try to synchronize all workshops that need it and haven't been synchronized in a while. + * + * @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 when the sync is done. + */ + syncAllWorkshops(siteId?: string, force?: boolean): Promise { + return this.syncOnSites('all workshops', this.syncAllWorkshopsFunc.bind(this, !!force), siteId); + } + + /** + * Sync all workshops on a site. + * + * @param force Wether to force sync not depending on last execution. + * @param siteId Site ID to sync. + * @return Promise resolved if sync is successful, rejected if sync fails. + */ + protected async syncAllWorkshopsFunc(force: boolean, siteId: string): Promise { + const workshopIds = await AddonModWorkshopOffline.getAllWorkshops(siteId); + + // Sync all workshops that haven't been synced for a while. + const promises = workshopIds.map(async (workshopId) => { + const data = force + ? await this.syncWorkshop(workshopId, siteId) + : await this.syncWorkshopIfNeeded(workshopId, siteId); + + if (data && data.updated) { + // Sync done. Send event. + CoreEvents.trigger(AddonModWorkshopSyncProvider.AUTO_SYNCED, { + workshopId: workshopId, + warnings: data.warnings, + }, siteId); + } + }); + + await Promise.all(promises); + } + + /** + * Sync a workshop only if a certain time has passed since the last time. + * + * @param workshopId Workshop ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the workshop is synced or if it doesn't need to be synced. + */ + async syncWorkshopIfNeeded(workshopId: number, siteId?: string): Promise { + const needed = await this.isSyncNeeded(workshopId, siteId); + + if (needed) { + return this.syncWorkshop(workshopId, siteId); + } + } + + /** + * Try to synchronize a workshop. + * + * @param workshopId Workshop ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if sync is successful, rejected otherwise. + */ + syncWorkshop(workshopId: number, siteId?: string): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + if (this.isSyncing(workshopId, siteId)) { + // There's already a sync ongoing for this discussion, return the promise. + return this.getOngoingSync(workshopId, siteId)!; + } + + // Verify that workshop isn't blocked. + if (CoreSync.isBlocked(AddonModWorkshopProvider.COMPONENT, workshopId, siteId)) { + this.logger.debug(`Cannot sync workshop '${workshopId}' because it is blocked.`); + + throw new CoreSyncBlockedError(Translate.instant('core.errorsyncblocked', { $a: this.componentTranslate })); + } + + this.logger.debug(`Try to sync workshop '${workshopId}' in site ${siteId}'`); + + const syncPromise = this.performSyncWorkshop(workshopId, siteId); + + return this.addOngoingSync(workshopId, syncPromise, siteId); + } + + /** + * Perform the workshop sync. + * + * @param workshopId Workshop ID. + * @param siteId Site ID. + * @return Promise resolved if sync is successful, rejected otherwise. + */ + protected async performSyncWorkshop(workshopId: number, siteId: string): Promise { + const result: AddonModWorkshopSyncResult = { + warnings: [], + updated: false, + }; + + // Sync offline logs. + await CoreUtils.ignoreErrors(CoreCourseLogHelper.syncActivity(AddonModWorkshopProvider.COMPONENT, workshopId, siteId)); + + // Get offline submissions to be sent. + const syncs = await Promise.all([ + // Get offline submissions to be sent. + CoreUtils.ignoreErrors(AddonModWorkshopOffline.getSubmissions(workshopId, siteId), []), + // Get offline submission assessments to be sent. + CoreUtils.ignoreErrors(AddonModWorkshopOffline.getAssessments(workshopId, siteId), []), + // Get offline submission evaluations to be sent. + CoreUtils.ignoreErrors(AddonModWorkshopOffline.getEvaluateSubmissions(workshopId, siteId), []), + // Get offline assessment evaluations to be sent. + CoreUtils.ignoreErrors(AddonModWorkshopOffline.getEvaluateAssessments(workshopId, siteId), []), + ]); + + let courseId: number | undefined; + + // Get courseId from the first object + for (const x in syncs) { + if (syncs[x].length > 0 && syncs[x][0].courseid) { + courseId = syncs[x][0].courseid; + break; + } + } + + if (!courseId) { + // Sync finished, set sync time. + await CoreUtils.ignoreErrors(this.setSyncTime(workshopId, siteId)); + + // Nothing to sync. + return result; + } + + if (!CoreApp.isOnline()) { + // Cannot sync in offline. + throw new CoreNetworkError(); + } + + const workshop = await AddonModWorkshop.getWorkshopById(courseId, workshopId, { siteId }); + + const submissionsActions: AddonModWorkshopOfflineSubmission[] = syncs[0]; + const assessments: AddonModWorkshopOfflineAssessment[] = syncs[1]; + const submissionEvaluations: AddonModWorkshopOfflineEvaluateSubmission[] = syncs[2]; + const assessmentEvaluations: AddonModWorkshopOfflineEvaluateAssessment[] = syncs[3]; + + const promises: Promise[] = []; + + promises.push(this.syncSubmission(workshop, submissionsActions, result, siteId).then(() => { + result.updated = true; + + return; + })); + + assessments.forEach((assessment) => { + promises.push(this.syncAssessment(workshop, assessment, result, siteId).then(() => { + result.updated = true; + + return; + })); + }); + + submissionEvaluations.forEach((evaluation) => { + promises.push(this.syncEvaluateSubmission(workshop, evaluation, result, siteId).then(() => { + result.updated = true; + + return; + })); + }); + + assessmentEvaluations.forEach((evaluation) => { + promises.push(this.syncEvaluateAssessment(workshop, evaluation, result, siteId).then(() => { + result.updated = true; + + return; + })); + }); + + await Promise.all(promises); + + if (result.updated) { + // Data has been sent to server. Now invalidate the WS calls. + await CoreUtils.ignoreErrors(AddonModWorkshop.invalidateContentById(workshopId, courseId, siteId)); + } + + // Sync finished, set sync time. + await CoreUtils.ignoreErrors(this.setSyncTime(workshopId, siteId)); + + // All done, return the warnings. + return result; + } + + /** + * Synchronize a submission. + * + * @param workshop Workshop. + * @param submissionActions Submission actions offline data. + * @param result Object with the result of the sync. + * @param siteId Site ID. + * @return Promise resolved if success, rejected otherwise. + */ + protected async syncSubmission( + workshop: AddonModWorkshopData, + submissionActions: AddonModWorkshopOfflineSubmission[], + result: AddonModWorkshopSyncResult, + siteId: string, + ): Promise { + let discardError: string | undefined; + + // Sort entries by timemodified. + submissionActions = submissionActions.sort((a, b) => a.timemodified - b.timemodified); + + let timemodified = 0; + let submissionId = submissionActions[0].submissionid; + + if (submissionId > 0) { + // Is editing. + try { + const submission = await AddonModWorkshop.getSubmission(workshop.id, submissionId, { + cmId: workshop.coursemodule, + siteId, + }); + + timemodified = submission.timemodified; + } catch { + timemodified = -1; + } + } + + if (timemodified < 0 || timemodified >= submissionActions[0].timemodified) { + // The entry was not found in Moodle or the entry has been modified, discard the action. + result.updated = true; + discardError = Translate.instant('addon.mod_workshop.warningsubmissionmodified'); + + await AddonModWorkshopOffline.deleteAllSubmissionActions(workshop.id, siteId); + + this.addOfflineDataDeletedWarning(result.warnings, workshop.name, discardError); + + return; + } + + submissionActions.forEach(async (action) => { + submissionId = action.submissionid > 0 ? action.submissionid : submissionId; + + try { + let attachmentsId: number | undefined; + + // Upload attachments first if any. + if (action.attachmentsid) { + const files = await AddonModWorkshopHelper.getSubmissionFilesFromOfflineFilesObject( + action.attachmentsid, + workshop.id, + siteId, + ); + + attachmentsId = await AddonModWorkshopHelper.uploadOrStoreSubmissionFiles( + workshop.id, + files, + false, + siteId, + ); + } else { + // Remove all files. + attachmentsId = await AddonModWorkshopHelper.uploadOrStoreSubmissionFiles( + workshop.id, + [], + false, + siteId, + ); + } + + if (workshop.submissiontypefile == AddonModWorkshopSubmissionType.SUBMISSION_TYPE_DISABLED) { + attachmentsId = undefined; + } + + // Perform the action. + switch (action.action) { + case AddonModWorkshopAction.ADD: + submissionId = await AddonModWorkshop.addSubmissionOnline( + workshop.id, + action.title, + action.content, + attachmentsId, + siteId, + ); + case AddonModWorkshopAction.UPDATE: + await AddonModWorkshop.updateSubmissionOnline( + submissionId, + action.title, + action.content, + attachmentsId, + siteId, + ); + case AddonModWorkshopAction.DELETE: + await AddonModWorkshop.deleteSubmissionOnline(submissionId, siteId); + } + } catch (error) { + if (error && CoreUtils.isWebServiceError(error)) { + // The WebService has thrown an error, this means it cannot be performed. Discard. + discardError = CoreTextUtils.getErrorMessageFromError(error); + } + + // Couldn't connect to server, reject. + throw error; + + } + // Delete the offline data. + result.updated = true; + + await AddonModWorkshopOffline.deleteSubmissionAction( + action.workshopid, + action.action, + siteId, + ); + + // Delete stored files. + if (action.action == AddonModWorkshopAction.ADD || action.action == AddonModWorkshopAction.UPDATE) { + + return AddonModWorkshopHelper.deleteSubmissionStoredFiles( + action.workshopid, + siteId, + ); + } + }); + + if (discardError) { + // Submission was discarded, add a warning. + this.addOfflineDataDeletedWarning(result.warnings, workshop.name, discardError); + } + } + + /** + * Synchronize an assessment. + * + * @param workshop Workshop. + * @param assessment Assessment offline data. + * @param result Object with the result of the sync. + * @param siteId Site ID. + * @return Promise resolved if success, rejected otherwise. + */ + protected async syncAssessment( + workshop: AddonModWorkshopData, + assessmentData: AddonModWorkshopOfflineAssessment, + result: AddonModWorkshopSyncResult, + siteId: string, + ): Promise { + let discardError: string | undefined; + const assessmentId = assessmentData.assessmentid; + + let timemodified = 0; + + try { + const assessment = await AddonModWorkshop.getAssessment(workshop.id, assessmentId, { + cmId: workshop.coursemodule, + siteId, + }); + + timemodified = assessment.timemodified; + } catch { + timemodified = -1; + } + + if (timemodified < 0 || timemodified >= assessmentData.timemodified) { + // The entry was not found in Moodle or the entry has been modified, discard the action. + result.updated = true; + discardError = Translate.instant('addon.mod_workshop.warningassessmentmodified'); + + await AddonModWorkshopOffline.deleteAssessment(workshop.id, assessmentId, siteId); + + this.addOfflineDataDeletedWarning(result.warnings, workshop.name, discardError); + + return; + } + + let attachmentsId = 0; + const inputData = assessmentData.inputdata; + + try { + let files: CoreFileEntry[] = []; + // Upload attachments first if any. + if (inputData.feedbackauthorattachmentsid) { + files = await AddonModWorkshopHelper.getAssessmentFilesFromOfflineFilesObject( + inputData.feedbackauthorattachmentsid, + workshop.id, + assessmentId, + siteId, + ); + } + + attachmentsId = + await AddonModWorkshopHelper.uploadOrStoreAssessmentFiles(workshop.id, assessmentId, files, false, siteId); + + inputData.feedbackauthorattachmentsid = attachmentsId || 0; + + await AddonModWorkshop.updateAssessmentOnline(assessmentId, inputData, siteId); + } catch (error) { + if (error && CoreUtils.isWebServiceError(error)) { + // The WebService has thrown an error, this means it cannot be performed. Discard. + discardError = CoreTextUtils.getErrorMessageFromError(error); + } else { + // Couldn't connect to server, reject. + throw error; + } + } + + // Delete the offline data. + result.updated = true; + + await AddonModWorkshopOffline.deleteAssessment(workshop.id, assessmentId, siteId); + await AddonModWorkshopHelper.deleteAssessmentStoredFiles(workshop.id, assessmentId, siteId); + + if (discardError) { + // Assessment was discarded, add a warning. + this.addOfflineDataDeletedWarning(result.warnings, workshop.name, discardError); + } + } + + /** + * Synchronize a submission evaluation. + * + * @param workshop Workshop. + * @param evaluate Submission evaluation offline data. + * @param result Object with the result of the sync. + * @param siteId Site ID. + * @return Promise resolved if success, rejected otherwise. + */ + protected async syncEvaluateSubmission( + workshop: AddonModWorkshopData, + evaluate: AddonModWorkshopOfflineEvaluateSubmission, + result: AddonModWorkshopSyncResult, + siteId: string, + ): Promise { + let discardError: string | undefined; + const submissionId = evaluate.submissionid; + + let timemodified = 0; + + try { + const submission = await AddonModWorkshop.getSubmission(workshop.id, submissionId, { + cmId: workshop.coursemodule, + siteId, + }); + + timemodified = submission.timemodified; + } catch { + timemodified = -1; + } + + if (timemodified < 0 || timemodified >= evaluate.timemodified) { + // The entry was not found in Moodle or the entry has been modified, discard the action. + result.updated = true; + discardError = Translate.instant('addon.mod_workshop.warningsubmissionmodified'); + + await AddonModWorkshopOffline.deleteEvaluateSubmission(workshop.id, submissionId, siteId); + + this.addOfflineDataDeletedWarning(result.warnings, workshop.name, discardError); + + return; + } + + try { + await AddonModWorkshop.evaluateSubmissionOnline( + submissionId, + evaluate.feedbacktext, + evaluate.published, + evaluate.gradeover, + siteId, + ); + } catch (error) { + if (error && CoreUtils.isWebServiceError(error)) { + // The WebService has thrown an error, this means it cannot be performed. Discard. + discardError = CoreTextUtils.getErrorMessageFromError(error); + } else { + // Couldn't connect to server, reject. + throw error; + } + } + + // Delete the offline data. + result.updated = true; + + await AddonModWorkshopOffline.deleteEvaluateSubmission(workshop.id, submissionId, siteId); + + if (discardError) { + // Assessment was discarded, add a warning. + this.addOfflineDataDeletedWarning(result.warnings, workshop.name, discardError); + } + } + + /** + * Synchronize a assessment evaluation. + * + * @param workshop Workshop. + * @param evaluate Assessment evaluation offline data. + * @param result Object with the result of the sync. + * @param siteId Site ID. + * @return Promise resolved if success, rejected otherwise. + */ + protected async syncEvaluateAssessment( + workshop: AddonModWorkshopData, + evaluate: AddonModWorkshopOfflineEvaluateAssessment, + result: AddonModWorkshopSyncResult, + siteId: string, + ): Promise { + let discardError: string | undefined; + const assessmentId = evaluate.assessmentid; + + let timemodified = 0; + + try { + const assessment = await AddonModWorkshop.getAssessment(workshop.id, assessmentId, { + cmId: workshop.coursemodule, + siteId, + }); + + timemodified = assessment.timemodified; + } catch { + timemodified = -1; + } + + if (timemodified < 0 || timemodified >= evaluate.timemodified) { + // The entry was not found in Moodle or the entry has been modified, discard the action. + result.updated = true; + discardError = Translate.instant('addon.mod_workshop.warningassessmentmodified'); + + return AddonModWorkshopOffline.deleteEvaluateAssessment(workshop.id, assessmentId, siteId); + } + + try { + await AddonModWorkshop.evaluateAssessmentOnline( + assessmentId, + evaluate.feedbacktext, + evaluate.weight, + evaluate.gradinggradeover, + siteId, + ); + } catch (error) { + if (error && CoreUtils.isWebServiceError(error)) { + // The WebService has thrown an error, this means it cannot be performed. Discard. + discardError = CoreTextUtils.getErrorMessageFromError(error); + } else { + // Couldn't connect to server, reject. + throw error; + } + } + + // Delete the offline data. + result.updated = true; + + await AddonModWorkshopOffline.deleteEvaluateAssessment(workshop.id, assessmentId, siteId); + + if (discardError) { + // Assessment was discarded, add a warning. + this.addOfflineDataDeletedWarning(result.warnings, workshop.name, discardError); + } + } + +} +export const AddonModWorkshopSync = makeSingleton(AddonModWorkshopSyncProvider); + +export type AddonModWorkshopAutoSyncData = { + workshopId: number; + warnings: string[]; +}; + +export type AddonModWorkshopSyncResult = { + warnings: string[]; + updated: boolean; +}; diff --git a/src/addons/mod/workshop/services/workshop.ts b/src/addons/mod/workshop/services/workshop.ts new file mode 100644 index 000000000..c4432cec8 --- /dev/null +++ b/src/addons/mod/workshop/services/workshop.ts @@ -0,0 +1,2065 @@ +// (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 { CoreSite, CoreSiteWSPreSets } from '@classes/site'; +import { CoreCourseCommonModWSOptions } from '@features/course/services/course'; +import { CoreCourseLogHelper } from '@features/course/services/log-helper'; +import { CoreFileUploaderStoreFilesResult } from '@features/fileuploader/services/fileuploader'; +import { CoreGradesMenuItem } from '@features/grades/services/grades-helper'; +import { CoreApp } from '@services/app'; +import { CoreSites, CoreSitesCommonWSOptions, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreTextFormat, defaultTextFormat } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreStatusWithWarningsWSResponse, CoreWS, CoreWSExternalFile, CoreWSExternalWarning } from '@services/ws'; +import { makeSingleton } from '@singletons'; +import { CoreFormFields } from '@singletons/form'; +import { AddonModWorkshopOffline } from './workshop-offline'; +import { AddonModWorkshopAutoSyncData, AddonModWorkshopSyncProvider } from './workshop-sync'; + +const ROOT_CACHE_KEY = 'mmaModWorkshop:'; + +export enum AddonModWorkshopPhase { + PHASE_SETUP = 10, + PHASE_SUBMISSION = 20, + PHASE_ASSESSMENT = 30, + PHASE_EVALUATION = 40, + PHASE_CLOSED = 50, +} + +export enum AddonModWorkshopSubmissionType { + SUBMISSION_TYPE_DISABLED = 0, + SUBMISSION_TYPE_AVAILABLE = 1, + SUBMISSION_TYPE_REQUIRED = 2, +} + +export enum AddonModWorkshopExampleMode { + EXAMPLES_VOLUNTARY = 0, + EXAMPLES_BEFORE_SUBMISSION = 1, + EXAMPLES_BEFORE_ASSESSMENT = 2, +} + +export enum AddonModWorkshopAction { + ADD = 'add', + DELETE = 'delete', + UPDATE = 'update', +} + +export enum AddonModWorkshopAssessmentMode { + ASSESSMENT = 'assessment', + PREVIEW = 'preview', +} + +export enum AddonModWorkshopOverallFeedbackMode { + DISABLED = 0, + ENABLED_OPTIONAL = 1, + ENABLED_REQUIRED = 2, +} + +declare module '@singletons/events' { + + /** + * Augment CoreEventsData interface with events specific to this service. + * + * @see https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation + */ + export interface CoreEventsData { + [AddonModWorkshopSyncProvider.AUTO_SYNCED]: AddonModWorkshopAutoSyncData; + [AddonModWorkshopProvider.SUBMISSION_CHANGED]: AddonModWorkshopSubmissionChangedEventData; + [AddonModWorkshopProvider.ASSESSMENT_SAVED]: AddonModWorkshopAssessmentSavedChangedEventData; + [AddonModWorkshopProvider.ASSESSMENT_INVALIDATED]: AddonModWorkshopAssessmentInvalidatedChangedEventData; + } +} + +/** + * Service that provides some features for workshops. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModWorkshopProvider { + + static readonly COMPONENT = 'mmaModWorkshop'; + static readonly PER_PAGE = 10; + + static readonly SUBMISSION_CHANGED = 'addon_mod_workshop_submission_changed'; + static readonly ASSESSMENT_SAVED = 'addon_mod_workshop_assessment_saved'; + static readonly ASSESSMENT_INVALIDATED = 'addon_mod_workshop_assessment_invalidated'; + + /** + * Get cache key for workshop data WS calls. + * + * @param courseId Course ID. + * @return Cache key. + */ + protected getWorkshopDataCacheKey(courseId: number): string { + return ROOT_CACHE_KEY + 'workshop:' + courseId; + } + + /** + * Get prefix cache key for all workshop activity data WS calls. + * + * @param workshopId Workshop ID. + * @return Cache key. + */ + protected getWorkshopDataPrefixCacheKey(workshopId: number): string { + return ROOT_CACHE_KEY + workshopId; + } + + /** + * Get cache key for workshop access information data WS calls. + * + * @param workshopId Workshop ID. + * @return Cache key. + */ + protected getWorkshopAccessInformationDataCacheKey(workshopId: number): string { + return this.getWorkshopDataPrefixCacheKey(workshopId) + ':access'; + } + + /** + * Get cache key for workshop user plan data WS calls. + * + * @param workshopId Workshop ID. + * @return Cache key. + */ + protected getUserPlanDataCacheKey(workshopId: number): string { + return this.getWorkshopDataPrefixCacheKey(workshopId) + ':userplan'; + } + + /** + * Get cache key for workshop submissions data WS calls. + * + * @param workshopId Workshop ID. + * @param userId User ID. + * @param groupId Group ID. + * @return Cache key. + */ + protected getSubmissionsDataCacheKey(workshopId: number, userId: number = 0, groupId: number = 0): string { + return this.getWorkshopDataPrefixCacheKey(workshopId) + ':submissions:' + userId + ':' + groupId; + } + + /** + * Get cache key for a workshop submission data WS calls. + * + * @param workshopId Workshop ID. + * @param submissionId Submission ID. + * @return Cache key. + */ + protected getSubmissionDataCacheKey(workshopId: number, submissionId: number): string { + return this.getWorkshopDataPrefixCacheKey(workshopId) + ':submission:' + submissionId; + } + + /** + * Get cache key for workshop grades data WS calls. + * + * @param workshopId Workshop ID. + * @return Cache key. + */ + protected getGradesDataCacheKey(workshopId: number): string { + return this.getWorkshopDataPrefixCacheKey(workshopId) + ':grades'; + } + + /** + * Get cache key for workshop grade report data WS calls. + * + * @param workshopId Workshop ID. + * @param groupId Group ID. + * @return Cache key. + */ + protected getGradesReportDataCacheKey(workshopId: number, groupId: number = 0): string { + return this.getWorkshopDataPrefixCacheKey(workshopId) + ':report:' + groupId; + } + + /** + * Get cache key for workshop submission assessments data WS calls. + * + * @param workshopId Workshop ID. + * @param submissionId Submission ID. + * @return Cache key. + */ + protected getSubmissionAssessmentsDataCacheKey(workshopId: number, submissionId: number): string { + return this.getWorkshopDataPrefixCacheKey(workshopId) + ':assessments:' + submissionId; + } + + /** + * Get cache key for workshop reviewer assessments data WS calls. + * + * @param workshopId Workshop ID. + * @param userId User ID or current user. + * @return Cache key. + */ + protected getReviewerAssessmentsDataCacheKey(workshopId: number, userId: number = 0): string { + return this.getWorkshopDataPrefixCacheKey(workshopId) + ':reviewerassessments:' + userId; + } + + /** + * Get cache key for a workshop assessment data WS calls. + * + * @param workshopId Workshop ID. + * @param assessmentId Assessment ID. + * @return Cache key. + */ + protected getAssessmentDataCacheKey(workshopId: number, assessmentId: number): string { + return this.getWorkshopDataPrefixCacheKey(workshopId) + ':assessment:' + assessmentId; + } + + /** + * Get cache key for workshop assessment form data WS calls. + * + * @param workshopId Workshop ID. + * @param assessmentId Assessment ID. + * @param mode Mode assessment (default) or preview. + * @return Cache key. + */ + protected getAssessmentFormDataCacheKey(workshopId: number, assessmentId: number, mode: string = 'assessment'): string { + return this.getWorkshopDataPrefixCacheKey(workshopId) + ':assessmentsform:' + assessmentId + ':' + mode; + } + + /** + * Return whether or not the plugin is enabled in a certain site. Plugin is enabled if the workshop WS are available. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with true if plugin is enabled, rejected or resolved with false otherwise. + */ + async isPluginEnabled(siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + return site.wsAvailable('mod_workshop_get_workshops_by_courses') && + site.wsAvailable('mod_workshop_get_workshop_access_information'); + } + + /** + * Get a workshop 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 workshop is retrieved. + */ + protected async getWorkshopByKey( + courseId: number, + key: string, + value: number, + options: CoreSitesCommonWSOptions = {}, + ): Promise { + const site = await CoreSites.getSite(options.siteId); + + const params: AddonModWorkshopGetWorkshopsByCoursesWSParams = { + courseids: [courseId], + }; + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getWorkshopDataCacheKey(courseId), + updateFrequency: CoreSite.FREQUENCY_RARELY, + component: AddonModWorkshopProvider.COMPONENT, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + const response = await site.read( + 'mod_workshop_get_workshops_by_courses', + params, + preSets, + ); + + const workshop = response.workshops.find((workshop) => workshop[key] == value); + if (!workshop) { + throw new CoreError('Activity not found'); + } + + // Set submission types for Moodle 3.5 and older. + if (typeof workshop.submissiontypetext == 'undefined') { + if (typeof workshop.nattachments != 'undefined' && workshop.nattachments > 0) { + workshop.submissiontypetext = AddonModWorkshopSubmissionType.SUBMISSION_TYPE_AVAILABLE; + workshop.submissiontypefile = AddonModWorkshopSubmissionType.SUBMISSION_TYPE_AVAILABLE; + } else { + workshop.submissiontypetext = AddonModWorkshopSubmissionType.SUBMISSION_TYPE_REQUIRED; + workshop.submissiontypefile = AddonModWorkshopSubmissionType.SUBMISSION_TYPE_DISABLED; + } + } + + return workshop; + } + + /** + * Get a workshop by course module ID. + * + * @param courseId Course ID. + * @param cmId Course module ID. + * @param options Other options. + * @return Promise resolved when the workshop is retrieved. + */ + getWorkshop(courseId: number, cmId: number, options: CoreSitesCommonWSOptions = {}): Promise { + return this.getWorkshopByKey(courseId, 'coursemodule', cmId, options); + } + + /** + * Get a workshop by ID. + * + * @param courseId Course ID. + * @param id Workshop ID. + * @param options Other options. + * @return Promise resolved when the workshop is retrieved. + */ + getWorkshopById(courseId: number, id: number, options: CoreSitesCommonWSOptions = {}): Promise { + return this.getWorkshopByKey(courseId, 'id', id, options); + } + + /** + * Invalidates workshop data. + * + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the workshop is invalidated. + */ + async invalidateWorkshopData(courseId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getWorkshopDataCacheKey(courseId)); + } + + /** + * Invalidates workshop data except files and module info. + * + * @param workshopId Workshop ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the workshop is invalidated. + */ + async invalidateWorkshopWSData(workshopId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getWorkshopDataPrefixCacheKey(workshopId)); + } + + /** + * Get access information for a given workshop. + * + * @param workshopId Workshop ID. + * @param options Other options. + * @return Promise resolved when the workshop is retrieved. + */ + async getWorkshopAccessInformation( + workshopId: number, + options: CoreCourseCommonModWSOptions = {}, + ): Promise { + const site = await CoreSites.getSite(options.siteId); + + const params: AddonModWorkshopGetWorkshopAccessInformationWSParams = { + workshopid: workshopId, + }; + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getWorkshopAccessInformationDataCacheKey(workshopId), + component: AddonModWorkshopProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + return site.read( + 'mod_workshop_get_workshop_access_information', + params, + preSets, + ); + } + + /** + * Invalidates workshop access information data. + * + * @param workshopId Workshop ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateWorkshopAccessInformationData(workshopId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getWorkshopAccessInformationDataCacheKey(workshopId)); + } + + /** + * Return the planner information for the given user. + * + * @param workshopId Workshop ID. + * @param options Other options. + * @return Promise resolved when the workshop data is retrieved. + */ + async getUserPlanPhases( + workshopId: number, + options: CoreCourseCommonModWSOptions = {}, + ): Promise> { + const site = await CoreSites.getSite(options.siteId); + + const params: AddonModWorkshopGetUserPlanWSParams = { + workshopid: workshopId, + }; + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getUserPlanDataCacheKey(workshopId), + updateFrequency: CoreSite.FREQUENCY_OFTEN, + component: AddonModWorkshopProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const response = await site.read('mod_workshop_get_user_plan', params, preSets); + + return CoreUtils.arrayToObject(response.userplan.phases, 'code'); + } + + /** + * Invalidates workshop user plan data. + * + * @param workshopId Workshop ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateUserPlanPhasesData(workshopId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + await site.invalidateWsCacheForKey(this.getUserPlanDataCacheKey(workshopId)); + } + + /** + * Retrieves all the workshop submissions visible by the current user or the one done by the given user. + * + * @param workshopId Workshop ID. + * @param options Other options. + * @return Promise resolved when the workshop submissions are retrieved. + */ + async getSubmissions( + workshopId: number, + options: AddonModWorkshopGetSubmissionsOptions = {}, + ): Promise { + const userId = options.userId || 0; + const groupId = options.groupId || 0; + + const site = await CoreSites.getSite(options.siteId); + + const params: AddonModWorkshopGetSubmissionsWSParams = { + workshopid: workshopId, + userid: userId, + groupid: groupId, + }; + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getSubmissionsDataCacheKey(workshopId, userId, groupId), + updateFrequency: CoreSite.FREQUENCY_OFTEN, + component: AddonModWorkshopProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const response = await site.read('mod_workshop_get_submissions', params, preSets); + + return response.submissions; + } + + /** + * Invalidates workshop submissions data. + * + * @param workshopId Workshop ID. + * @param userId User ID. + * @param groupId Group ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateSubmissionsData(workshopId: number, userId: number = 0, groupId: number = 0, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getSubmissionsDataCacheKey(workshopId, userId, groupId)); + } + + /** + * Retrieves the given submission. + * + * @param workshopId Workshop ID. + * @param submissionId Submission ID. + * @param options Other options. + * @return Promise resolved when the workshop submission data is retrieved. + */ + async getSubmission( + workshopId: number, + submissionId: number, + options: CoreCourseCommonModWSOptions = {}, + ): Promise { + const site = await CoreSites.getSite(options.siteId); + + const params: AddonModWorkshopGetSubmissionWSParams = { + submissionid: submissionId, + }; + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getSubmissionDataCacheKey(workshopId, submissionId), + component: AddonModWorkshopProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const response = await site.read('mod_workshop_get_submission', params, preSets); + + return response.submission; + } + + /** + * Invalidates workshop submission data. + * + * @param workshopId Workshop ID. + * @param submissionId Submission ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateSubmissionData(workshopId: number, submissionId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getSubmissionDataCacheKey(workshopId, submissionId)); + } + + /** + * Returns the grades information for the given workshop and user. + * + * @param workshopId Workshop ID. + * @param options Other options. + * @return Promise resolved when the workshop grades data is retrieved. + */ + async getGrades(workshopId: number, options: CoreCourseCommonModWSOptions = {}): Promise { + const site = await CoreSites.getSite(options.siteId); + + const params: AddonModWorkshopGetGradesWSParams = { + workshopid: workshopId, + }; + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getGradesDataCacheKey(workshopId), + component: AddonModWorkshopProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + return site.read('mod_workshop_get_grades', params, preSets); + } + + /** + * Invalidates workshop grades data. + * + * @param workshopId Workshop ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateGradesData(workshopId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getGradesDataCacheKey(workshopId)); + } + + /** + * Retrieves the assessment grades report. + * + * @param workshopId Workshop ID. + * @param options Other options. + * @return Promise resolved when the workshop data is retrieved. + */ + async getGradesReport( + workshopId: number, + options: AddonModWorkshopGetGradesReportOptions = {}, + ): Promise { + const site = await CoreSites.getSite(options.siteId); + + const params: AddonModWorkshopGetGradesReportWSParams = { + workshopid: workshopId, + groupid: options.groupId, + page: options.page || 0, + perpage: options.perPage || AddonModWorkshopProvider.PER_PAGE, + }; + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getGradesReportDataCacheKey(workshopId, options.groupId), + updateFrequency: CoreSite.FREQUENCY_OFTEN, + component: AddonModWorkshopProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const response = + await site.read('mod_workshop_get_grades_report', params, preSets); + + return response.report; + } + + /** + * Performs the whole fetch of the grade reports in the workshop. + * + * @param workshopId Workshop ID. + * @param options Other options. + * @return Promise resolved when done. + */ + fetchAllGradeReports( + workshopId: number, + options: AddonModWorkshopFetchAllGradesReportOptions = {}, + ): Promise { + return this.fetchGradeReportsRecursive(workshopId, [], { + ...options, // Include all options. + page: 0, + perPage: options.perPage || AddonModWorkshopProvider.PER_PAGE, + siteId: options.siteId || CoreSites.getCurrentSiteId(), + }); + } + + /** + * Recursive call on fetch all grade reports. + * + * @param workshopId Workshop ID. + * @param grades Grades already fetched (just to concatenate them). + * @param options Other options. + * @return Promise resolved when done. + */ + protected async fetchGradeReportsRecursive( + workshopId: number, + grades: AddonModWorkshopGradesData[], + options: AddonModWorkshopGetGradesReportOptions = {}, + ): Promise { + const report = await this.getGradesReport(workshopId, options); + + Array.prototype.push.apply(grades, report.grades); + const canLoadMore = ((options.page! + 1) * options.perPage!) < report.totalcount; + + if (canLoadMore) { + options.page!++; + + return this.fetchGradeReportsRecursive(workshopId, grades, options); + } + + return grades; + } + + /** + * Invalidates workshop grade report data. + * + * @param workshopId Workshop ID. + * @param groupId Group ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateGradeReportData(workshopId: number, groupId: number = 0, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getGradesReportDataCacheKey(workshopId, groupId)); + } + + /** + * Retrieves the given submission assessment. + * + * @param workshopId Workshop ID. + * @param submissionId Submission ID. + * @param options Other options. + * @return Promise resolved when the workshop data is retrieved. + */ + async getSubmissionAssessments( + workshopId: number, + submissionId: number, + options: CoreCourseCommonModWSOptions = {}, + ): Promise { + + const site = await CoreSites.getSite(options.siteId); + + const params: AddonModWorkshopGetSubmissionAssessmentsWSParams = { + submissionid: submissionId, + }; + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getSubmissionAssessmentsDataCacheKey(workshopId, submissionId), + component: AddonModWorkshopProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + const response = await site.read( + 'mod_workshop_get_submission_assessments', + params, + preSets, + ); + + return response.assessments; + } + + /** + * Invalidates workshop submission assessments data. + * + * @param workshopId Workshop ID. + * @param submissionId Submission ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateSubmissionAssesmentsData(workshopId: number, submissionId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getSubmissionAssessmentsDataCacheKey(workshopId, submissionId)); + } + + /** + * Add a new submission to a given workshop. + * + * @param workshopId Workshop ID. + * @param courseId Course ID the workshop belongs to. + * @param title The submission title. + * @param content The submission text content. + * @param attachmentsId The draft file area id for attachments. + * @param siteId Site ID. If not defined, current site. + * @param allowOffline True if it can be stored in offline, false otherwise. + * @return Promise resolved with submission ID if sent online or false if stored offline. + */ + async addSubmission( + workshopId: number, + courseId: number, + title: string, + content: string, + attachmentsId?: number | CoreFileUploaderStoreFilesResult, + siteId?: string, + allowOffline: boolean = false, + ): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + // Convenience function to store a message to be synchronized later. + const storeOffline = async (): Promise => { + await AddonModWorkshopOffline.saveSubmission( + workshopId, + courseId, + title, + content, + attachmentsId as CoreFileUploaderStoreFilesResult, + undefined, + AddonModWorkshopAction.ADD, + siteId, + ); + + return false; + }; + + // If we are editing an offline submission, discard previous first. + await AddonModWorkshopOffline.deleteSubmissionAction(workshopId, AddonModWorkshopAction.ADD, siteId); + + if (!CoreApp.isOnline() && allowOffline) { + // App is offline, store the action. + return storeOffline(); + } + + try { + return await this.addSubmissionOnline(workshopId, title, content, attachmentsId as number, siteId); + } catch (error) { + if (allowOffline && !CoreUtils.isWebServiceError(error)) { + // Couldn't connect to server, store in offline. + return storeOffline(); + } + + // The WebService has thrown an error or offline not supported, reject. + throw error; + } + } + + /** + * Add a new submission to a given workshop. It will fail if offline or cannot connect. + * + * @param workshopId Workshop ID. + * @param title The submission title. + * @param content The submission text content. + * @param attachmentsId The draft file area id for attachments. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the submission is created. + */ + async addSubmissionOnline( + workshopId: number, + title: string, + content: string, + attachmentsId?: number, + siteId?: string, + ): Promise { + const site = await CoreSites.getSite(siteId); + + const params: AddonModWorkshopAddSubmissionWSParams = { + workshopid: workshopId, + title: title, + content: content, + attachmentsid: attachmentsId, + }; + + const response = await site.write('mod_workshop_add_submission', params); + + // Other errors ocurring. + CoreWS.throwOnFailedStatus(response, 'Add submission failed'); + + return response.submissionid!; + } + + /** + * Updates the given submission. + * + * @param workshopId Workshop ID. + * @param submissionId Submission ID. + * @param courseId Course ID the workshop belongs to. + * @param title The submission title. + * @param content The submission text content. + * @param attachmentsId The draft file area id for attachments. + * @param siteId Site ID. If not defined, current site. + * @param allowOffline True if it can be stored in offline, false otherwise. + * @return Promise resolved with submission ID if sent online or false if stored offline. + */ + async updateSubmission( + workshopId: number, + submissionId: number, + courseId: number, + title: string, + content: string, + attachmentsId?: CoreFileUploaderStoreFilesResult | number | undefined, + siteId?: string, + allowOffline: boolean = false, + ): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + // Convenience function to store a message to be synchronized later. + const storeOffline = async (): Promise => { + await AddonModWorkshopOffline.saveSubmission( + workshopId, + courseId, + title, + content, + attachmentsId as CoreFileUploaderStoreFilesResult, + submissionId, + AddonModWorkshopAction.UPDATE, + siteId, + ); + + return false; + }; + + // If we are editing an offline discussion, discard previous first. + await AddonModWorkshopOffline.deleteSubmissionAction(workshopId, AddonModWorkshopAction.UPDATE, siteId); + + if (!CoreApp.isOnline() && allowOffline) { + // App is offline, store the action. + return storeOffline(); + } + + try { + return await this.updateSubmissionOnline(submissionId, title, content, attachmentsId as number, siteId); + } catch (error) { + if (allowOffline && !CoreUtils.isWebServiceError(error)) { + // Couldn't connect to server, store in offline. + return storeOffline(); + } + + // The WebService has thrown an error or offline not supported, reject. + throw error; + } + } + + /** + * Updates the given submission. It will fail if offline or cannot connect. + * + * @param submissionId Submission ID. + * @param title The submission title. + * @param content The submission text content. + * @param attachmentsId The draft file area id for attachments. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the submission is updated. + */ + async updateSubmissionOnline( + submissionId: number, + title: string, + content: string, + attachmentsId?: number, + siteId?: string, + ): Promise { + const site = await CoreSites.getSite(siteId); + + const params: AddonModWorkshopUpdateSubmissionWSParams = { + submissionid: submissionId, + title: title, + content: content, + attachmentsid: attachmentsId || 0, + }; + + const response = await site.write('mod_workshop_update_submission', params); + + // Other errors ocurring. + CoreWS.throwOnFailedStatus(response, 'Update submission failed'); + + return submissionId; + } + + /** + * Deletes the given submission. + * + * @param workshopId Workshop ID. + * @param submissionId Submission ID. + * @param courseId Course ID the workshop belongs to. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with submission ID if sent online, resolved with false if stored offline. + */ + async deleteSubmission(workshopId: number, submissionId: number, courseId: number, siteId?: string): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + // Convenience function to store a message to be synchronized later. + const storeOffline = (): Promise => AddonModWorkshopOffline.saveSubmission( + workshopId, + courseId, + '', + '', + undefined, + submissionId, + AddonModWorkshopAction.DELETE, + siteId, + ); + + // If we are editing an offline discussion, discard previous first. + await AddonModWorkshopOffline.deleteSubmissionAction(workshopId, AddonModWorkshopAction.DELETE, siteId); + + if (!CoreApp.isOnline()) { + // App is offline, store the action. + return storeOffline(); + } + + try { + return await this.deleteSubmissionOnline(submissionId, siteId); + } catch (error) { + if (!CoreUtils.isWebServiceError(error)) { + // Couldn't connect to server, store in offline. + return storeOffline(); + } + + // The WebService has thrown an error or offline not supported, reject. + throw error; + } + } + + /** + * Deletes the given submission. It will fail if offline or cannot connect. + * + * @param submissionId Submission ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the submission is deleted. + */ + async deleteSubmissionOnline(submissionId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + const params: AddonModWorkshopDeleteSubmissionWSParams = { + submissionid: submissionId, + }; + + const response = await site.write('mod_workshop_delete_submission', params); + + // Other errors ocurring. + CoreWS.throwOnFailedStatus(response, 'Delete submission failed'); + } + + /** + * Retrieves all the assessments reviewed by the given user. + * + * @param workshopId Workshop ID. + * @param options Other options. + * @return Promise resolved when the workshop data is retrieved. + */ + async getReviewerAssessments( + workshopId: number, + options: AddonModWorkshopUserOptions = {}, + ): Promise { + const site = await CoreSites.getSite(options.siteId); + + const params: AddonModWorkshopGetReviewerAssessmentsWSParams = { + workshopid: workshopId, + }; + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getReviewerAssessmentsDataCacheKey(workshopId, options.userId), + component: AddonModWorkshopProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + if (options.userId) { + params.userid = options.userId; + } + + const response = + await site.read('mod_workshop_get_reviewer_assessments', params, preSets); + + return response.assessments; + } + + /** + * Invalidates workshop user assessments data. + * + * @param workshopId Workshop ID. + * @param userId User ID. If not defined, current user. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateReviewerAssesmentsData(workshopId: number, userId?: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getReviewerAssessmentsDataCacheKey(workshopId, userId)); + } + + /** + * Retrieves the given assessment. + * + * @param workshopId Workshop ID. + * @param assessmentId Assessment ID. + * @param options Other options. + * @return Promise resolved when the workshop data is retrieved. + */ + async getAssessment( + workshopId: number, + assessmentId: number, + options: CoreCourseCommonModWSOptions = {}, + ): Promise { + const site = await CoreSites.getSite(options.siteId); + + const params: AddonModWorkshopGetAssessmentWSParams = { + assessmentid: assessmentId, + }; + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getAssessmentDataCacheKey(workshopId, assessmentId), + component: AddonModWorkshopProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + const response = await site.read('mod_workshop_get_assessment', params, preSets); + + return response.assessment; + } + + /** + * Invalidates workshop assessment data. + * + * @param workshopId Workshop ID. + * @param assessmentId Assessment ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateAssessmentData(workshopId: number, assessmentId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getAssessmentDataCacheKey(workshopId, assessmentId)); + } + + /** + * Retrieves the assessment form definition (data required to be able to display the assessment form). + * + * @param workshopId Workshop ID. + * @param assessmentId Assessment ID. + * @param options Other options. + * @return Promise resolved when the workshop data is retrieved. + */ + async getAssessmentForm( + workshopId: number, + assessmentId: number, + options: AddonModWorkshopGetAssessmentFormOptions = {}, + ): Promise { + const mode = options.mode || AddonModWorkshopAssessmentMode.ASSESSMENT; + + const site = await CoreSites.getSite(options.siteId); + + const params: AddonModWorkshopGetAssessmentFormDefinitionWSParams = { + assessmentid: assessmentId, + mode: mode, + }; + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getAssessmentFormDataCacheKey(workshopId, assessmentId, mode), + updateFrequency: CoreSite.FREQUENCY_RARELY, + component: AddonModWorkshopProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const response = await site.read( + 'mod_workshop_get_assessment_form_definition', + params, + preSets, + ); + + return { + dimenssionscount: response.dimenssionscount, + descriptionfiles: response.descriptionfiles, + dimensionsinfo: response.dimensionsinfo, + warnings: response.warnings, + fields: this.parseFields(response.fields), + current: this.parseFields(response.current), + options: CoreUtils.objectToKeyValueMap(response.options, 'name', 'value'), + }; + } + + /** + * Parse fieldes into a more handful format. + * + * @param fields Fields to parse + * @return Parsed fields + */ + parseFields(fields: AddonModWorkshopGetAssessmentFormFieldData[]): AddonModWorkshopGetAssessmentFormFieldsParsedData[] { + const parsedFields: AddonModWorkshopGetAssessmentFormFieldsParsedData[] = []; + + fields.forEach((field) => { + const args: string[] = field.name.split('_'); + const name = args[0]; + const idx = args[3]; + const idy = args[6] || false; + + if (parseInt(idx, 10) + '' == idx) { + if (!parsedFields[idx]) { + parsedFields[idx] = { + number: idx + 1, // eslint-disable-line id-blacklist + }; + } + + if (idy && parseInt(idy, 10) + '' == idy) { + if (!parsedFields[idx].fields) { + parsedFields[idx].fields = []; + } + + if (!parsedFields[idx].fields[idy]) { + parsedFields[idx].fields[idy] = { + number: idy + 1, // eslint-disable-line id-blacklist + }; + } + parsedFields[idx].fields[idy][name] = field.value; + } else { + parsedFields[idx][name] = field.value; + } + } + }); + + return parsedFields; + } + + /** + * Invalidates workshop assessments form data. + * + * @param workshopId Workshop ID. + * @param assessmentId Assessment ID. + * @param mode Mode assessment (default) or preview. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateAssessmentFormData( + workshopId: number, + assessmentId: number, + mode: string = 'assessment', + siteId?: string, + ): + Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getAssessmentFormDataCacheKey(workshopId, assessmentId, mode)); + } + + /** + * Updates the given assessment. + * + * @param workshopId Workshop ID. + * @param assessmentId Assessment ID. + * @param courseId Course ID the workshop belongs to. + * @param inputData Assessment data. + * @param siteId Site ID. If not defined, current site. + * @param allowOffline True if it can be stored in offline, false otherwise. + * @return Promise resolved with true if sent online, or false if stored offline. + */ + async updateAssessment( + workshopId: number, + assessmentId: number, + courseId: number, + inputData: CoreFormFields, + siteId?: string, + allowOffline = false, + ): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + // Convenience function to store a message to be synchronized later. + const storeOffline = async (): Promise => { + await AddonModWorkshopOffline.saveAssessment(workshopId, assessmentId, courseId, inputData, siteId); + + return false; + }; + + // If we are editing an offline discussion, discard previous first. + await AddonModWorkshopOffline.deleteAssessment(workshopId, assessmentId, siteId); + if (!CoreApp.isOnline() && allowOffline) { + // App is offline, store the action. + return storeOffline(); + } + try { + await this.updateAssessmentOnline(assessmentId, inputData, siteId); + + return true; + } catch (error) { + if (allowOffline && !CoreUtils.isWebServiceError(error)) { + // Couldn't connect to server, store in offline. + return storeOffline(); + } + + // The WebService has thrown an error or offline not supported, reject. + throw error; + } + } + + /** + * Updates the given assessment. It will fail if offline or cannot connect. + * + * @param assessmentId Assessment ID. + * @param inputData Assessment data. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the grade of the submission. + */ + async updateAssessmentOnline(assessmentId: number, inputData: CoreFormFields, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + const params: AddonModWorkshopUpdateAssessmentWSParams = { + assessmentid: assessmentId, + data: CoreUtils.objectToArrayOfObjects(inputData, 'name', 'value'), + }; + + const response = await site.write('mod_workshop_update_assessment', params); + + // Other errors ocurring. + CoreWS.throwOnFailedStatus(response, 'Update assessment failed'); + } + + /** + * Evaluates a submission (used by teachers for provide feedback or override the submission grade). + * + * @param workshopId Workshop ID. + * @param submissionId The submission id. + * @param courseId Course ID the workshop belongs to. + * @param feedbackText The feedback for the author. + * @param published Whether to publish the submission for other users. + * @param gradeOver The new submission grade (empty for no overriding the grade). + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when submission is evaluated if sent online, + * resolved with false if stored offline. + */ + async evaluateSubmission( + workshopId: number, + submissionId: number, + courseId: number, + feedbackText?: string, + published?: boolean, + gradeOver?: string, + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + // Convenience function to store a message to be synchronized later. + const storeOffline = (): Promise => AddonModWorkshopOffline.saveEvaluateSubmission( + workshopId, + submissionId, + courseId, + feedbackText, + published, + gradeOver, + siteId, + ).then(() => false); + + // If we are editing an offline discussion, discard previous first. + await AddonModWorkshopOffline.deleteEvaluateSubmission(workshopId, submissionId, siteId); + if (!CoreApp.isOnline()) { + // App is offline, store the action. + return storeOffline(); + } + + try { + return await this.evaluateSubmissionOnline(submissionId, feedbackText, published, gradeOver, siteId); + } catch (error) { + if (CoreUtils.isWebServiceError(error)) { + // The WebService has thrown an error or offline not supported, reject. + throw error; + } + + // Couldn't connect to server, store in offline. + return storeOffline(); + } + } + + /** + * Evaluates a submission (used by teachers for provide feedback or override the submission grade). + * It will fail if offline or cannot connect. + * + * @param submissionId The submission id. + * @param feedbackText The feedback for the author. + * @param published Whether to publish the submission for other users. + * @param gradeOver The new submission grade (empty for no overriding the grade). + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the submission is evaluated. + */ + async evaluateSubmissionOnline( + submissionId: number, + feedbackText?: string, + published?: boolean, + gradeOver?: string, + siteId?: string, + ): Promise { + const site = await CoreSites.getSite(siteId); + + const params: AddonModWorkshopEvaluateSubmissionWSParams = { + submissionid: submissionId, + feedbacktext: feedbackText || '', + feedbackformat: defaultTextFormat, + published: published, + gradeover: gradeOver, + }; + + const response = await site.write('mod_workshop_evaluate_submission', params); + + // Other errors ocurring. + CoreWS.throwOnFailedStatus(response, 'Evaluate submission failed'); + + return true; + } + + /** + * Evaluates an assessment (used by teachers for provide feedback to the reviewer). + * + * @param workshopId Workshop ID. + * @param assessmentId The assessment id. + * @param courseId Course ID the workshop belongs to. + * @param feedbackText The feedback for the reviewer. + * @param weight The new weight for the assessment. + * @param gradingGradeOver The new grading grade (empty for no overriding the grade). + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when assessment is evaluated if sent online, + * resolved with false if stored offline. + */ + async evaluateAssessment( + workshopId: number, + assessmentId: number, + courseId: number, + feedbackText?: string, + weight = 0, + gradingGradeOver?: string, + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + // Convenience function to store a message to be synchronized later. + const storeOffline = (): Promise => AddonModWorkshopOffline.saveEvaluateAssessment( + workshopId, + assessmentId, + courseId, + feedbackText, + weight, + gradingGradeOver, + siteId, + ).then(() => false); + + // If we are editing an offline discussion, discard previous first. + await AddonModWorkshopOffline.deleteEvaluateAssessment(workshopId, assessmentId, siteId); + if (!CoreApp.isOnline()) { + // App is offline, store the action. + return storeOffline(); + } + try { + return await this.evaluateAssessmentOnline(assessmentId, feedbackText, weight, gradingGradeOver, siteId); + } catch (error) { + if (!CoreUtils.isWebServiceError(error)) { + // Couldn't connect to server, store in offline. + return storeOffline(); + } + // The WebService has thrown an error or offline not supported, reject. + throw error; + } + } + + /** + * Evaluates an assessment (used by teachers for provide feedback to the reviewer). It will fail if offline or cannot connect. + * + * @param assessmentId The assessment id. + * @param feedbackText The feedback for the reviewer. + * @param weight The new weight for the assessment. + * @param gradingGradeOver The new grading grade (empty for no overriding the grade). + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the assessment is evaluated. + */ + async evaluateAssessmentOnline( + assessmentId: number, + feedbackText?: string, + weight?: number, + gradingGradeOver?: string, + siteId?: string, + ): Promise { + const site = await CoreSites.getSite(siteId); + + const params: AddonModWorkshopEvaluateAssessmentWSParams = { + assessmentid: assessmentId, + feedbacktext: feedbackText || '', + feedbackformat: defaultTextFormat, + weight: weight, + gradinggradeover: gradingGradeOver, + }; + + const response = await site.write('mod_workshop_evaluate_assessment', params); + + // Other errors ocurring. + CoreWS.throwOnFailedStatus(response, 'Evaluate assessment failed'); + + return true; + } + + /** + * Invalidate the prefetched content except files. + * + * @param moduleId The module ID. + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promised resolved when content is invalidated. + */ + async invalidateContent(moduleId: number, courseId: number, siteId?: string): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + const workshop = await this.getWorkshop(courseId, moduleId, { + readingStrategy: CoreSitesReadingStrategy.PreferCache, + siteId, + }); + + await this.invalidateContentById(workshop.id, courseId, siteId); + } + + /** + * Invalidate the prefetched content except files using the activityId. + * + * @param workshopId Workshop ID. + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when content is invalidated. + */ + async invalidateContentById(workshopId: number, courseId: number, siteId?: string): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + const promises = [ + // Do not invalidate workshop data before getting workshop info, we need it! + this.invalidateWorkshopData(courseId, siteId), + this.invalidateWorkshopWSData(workshopId, siteId), + ]; + + await Promise.all(promises); + } + + /** + * Report the workshop as being viewed. + * + * @param id Workshop ID. + * @param name Name of the workshop. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the WS call is successful. + */ + async logView(id: number, name?: string, siteId?: string): Promise { + const params: AddonModWorkshopViewWorkshopWSParams = { + workshopid: id, + }; + + await CoreCourseLogHelper.logSingle( + 'mod_workshop_view_workshop', + params, + AddonModWorkshopProvider.COMPONENT, + id, + name, + 'workshop', + {}, + siteId, + ); + } + + /** + * Report the workshop submission as being viewed. + * + * @param id Submission ID. + * @param workshopId Workshop ID. + * @param name Name of the workshop. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the WS call is successful. + */ + async logViewSubmission(id: number, workshopId: number, name?: string, siteId?: string): Promise { + const params: AddonModWorkshopViewSubmissionWSParams = { + submissionid: id, + }; + + await CoreCourseLogHelper.logSingle( + 'mod_workshop_view_submission', + params, + AddonModWorkshopProvider.COMPONENT, + workshopId, + name, + 'workshop', + params, + siteId, + ); + } + +} +export const AddonModWorkshop = makeSingleton(AddonModWorkshopProvider); + +/** + * Params of mod_workshop_view_workshop WS. + */ +type AddonModWorkshopViewWorkshopWSParams = { + workshopid: number; // Workshop instance id. +}; + +/** + * Params of mod_workshop_view_submission WS. + */ +type AddonModWorkshopViewSubmissionWSParams = { + submissionid: number; // Submission id. +}; + +/** + * Params of mod_workshop_get_workshops_by_courses WS. + */ +type AddonModWorkshopGetWorkshopsByCoursesWSParams = { + courseids?: number[]; // Array of course ids. +}; + +/** + * Data returned by mod_workshop_get_workshops_by_courses WS. + */ +type AddonModWorkshopGetWorkshopsByCoursesWSResponse = { + workshops: AddonModWorkshopData[]; + warnings?: CoreWSExternalWarning[]; +}; + +export type AddonModWorkshopData = { + id: number; // The primary key of the record. + course: number; // Course id this workshop is part of. + name: string; // Workshop name. + intro: string; // Workshop introduction text. + introformat?: CoreTextFormat; // Intro format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + instructauthors?: string; // Instructions for the submission phase. + instructauthorsformat?: CoreTextFormat; // Instructauthors format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + instructreviewers?: string; // Instructions for the assessment phase. + instructreviewersformat?: CoreTextFormat; // Instructreviewers format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + timemodified?: number; // The timestamp when the module was modified. + phase: AddonModWorkshopPhase; // The current phase of workshop. + useexamples?: boolean; // Optional feature: students practise evaluating on example submissions from teacher. + usepeerassessment?: boolean; // Optional feature: students perform peer assessment of others' work. + useselfassessment?: boolean; // Optional feature: students perform self assessment of their own work. + grade?: number; // The maximum grade for submission. + gradinggrade?: number; // The maximum grade for assessment. + strategy?: string; // The type of the current grading strategy used in this workshop. + evaluation?: string; // The recently used grading evaluation method. + gradedecimals?: number; // Number of digits that should be shown after the decimal point when displaying grades. + submissiontypetext?: AddonModWorkshopSubmissionType; // Indicates whether text is required as part of each submission. + // 0 for no, 1 for optional, 2 for required. + submissiontypefile?: AddonModWorkshopSubmissionType; // Indicates whether a file upload is required as part of each submission. + // 0 for no, 1 for optional, 2 for required. + nattachments?: number; // Maximum number of submission attachments. + submissionfiletypes?: string; // Comma separated list of file extensions. + latesubmissions?: boolean; // Allow submitting the work after the deadline. + maxbytes?: number; // Maximum size of the one attached file. + examplesmode?: AddonModWorkshopExampleMode; // 0 = example assessments are voluntary, + // 1 = examples must be assessed before submission, + // 2 = examples are available after own submission and must be assessed before peer/self assessment phase. + submissionstart?: number; // 0 = will be started manually, greater than 0 the timestamp of the start of the submission phase. + submissionend?: number; // 0 = will be closed manually, greater than 0 the timestamp of the end of the submission phase. + assessmentstart?: number; // 0 = will be started manually, greater than 0 the timestamp of the start of the assessment phase. + assessmentend?: number; // 0 = will be closed manually, greater than 0 the timestamp of the end of the assessment phase. + phaseswitchassessment?: boolean; // Automatically switch to the assessment phase after the submissions deadline. + conclusion?: string; // A text to be displayed at the end of the workshop. + conclusionformat?: CoreTextFormat; // Conclusion format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + overallfeedbackmode?: AddonModWorkshopOverallFeedbackMode; // Mode of the overall feedback support. + overallfeedbackfiles?: number; // Number of allowed attachments to the overall feedback. + overallfeedbackfiletypes?: string; // Comma separated list of file extensions. + overallfeedbackmaxbytes?: number; // Maximum size of one file attached to the overall feedback. + coursemodule: number; // Coursemodule. + introfiles: CoreWSExternalFile[]; // Introfiles. + instructauthorsfiles?: CoreWSExternalFile[]; // Instructauthorsfiles. + instructreviewersfiles?: CoreWSExternalFile[]; // Instructreviewersfiles. + conclusionfiles?: CoreWSExternalFile[]; // Conclusionfiles. +}; + +/** + * Params of mod_workshop_get_workshop_access_information WS. + */ +type AddonModWorkshopGetWorkshopAccessInformationWSParams = { + workshopid: number; // Workshop instance id. +}; + +/** + * Data returned by mod_workshop_get_workshop_access_information WS. + */ +export type AddonModWorkshopGetWorkshopAccessInformationWSResponse = { + creatingsubmissionallowed: boolean; // Is the given user allowed to create their submission?. + modifyingsubmissionallowed: boolean; // Is the user allowed to modify his existing submission?. + assessingallowed: boolean; // Is the user allowed to create/edit his assessments?. + assessingexamplesallowed: boolean; // Are reviewers allowed to create/edit their assessments of the example submissions?. + examplesassessedbeforesubmission: boolean; // Whether the given user has assessed all his required examples before submission + // (always true if there are not examples to assess or not configured to check before submission). + examplesassessedbeforeassessment: boolean; // Whether the given user has assessed all his required examples before assessment + // (always true if there are not examples to assessor not configured to check before assessment). + canview: boolean; // Whether the user has the capability mod/workshop:view allowed. + canaddinstance: boolean; // Whether the user has the capability mod/workshop:addinstance allowed. + canswitchphase: boolean; // Whether the user has the capability mod/workshop:switchphase allowed. + caneditdimensions: boolean; // Whether the user has the capability mod/workshop:editdimensions allowed. + cansubmit: boolean; // Whether the user has the capability mod/workshop:submit allowed. + canpeerassess: boolean; // Whether the user has the capability mod/workshop:peerassess allowed. + canmanageexamples: boolean; // Whether the user has the capability mod/workshop:manageexamples allowed. + canallocate: boolean; // Whether the user has the capability mod/workshop:allocate allowed. + canpublishsubmissions: boolean; // Whether the user has the capability mod/workshop:publishsubmissions allowed. + canviewauthornames: boolean; // Whether the user has the capability mod/workshop:viewauthornames allowed. + canviewreviewernames: boolean; // Whether the user has the capability mod/workshop:viewreviewernames allowed. + canviewallsubmissions: boolean; // Whether the user has the capability mod/workshop:viewallsubmissions allowed. + canviewpublishedsubmissions: boolean; // Whether the user has the capability mod/workshop:viewpublishedsubmissions allowed. + canviewauthorpublished: boolean; // Whether the user has the capability mod/workshop:viewauthorpublished allowed. + canviewallassessments: boolean; // Whether the user has the capability mod/workshop:viewallassessments allowed. + canoverridegrades: boolean; // Whether the user has the capability mod/workshop:overridegrades allowed. + canignoredeadlines: boolean; // Whether the user has the capability mod/workshop:ignoredeadlines allowed. + candeletesubmissions: boolean; // Whether the user has the capability mod/workshop:deletesubmissions allowed. + canexportsubmissions: boolean; // Whether the user has the capability mod/workshop:exportsubmissions allowed. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_workshop_get_user_plan WS. + */ +type AddonModWorkshopGetUserPlanWSParams = { + workshopid: number; // Workshop instance id. + userid?: number; // User id (empty or 0 for current user). +}; + +/** + * Data returned by mod_workshop_get_user_plan WS. + */ +type AddonModWorkshopGetUserPlanWSResponse = { + userplan: { + phases: AddonModWorkshopPhaseData[]; + examples: { + id: number; // Example submission id. + title: string; // Example submission title. + assessmentid: number; // Example submission assessment id. + grade: number; // The submission grade. + gradinggrade: number; // The assessment grade. + }[]; + }; + warnings?: CoreWSExternalWarning[]; +}; + +export type AddonModWorkshopPhaseData = { + code: AddonModWorkshopPhase; // Phase code. + title: string; // Phase title. + active: boolean; // Whether is the active task. + tasks: AddonModWorkshopPhaseTaskData[]; + actions: AddonModWorkshopPhaseActionData[]; +}; + +export type AddonModWorkshopPhaseTaskData = { + code: string; // Task code. + title: string; // Task title. + link: string; // Link to task. + details?: string; // Task details. + completed: string; // Completion information (maybe empty, maybe a boolean or generic info). +}; + +export type AddonModWorkshopPhaseActionData = { + type?: string; // Action type. + label?: string; // Action label. + url: string; // Link to action. + method?: string; // Get or post. +}; + +/** + * Params of mod_workshop_get_submissions WS. + */ +type AddonModWorkshopGetSubmissionsWSParams = { + workshopid: number; // Workshop instance id. + userid?: number; // Get submissions done by this user. Use 0 or empty for the current user. + groupid?: number; // Group id, 0 means that the function will determine the user group. + // It will return submissions done by users in the given group. + page?: number; // The page of records to return. + perpage?: number; // The number of records to return per page. +}; + +/** + * Data returned by mod_workshop_get_submissions WS. + */ +type AddonModWorkshopGetSubmissionsWSResponse = { + submissions: AddonModWorkshopSubmissionData[]; + totalcount: number; // Total count of submissions. + totalfilesize: number; // Total size (bytes) of the files attached to all the submissions (even the ones not returned due + // to pagination). + warnings?: CoreWSExternalWarning[]; +}; + +export type AddonModWorkshopSubmissionData = { + id: number; // The primary key of the record. + workshopid: number; // The id of the workshop instance. + example: boolean; // Is this submission an example from teacher. + authorid: number; // The author of the submission. + timecreated: number; // Timestamp when the work was submitted for the first time. + timemodified: number; // Timestamp when the submission has been updated. + title: string; // The submission title. + content: string; // Submission text. + contentformat?: CoreTextFormat; // Content format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + contenttrust: number; // The trust mode of the data. + attachment: number; // Used by File API file_postupdate_standard_filemanager. + grade?: number; // Aggregated grade for the submission. The grade is a decimal number from interval 0..100. + // If NULL then the grade for submission has not been aggregated yet. + gradeover?: number; // Grade for the submission manually overridden by a teacher. Grade is always from interval 0..100. + // If NULL then the grade is not overriden. + gradeoverby?: number; // The id of the user who has overridden the grade for submission. + feedbackauthor?: string; // Teacher comment/feedback for the author of the submission, for example describing the reasons + // for the grade overriding. + feedbackauthorformat?: CoreTextFormat; // Feedbackauthor format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + timegraded?: number; // The timestamp when grade or gradeover was recently modified. + published: boolean; // Shall the submission be available to other when the workshop is closed. + late: number; // Has this submission been submitted after the deadline or during the assessment phase?. + contentfiles?: CoreWSExternalFile[]; // Contentfiles. + attachmentfiles?: CoreWSExternalFile[]; // Attachmentfiles. +}; + +/** + * Params of mod_workshop_get_submission WS. + */ +type AddonModWorkshopGetSubmissionWSParams = { + submissionid: number; // Submission id. +}; + +/** + * Data returned by mod_workshop_get_submission WS. + */ +type AddonModWorkshopGetSubmissionWSResponse = { + submission: AddonModWorkshopSubmissionData; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_workshop_get_grades WS. + */ +type AddonModWorkshopGetGradesWSParams = { + workshopid: number; // Workshop instance id. + userid?: number; // User id (empty or 0 for current user). +}; + +/** + * Data returned by mod_workshop_get_grades WS. + */ +export type AddonModWorkshopGetGradesWSResponse = { + assessmentrawgrade?: number; // The assessment raw (numeric) grade. + assessmentlongstrgrade?: string; // The assessment string grade. + assessmentgradehidden?: boolean; // Whether the grade is hidden or not. + submissionrawgrade?: number; // The submission raw (numeric) grade. + submissionlongstrgrade?: string; // The submission string grade. + submissiongradehidden?: boolean; // Whether the grade is hidden or not. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_workshop_get_grades_report WS. + */ +type AddonModWorkshopGetGradesReportWSParams = { + workshopid: number; // Workshop instance id. + groupid?: number; // Group id, 0 means that the function will determine the user group. + sortby?: string; // Sort by this element: + // lastname, firstname, submissiontitle, submissionmodified, submissiongrade, gradinggrade. + sortdirection?: string; // Sort direction: ASC or DESC. + page?: number; // The page of records to return. + perpage?: number; // The number of records to return per page. +}; + +/** + * Data returned by mod_workshop_get_grades_report WS. + */ +type AddonModWorkshopGetGradesReportWSResponse = { + report: AddonModWorkshoGradesReportData; + warnings?: CoreWSExternalWarning[]; +}; + +export type AddonModWorkshoGradesReportData = { + grades: AddonModWorkshopGradesData[]; + totalcount: number; // Number of total submissions. +}; + +export type AddonModWorkshopGradesData = { + userid: number; // The id of the user being displayed in the report. + submissionid: number; // Submission id. + submissiontitle: string; // Submission title. + submissionmodified: number; // Timestamp submission was updated. + submissiongrade?: number; // Aggregated grade for the submission. + gradinggrade?: number; // Computed grade for the assessment. + submissiongradeover?: number; // Grade for the assessment overrided by the teacher. + submissiongradeoverby?: number; // The id of the user who overrided the grade. + submissionpublished?: number; // Whether is a submission published. + reviewedby?: AddonModWorkshopReviewer[]; // The users who reviewed the user submission. + reviewerof?: AddonModWorkshopReviewer[]; // The assessments the user reviewed. +}; + +export type AddonModWorkshopReviewer = { + userid: number; // The id of the user (0 when is configured to do not display names). + assessmentid: number; // The id of the assessment. + submissionid: number; // The id of the submission assessed. + grade: number; // The grade for submission. + gradinggrade: number; // The grade for assessment. + gradinggradeover: number; // The aggregated grade overrided. + weight: number; // The weight of the assessment for aggregation. +}; + +/** + * Params of mod_workshop_get_submission_assessments WS. + */ +type AddonModWorkshopGetSubmissionAssessmentsWSParams = { + submissionid: number; // Submission id. +}; + +/** + * Data returned by mod_workshop_get_submission_assessments and mod_workshop_get_reviewer_assessments WS. + */ +type AddonModWorkshopGetAssessmentsWSResponse = { + assessments: AddonModWorkshopSubmissionAssessmentData[]; + warnings?: CoreWSExternalWarning[]; +}; + +export type AddonModWorkshopSubmissionAssessmentData = { + id: number; // The primary key of the record. + submissionid: number; // The id of the assessed submission. + reviewerid: number; // The id of the reviewer who makes this assessment. + weight: number; // The weight of the assessment for the purposes of aggregation. + timecreated: number; // If 0 then the assessment was allocated but the reviewer has not assessed yet. + // If greater than 0 then the timestamp of when the reviewer assessed for the first time. + timemodified: number; // If 0 then the assessment was allocated but the reviewer has not assessed yet. + // If greater than 0 then the timestamp of when the reviewer assessed for the last time. + grade?: number; // The aggregated grade for submission suggested by the reviewer. + // The grade 0..100 is computed from the values assigned to the assessment dimensions fields. + // If NULL then it has not been aggregated yet. + gradinggrade?: number; // The computed grade 0..100 for this assessment. If NULL then it has not been computed yet. + gradinggradeover?: number; // Grade for the assessment manually overridden by a teacher. + // Grade is always from interval 0..100. If NULL then the grade is not overriden. + gradinggradeoverby: number; // The id of the user who has overridden the grade for submission. + feedbackauthor: string; // The comment/feedback from the reviewer for the author. + feedbackauthorformat?: CoreTextFormat; // Feedbackauthor format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + feedbackauthorattachment: number; // Are there some files attached to the feedbackauthor field? + // Sets to 1 by file_postupdate_standard_filemanager(). + feedbackreviewer?: string; // The comment/feedback from the teacher for the reviewer. + // For example the reason why the grade for assessment was overridden. + feedbackreviewerformat?: CoreTextFormat; // Feedbackreviewer format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + feedbackcontentfiles: CoreWSExternalFile[]; // Feedbackcontentfiles. + feedbackattachmentfiles: CoreWSExternalFile[]; // Feedbackattachmentfiles. +}; + +/** + * Params of mod_workshop_get_reviewer_assessments WS. + */ +type AddonModWorkshopGetReviewerAssessmentsWSParams = { + workshopid: number; // Workshop instance id. + userid?: number; // User id who did the assessment review (empty or 0 for current user). +}; + +/** + * Params of mod_workshop_get_assessment WS. + */ +type AddonModWorkshopGetAssessmentWSParams = { + assessmentid: number; // Assessment id. +}; + +/** + * Data returned by mod_workshop_get_assessment WS. + */ +type AddonModWorkshopGetAssessmentWSResponse = { + assessment: AddonModWorkshopSubmissionAssessmentData; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_workshop_get_assessment_form_definition WS. + */ +type AddonModWorkshopGetAssessmentFormDefinitionWSParams = { + assessmentid: number; // Assessment id. + mode?: AddonModWorkshopAssessmentMode; // The form mode (assessment or preview). +}; + +/** + * Data returned by mod_workshop_get_assessment_form_definition WS. + */ +type AddonModWorkshopGetAssessmentFormDefinitionWSResponse = { + dimenssionscount: number; // The number of dimenssions used by the form. + descriptionfiles: CoreWSExternalFile[]; + options: { // The form options. + name: string; // Option name. + value: string; // Option value. + }[]; + fields: AddonModWorkshopGetAssessmentFormFieldData[]; // The form fields. + current: AddonModWorkshopGetAssessmentFormFieldData[]; // The current field values. + dimensionsinfo: { // The dimensions general information. + id: number; // Dimension id. + min: number; // Minimum grade for the dimension. + max: number; // Maximum grade for the dimension. + weight: string; // The weight of the dimension. + scale?: string; // Scale items (if used). + }[]; + warnings?: CoreWSExternalWarning[]; +}; + +export type AddonModWorkshopGetAssessmentFormDefinitionData = + Omit & { + options?: {[name: string]: string} ; + fields: AddonModWorkshopGetAssessmentFormFieldsParsedData[]; // The form fields. + current: AddonModWorkshopGetAssessmentFormFieldsParsedData[]; // The current field values. + }; + +export type AddonModWorkshopGetAssessmentFormFieldData = { + name: string; // Field name. + value: string; // Field default value. +}; + +export type AddonModWorkshopGetAssessmentFormFieldsParsedData = ( + Record & + { + number?: number; // eslint-disable-line id-blacklist + grades?: CoreGradesMenuItem[]; + grade?: number | string; + fields?: (Record & { + number: number; // eslint-disable-line id-blacklist + })[]; + } +); + +/** + * Common options with a user ID. + */ +export type AddonModWorkshopUserOptions = CoreCourseCommonModWSOptions & { + userId?: number; // User ID. If not defined, current user. +}; + +/** + * Common options with a group ID. + */ +export type AddonModWorkshopGroupOptions = CoreCourseCommonModWSOptions & { + groupId?: number; // Group id, 0 or not defined means that the function will determine the user group. +}; + +/** + * Options to pass to getSubmissions. + */ +export type AddonModWorkshopGetSubmissionsOptions = AddonModWorkshopUserOptions & AddonModWorkshopGroupOptions; + +/** + * Options to pass to fetchAllGradeReports. + */ +export type AddonModWorkshopFetchAllGradesReportOptions = AddonModWorkshopGroupOptions & { + perPage?: number; // Records per page to return. Default AddonModWorkshopProvider.PER_PAGE. +}; + +/** + * Options to pass to getGradesReport. + */ +export type AddonModWorkshopGetGradesReportOptions = AddonModWorkshopFetchAllGradesReportOptions & { + page?: number; // Page of records to return. Default 0. +}; + +/** + * Options to pass to getAssessmentForm. + */ +export type AddonModWorkshopGetAssessmentFormOptions = CoreCourseCommonModWSOptions & { + mode?: AddonModWorkshopAssessmentMode; // Mode assessment (default) or preview. Defaults to 'assessment'. +}; + +/** + * Params of mod_workshop_update_assessment WS. + */ +type AddonModWorkshopUpdateAssessmentWSParams = { + assessmentid: number; // Assessment id. + data: AddonModWorkshopAssessmentFieldData[]; // Assessment data. +}; + +export type AddonModWorkshopAssessmentFieldData = { + name: string; // The assessment data (use WS get_assessment_form_definition for obtaining the data to sent). + // Apart from that data, you can optionally send: + // feedbackauthor (str); the feedback for the submission author + // feedbackauthorformat (int); the format of the feedbackauthor + // feedbackauthorinlineattachmentsid (int); the draft file area for the editor attachments + // feedbackauthorattachmentsid (int); the draft file area id for the feedback attachments. + value: string; // The value of the option. +}; + +/** + * Data returned by mod_workshop_update_assessment WS. + */ +type AddonModWorkshopUpdateAssessmentWSResponse = { + status: boolean; // Status: true if the assessment was added or updated false otherwise. + rawgrade?: number; // Raw percentual grade (0.00000 to 100.00000) for submission. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_workshop_evaluate_submission WS. + */ +type AddonModWorkshopEvaluateSubmissionWSParams = { + submissionid: number; // Submission id. + feedbacktext?: string; // The feedback for the author. + feedbackformat?: CoreTextFormat; // The feedback format for text. + published?: boolean; // Publish the submission for others?. + gradeover?: string; // The new submission grade. +}; + +/** + * Params of mod_workshop_evaluate_assessment WS. + */ +type AddonModWorkshopEvaluateAssessmentWSParams = { + assessmentid: number; // Assessment id. + feedbacktext?: string; // The feedback for the reviewer. + feedbackformat?: CoreTextFormat; // The feedback format for text. + weight?: number; // The new weight for the assessment. + gradinggradeover?: string; // The new grading grade. +}; + +/** + * Params of mod_workshop_delete_submission WS. + */ +type AddonModWorkshopDeleteSubmissionWSParams = { + submissionid: number; // Submission id. +}; + +/** + * Params of mod_workshop_add_submission WS. + */ +type AddonModWorkshopAddSubmissionWSParams = { + workshopid: number; // Workshop id. + title: string; // Submission title. + content?: string; // Submission text content. + contentformat?: number; // The format used for the content. + inlineattachmentsid?: number; // The draft file area id for inline attachments in the content. + attachmentsid?: number; // The draft file area id for attachments. +}; + +/** + * Data returned by mod_workshop_add_submission WS. + */ +type AddonModWorkshopAddSubmissionWSResponse = { + status: boolean; // True if the submission was created false otherwise. + submissionid?: number; // New workshop submission id. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_workshop_update_submission WS. + */ +type AddonModWorkshopUpdateSubmissionWSParams = { + submissionid: number; // Submission id. + title: string; // Submission title. + content?: string; // Submission text content. + contentformat?: number; // The format used for the content. + inlineattachmentsid?: number; // The draft file area id for inline attachments in the content. + attachmentsid?: number; // The draft file area id for attachments. +}; + +export type AddonModWorkshopSubmissionChangedEventData = { + workshopId: number; + submissionId?: number; +}; + +export type AddonModWorkshopAssessmentSavedChangedEventData = { + workshopId: number; + assessmentId: number; + userId: number; +}; + +export type AddonModWorkshopAssessmentInvalidatedChangedEventData = null; diff --git a/src/addons/mod/workshop/workshop.module.ts b/src/addons/mod/workshop/workshop.module.ts new file mode 100644 index 000000000..1823ec561 --- /dev/null +++ b/src/addons/mod/workshop/workshop.module.ts @@ -0,0 +1,79 @@ +// (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, Type } 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 { CoreCronDelegate } from '@services/cron'; +import { CORE_SITE_SCHEMAS } from '@services/sites'; +import { AddonModWorkshopAssessmentStrategyModule } from './assessment/assessment.module'; +import { AddonModWorkshopComponentsModule } from './components/components.module'; +import { AddonWorkshopAssessmentStrategyDelegateService } from './services/assessment-strategy-delegate'; +import { ADDON_MOD_WORKSHOP_OFFLINE_SITE_SCHEMA } from './services/database/workshop'; +import { AddonModWorkshopIndexLinkHandler } from './services/handlers/index-link'; +import { AddonModWorkshopListLinkHandler } from './services/handlers/list-link'; +import { AddonModWorkshopModuleHandler, AddonModWorkshopModuleHandlerService } from './services/handlers/module'; +import { AddonModWorkshopPrefetchHandler } from './services/handlers/prefetch'; +import { AddonModWorkshopSyncCronHandler } from './services/handlers/sync-cron'; +import { AddonModWorkshopProvider } from './services/workshop'; +import { AddonModWorkshopHelperProvider } from './services/workshop-helper'; +import { AddonModWorkshopOfflineProvider } from './services/workshop-offline'; +import { AddonModWorkshopSyncProvider } from './services/workshop-sync'; + +// List of providers (without handlers). +export const ADDON_MOD_WORKSHOP_SERVICES: Type[] = [ + AddonModWorkshopProvider, + AddonModWorkshopOfflineProvider, + AddonModWorkshopSyncProvider, + AddonModWorkshopHelperProvider, + AddonWorkshopAssessmentStrategyDelegateService, +]; + +const routes: Routes = [ + { + path: AddonModWorkshopModuleHandlerService.PAGE_NAME, + loadChildren: () => import('./workshop-lazy.module').then(m => m.AddonModWorkshopLazyModule), + }, +]; + +@NgModule({ + imports: [ + CoreMainMenuTabRoutingModule.forChild(routes), + AddonModWorkshopComponentsModule, + AddonModWorkshopAssessmentStrategyModule, + ], + providers: [ + { + provide: CORE_SITE_SCHEMAS, + useValue: [ADDON_MOD_WORKSHOP_OFFLINE_SITE_SCHEMA], + multi: true, + }, + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + CoreCourseModuleDelegate.registerHandler(AddonModWorkshopModuleHandler.instance); + CoreCourseModulePrefetchDelegate.registerHandler(AddonModWorkshopPrefetchHandler.instance); + CoreCronDelegate.register(AddonModWorkshopSyncCronHandler.instance); + CoreContentLinksDelegate.registerHandler(AddonModWorkshopIndexLinkHandler.instance); + CoreContentLinksDelegate.registerHandler(AddonModWorkshopListLinkHandler.instance); + }, + }, + ], +}) +export class AddonModWorkshopModule {} diff --git a/src/core/features/compile/services/compile.ts b/src/core/features/compile/services/compile.ts index 108ae1e6c..36946262c 100644 --- a/src/core/features/compile/services/compile.ts +++ b/src/core/features/compile/services/compile.ts @@ -142,7 +142,7 @@ import { ADDON_MOD_SCORM_SERVICES } from '@addons/mod/scorm/scorm.module'; import { ADDON_MOD_SURVEY_SERVICES } from '@addons/mod/survey/survey.module'; import { ADDON_MOD_URL_SERVICES } from '@addons/mod/url/url.module'; import { ADDON_MOD_WIKI_SERVICES } from '@addons/mod/wiki/wiki.module'; -// @todo import { ADDON_MOD_WORKSHOP_SERVICES } from '@addons/mod/workshop/workshop.module'; +import { ADDON_MOD_WORKSHOP_SERVICES } from '@addons/mod/workshop/workshop.module'; import { ADDON_NOTES_SERVICES } from '@addons/notes/notes.module'; import { ADDON_NOTIFICATIONS_SERVICES } from '@addons/notifications/notifications.module'; import { ADDON_PRIVATEFILES_SERVICES } from '@addons/privatefiles/privatefiles.module'; @@ -150,7 +150,7 @@ import { ADDON_REMOTETHEMES_SERVICES } from '@addons/remotethemes/remotethemes.m // Import some addon modules that define components, directives and pipes. Only import the important ones. import { AddonModAssignComponentsModule } from '@addons/mod/assign/components/components.module'; -// @todo import { AddonModWorkshopComponentsModule } from '@addons/mod/workshop/components/components.module'; +import { AddonModWorkshopComponentsModule } from '@addons/mod/workshop/components/components.module'; /** * Service to provide functionalities regarding compiling dynamic HTML and Javascript. @@ -172,7 +172,7 @@ export class CoreCompileProvider { CoreSharedModule, CoreCourseComponentsModule, CoreCoursesComponentsModule, CoreUserComponentsModule, CoreCourseDirectivesModule, CoreQuestionComponentsModule, AddonModAssignComponentsModule, CoreBlockComponentsModule, CoreEditorComponentsModule, CoreSearchComponentsModule, CoreSitePluginsDirectivesModule, - // @todo AddonModWorkshopComponentsModule, + AddonModWorkshopComponentsModule, ]; constructor(protected injector: Injector, compilerFactory: JitCompilerFactory) { @@ -308,7 +308,7 @@ export class CoreCompileProvider { ...ADDON_MOD_SURVEY_SERVICES, ...ADDON_MOD_URL_SERVICES, ...ADDON_MOD_WIKI_SERVICES, - // @todo ...ADDON_MOD_WORKSHOP_SERVICES, + ...ADDON_MOD_WORKSHOP_SERVICES, ...ADDON_NOTES_SERVICES, ...ADDON_NOTIFICATIONS_SERVICES, ...ADDON_PRIVATEFILES_SERVICES, diff --git a/src/core/services/utils/utils.ts b/src/core/services/utils/utils.ts index 4e0ddb726..13e1e9e47 100644 --- a/src/core/services/utils/utils.ts +++ b/src/core/services/utils/utils.ts @@ -1158,12 +1158,12 @@ export class CoreUtilsProvider { * @param keyPrefix Key prefix if neededs to delete it. * @return Object. */ - objectToKeyValueMap( + objectToKeyValueMap( objects: Record[], keyName: string, valueName: string, keyPrefix?: string, - ): {[name: string]: unknown} | undefined { + ): {[name: string]: T} | undefined { if (!objects) { return; }