diff --git a/Dockerfile b/Dockerfile index 4f7efd3ed..031dc280b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,6 +22,6 @@ RUN npm install && rm -rf /root/.npm RUN npx gulp # Provide a Healthcheck command for easier use in CI. -HEALTHCHECK --interval=10s --timeout=3s --start-period=30s CMD curl -f http://localhost:8100 || exit 1 +HEALTHCHECK --interval=10s --timeout=5s --start-period=60s CMD curl -f http://localhost:8100 || exit 1 CMD ["npm", "run", "ionic:serve"] diff --git a/angular.json b/angular.json index c44eabc41..2b8ec418d 100644 --- a/angular.json +++ b/angular.json @@ -68,7 +68,8 @@ "serve": { "builder": "@angular-devkit/build-angular:dev-server", "options": { - "browserTarget": "app:build" + "browserTarget": "app:build", + "port": 8100 }, "configurations": { "production": { diff --git a/package.json b/package.json index 39c44d1d2..a98696dbe 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "test:coverage": "NODE_ENV=testing gulp && jest --coverage", "lint": "NODE_OPTIONS=--max-old-space-size=4096 ng lint", "ionic:serve:before": "gulp", - "ionic:serve": "gulp watch & ng serve", + "ionic:serve": "gulp watch & NODE_OPTIONS=--max-old-space-size=4096 ng serve", "ionic:build:before": "gulp" }, "dependencies": { diff --git a/scripts/langindex.json b/scripts/langindex.json index 0833ae5d8..3dd04a77c 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -1013,6 +1013,7 @@ "addon.mod_workshop.yourassessmentfor": "workshop", "addon.mod_workshop.yourgrades": "workshop", "addon.mod_workshop.yoursubmission": "workshop", + "addon.mod_workshop.yoursubmissionwithassessments": "workshop", "addon.mod_workshop_assessment_accumulative.dimensioncommentfor": "workshopform_accumulative", "addon.mod_workshop_assessment_accumulative.dimensiongradefor": "workshopform_accumulative", "addon.mod_workshop_assessment_accumulative.dimensionnumber": "workshopform_accumulative", @@ -1468,6 +1469,9 @@ "core.coursenogroups": "local_moodlemobileapp", "core.courses.addtofavourites": "block_myoverview", "core.courses.allowguests": "enrol_guest", + "core.courses.aria:coursename": "course", + "core.courses.aria:courseprogress": "block_myoverview", + "core.courses.aria:favourite": "course", "core.courses.availablecourses": "moodle", "core.courses.cannotretrievemorecategories": "local_moodlemobileapp", "core.courses.categories": "moodle", @@ -1480,6 +1484,7 @@ "core.courses.errorloadplugins": "local_moodlemobileapp", "core.courses.errorsearching": "local_moodlemobileapp", "core.courses.errorselfenrol": "local_moodlemobileapp", + "core.courses.favourite": "course", "core.courses.filtermycourses": "local_moodlemobileapp", "core.courses.frontpage": "admin", "core.courses.hidecourse": "block_myoverview", @@ -1859,8 +1864,10 @@ "core.lostconnection": "local_moodlemobileapp", "core.mainmenu.changesite": "local_moodlemobileapp", "core.mainmenu.help": "moodle", + "core.mainmenu.home": "moodle", "core.mainmenu.logout": "moodle", "core.mainmenu.website": "local_moodlemobileapp", + "core.maxfilesize": "moodle", "core.maxsizeandattachments": "moodle", "core.min": "moodle", "core.mins": "moodle", @@ -1944,8 +1951,8 @@ "core.question.certainty": "qbehaviour_deferredcbm", "core.question.complete": "question", "core.question.correct": "question", - "core.question.errorattachmentsnotsupported": "local_moodlemobileapp", - "core.question.errorinlinefilesnotsupported": "local_moodlemobileapp", + "core.question.errorattachmentsnotsupportedinsite": "local_moodlemobileapp", + "core.question.errorembeddedfilesnotsupportedinsite": "local_moodlemobileapp", "core.question.errorquestionnotsupported": "local_moodlemobileapp", "core.question.feedback": "question", "core.question.howtodraganddrop": "local_moodlemobileapp", @@ -2000,9 +2007,10 @@ "core.settings.cannotsyncoffline": "local_moodlemobileapp", "core.settings.cannotsyncwithoutwifi": "local_moodlemobileapp", "core.settings.colorscheme": "local_moodlemobileapp", - "core.settings.colorscheme-auto": "local_moodlemobileapp", "core.settings.colorscheme-dark": "local_moodlemobileapp", "core.settings.colorscheme-light": "local_moodlemobileapp", + "core.settings.colorscheme-system": "local_moodlemobileapp", + "core.settings.colorscheme-system-notice": "local_moodlemobileapp", "core.settings.compilationinfo": "local_moodlemobileapp", "core.settings.copyinfo": "local_moodlemobileapp", "core.settings.cordovadevicemodel": "local_moodlemobileapp", diff --git a/src/addons/addons.module.ts b/src/addons/addons.module.ts index 42e4642f9..d7fd185a5 100644 --- a/src/addons/addons.module.ts +++ b/src/addons/addons.module.ts @@ -14,45 +14,45 @@ import { NgModule } from '@angular/core'; -import { AddonBlockModule } from './block/block.module'; -import { AddonPrivateFilesModule } from './privatefiles/privatefiles.module'; -import { AddonFilterModule } from './filter/filter.module'; -import { AddonUserProfileFieldModule } from './userprofilefield/userprofilefield.module'; import { AddonBadgesModule } from './badges/badges.module'; +import { AddonBlockModule } from './block/block.module'; +import { AddonBlogModule } from './blog/blog.module'; import { AddonCalendarModule } from './calendar/calendar.module'; +import { AddonCompetencyModule } from './competency/competency.module'; import { AddonCourseCompletionModule } from './coursecompletion/coursecompletion.module'; -import { AddonNotificationsModule } from './notifications/notifications.module'; +import { AddonFilterModule } from './filter/filter.module'; import { AddonMessageOutputModule } from './messageoutput/messageoutput.module'; import { AddonMessagesModule } from './messages/messages.module'; import { AddonModModule } from './mod/mod.module'; +import { AddonNotesModule } from './notes/notes.module'; +import { AddonNotificationsModule } from './notifications/notifications.module'; +import { AddonPrivateFilesModule } from './privatefiles/privatefiles.module'; import { AddonQbehaviourModule } from './qbehaviour/qbehaviour.module'; import { AddonQtypeModule } from './qtype/qtype.module'; -import { AddonBlogModule } from './blog/blog.module'; import { AddonRemoteThemesModule } from './remotethemes/remotethemes.module'; -import { AddonNotesModule } from './notes/notes.module'; -import { AddonCompetencyModule } from './competency/competency.module'; import { AddonStorageManagerModule } from './storagemanager/storagemanager.module'; +import { AddonUserProfileFieldModule } from './userprofilefield/userprofilefield.module'; @NgModule({ imports: [ - AddonBlockModule, AddonBadgesModule, + AddonBlockModule, AddonBlogModule, AddonCalendarModule, AddonCompetencyModule, AddonCourseCompletionModule, - AddonMessagesModule, - AddonPrivateFilesModule, AddonFilterModule, - AddonUserProfileFieldModule, - AddonNotificationsModule, AddonMessageOutputModule, + AddonMessagesModule, AddonModModule, AddonNotesModule, + AddonNotificationsModule, + AddonPrivateFilesModule, AddonQbehaviourModule, AddonQtypeModule, AddonRemoteThemesModule, AddonStorageManagerModule, + AddonUserProfileFieldModule, ], }) export class AddonsModule {} diff --git a/src/addons/block/block.module.ts b/src/addons/block/block.module.ts index 0d8ff2d47..fb0db615c 100644 --- a/src/addons/block/block.module.ts +++ b/src/addons/block/block.module.ts @@ -14,6 +14,7 @@ import { NgModule } from '@angular/core'; +import { AddonBlockActivityModulesModule } from './activitymodules/activitymodules.module'; import { AddonBlockActivityResultsModule } from './activityresults/activityresults.module'; import { AddonBlockBadgesModule } from './badges/badges.module'; import { AddonBlockBlogMenuModule } from './blogmenu/blogmenu.module'; @@ -30,19 +31,19 @@ import { AddonBlockMyOverviewModule } from './myoverview/myoverview.module'; import { AddonBlockNewsItemsModule } from './newsitems/newsitems.module'; import { AddonBlockOnlineUsersModule } from './onlineusers/onlineusers.module'; import { AddonBlockPrivateFilesModule } from './privatefiles/privatefiles.module'; +import { AddonBlockRecentActivityModule } from './recentactivity/recentactivity.module'; import { AddonBlockRecentlyAccessedCoursesModule } from './recentlyaccessedcourses/recentlyaccessedcourses.module'; +import { AddonBlockRecentlyAccessedItemsModule } from './recentlyaccesseditems/recentlyaccesseditems.module'; import { AddonBlockRssClientModule } from './rssclient/rssclient.module'; import { AddonBlockSelfCompletionModule } from './selfcompletion/selfcompletion.module'; import { AddonBlockSiteMainMenuModule } from './sitemainmenu/sitemainmenu.module'; import { AddonBlockStarredCoursesModule } from './starredcourses/starredcourses.module'; import { AddonBlockTagsModule } from './tags/tags.module'; -import { AddonBlockActivityModulesModule } from './activitymodules/activitymodules.module'; -import { AddonBlockRecentlyAccessedItemsModule } from './recentlyaccesseditems/recentlyaccesseditems.module'; import { AddonBlockTimelineModule } from './timeline/timeline.module'; @NgModule({ - declarations: [], imports: [ + AddonBlockActivityModulesModule, AddonBlockActivityResultsModule, AddonBlockBadgesModule, AddonBlockBlogMenuModule, @@ -54,22 +55,20 @@ import { AddonBlockTimelineModule } from './timeline/timeline.module'; AddonBlockCompletionStatusModule, AddonBlockGlossaryRandomModule, AddonBlockHtmlModule, - AddonBlockMyOverviewModule, AddonBlockLearningPlansModule, + AddonBlockMyOverviewModule, AddonBlockNewsItemsModule, AddonBlockOnlineUsersModule, AddonBlockPrivateFilesModule, + AddonBlockRecentActivityModule, AddonBlockRecentlyAccessedCoursesModule, + AddonBlockRecentlyAccessedItemsModule, AddonBlockRssClientModule, AddonBlockSelfCompletionModule, AddonBlockSiteMainMenuModule, AddonBlockStarredCoursesModule, AddonBlockTagsModule, - AddonBlockActivityModulesModule, - AddonBlockRecentlyAccessedItemsModule, AddonBlockTimelineModule, ], - providers: [], - exports: [], }) export class AddonBlockModule { } diff --git a/src/addons/calendar/pages/edit-event/edit-event.html b/src/addons/calendar/pages/edit-event/edit-event.html index 855e6468a..34b66721c 100644 --- a/src/addons/calendar/pages/edit-event/edit-event.html +++ b/src/addons/calendar/pages/edit-event/edit-event.html @@ -22,7 +22,7 @@ - + @@ -34,7 +34,7 @@ - + @@ -135,7 +135,7 @@

{{ 'core.description' | translate }}

- diff --git a/src/addons/filter/filter.module.ts b/src/addons/filter/filter.module.ts index 8053a2522..da92525ed 100644 --- a/src/addons/filter/filter.module.ts +++ b/src/addons/filter/filter.module.ts @@ -30,7 +30,6 @@ import { AddonFilterTidyModule } from './tidy/tidy.module'; import { AddonFilterUrlToLinkModule } from './urltolink/urltolink.module'; @NgModule({ - declarations: [], imports: [ AddonFilterActivityNamesModule, AddonFilterAlgebraModule, @@ -47,7 +46,5 @@ import { AddonFilterUrlToLinkModule } from './urltolink/urltolink.module'; AddonFilterTidyModule, AddonFilterUrlToLinkModule, ], - providers: [], - exports: [], }) export class AddonFilterModule { } diff --git a/src/addons/messageoutput/messageoutput.module.ts b/src/addons/messageoutput/messageoutput.module.ts index ac418e76b..dfcc6e78b 100644 --- a/src/addons/messageoutput/messageoutput.module.ts +++ b/src/addons/messageoutput/messageoutput.module.ts @@ -22,12 +22,8 @@ export const ADDON_MESSAGEOUTPUT_SERVICES: Type[] = [ ]; @NgModule({ - declarations: [ - ], imports: [ AddonMessageOutputAirnotifierModule, ], - providers: [ - ], }) export class AddonMessageOutputModule {} diff --git a/src/addons/mod/assign/components/submission/addon-mod-assign-submission.html b/src/addons/mod/assign/components/submission/addon-mod-assign-submission.html index 93a6b796f..83b914e6d 100644 --- a/src/addons/mod/assign/components/submission/addon-mod-assign-submission.html +++ b/src/addons/mod/assign/components/submission/addon-mod-assign-submission.html @@ -240,7 +240,7 @@ -

{{ 'addon.mod_assign.gradelocked' | translate }}

+

{{ 'addon.mod_assign.gradelocked' | translate }}

@@ -262,7 +262,7 @@ {{grade.label}} -

{{ outcome.selected }}

+

{{ outcome.selected }}

diff --git a/src/addons/mod/assign/feedback/comments/component/addon-mod-assign-feedback-comments.html b/src/addons/mod/assign/feedback/comments/component/addon-mod-assign-feedback-comments.html index e3d1fd1ab..9e615ff11 100644 --- a/src/addons/mod/assign/feedback/comments/component/addon-mod-assign-feedback-comments.html +++ b/src/addons/mod/assign/feedback/comments/component/addon-mod-assign-feedback-comments.html @@ -25,7 +25,7 @@ - diff --git a/src/addons/mod/assign/pages/edit/edit.html b/src/addons/mod/assign/pages/edit/edit.html index 7acd635e3..d5c5bedf5 100644 --- a/src/addons/mod/assign/pages/edit/edit.html +++ b/src/addons/mod/assign/pages/edit/edit.html @@ -27,7 +27,7 @@ - + ; + async uploadOrStoreFiles( + assignId: number, + folderName: string, + files: CoreFileEntry[], + offline: false, + userId?: number, + siteId?: string, + ): Promise; + async uploadOrStoreFiles( + assignId: number, + folderName: string, + files: CoreFileEntry[], + offline: boolean, + userId?: number, + siteId?: string, + ): Promise; + async uploadOrStoreFiles( + assignId: number, + folderName: string, + files: CoreFileEntry[], + offline: boolean, userId?: number, siteId?: string, ): Promise { if (offline) { - return await this.storeSubmissionFiles(assignId, folderName, files, userId, siteId); + return this.storeSubmissionFiles(assignId, folderName, files, userId, siteId); } - return await this.uploadFiles(assignId, files, siteId); + return this.uploadFiles(assignId, files, siteId); } } diff --git a/src/addons/mod/assign/services/assign.ts b/src/addons/mod/assign/services/assign.ts index dec89da07..72eb9e60e 100644 --- a/src/addons/mod/assign/services/assign.ts +++ b/src/addons/mod/assign/services/assign.ts @@ -21,7 +21,6 @@ import { makeSingleton } from '@singletons'; import { CoreCourseCommonModWSOptions } from '@features/course/services/course'; import { CoreTextUtils } from '@services/utils/text'; import { CoreGrades } from '@features/grades/services/grades'; -import { CoreFilepool } from '@services/filepool'; import { CoreTimeUtils } from '@services/utils/time'; import { CoreCourseLogHelper } from '@features/course/services/log-helper'; import { CoreError } from '@classes/errors/error'; @@ -34,6 +33,7 @@ import { AddonModAssignSubmissionFormatted } from './assign-helper'; import { CoreWSError } from '@classes/errors/wserror'; import { AddonModAssignAutoSyncData, AddonModAssignManualSyncData, AddonModAssignSyncProvider } from './assign-sync'; import { CoreFormFields } from '@singletons/form'; +import { CoreFileHelper } from '@services/file-helper'; const ROOT_CACHE_KEY = 'mmaModAssign:'; @@ -421,7 +421,7 @@ export class AddonModAssignProvider { filearea.files.forEach((file) => { if (!file.filename) { // We don't have filename, extract it from the path. - file.filename = file.filepath?.charAt(0) == '/' ? file.filepath.substr(1) : file.filepath; + file.filename = CoreFileHelper.getFilenameFromPath(file); } files.push(file); @@ -759,7 +759,6 @@ export class AddonModAssignProvider { /** * Invalidate the prefetched content except files. - * To invalidate files, use AddonModAssignProvider.invalidateFiles. * * @param moduleId The module ID. * @param courseId Course ID. @@ -783,20 +782,6 @@ export class AddonModAssignProvider { await Promise.all(promises); } - /** - * Invalidate the prefetched files. - * - * @param moduleId The module ID. - * @return Promise resolved when the files are invalidated. - */ - async invalidateFiles(moduleId: number): Promise { - await CoreFilepool.invalidateFilesByComponent( - CoreSites.getCurrentSiteId(), - AddonModAssignProvider.COMPONENT, - moduleId, - ); - } - /** * Invalidates assignment submissions data WS calls. * diff --git a/src/addons/mod/assign/submission/file/services/handler.ts b/src/addons/mod/assign/submission/file/services/handler.ts index 72c93d843..f1b52ea2f 100644 --- a/src/addons/mod/assign/submission/file/services/handler.ts +++ b/src/addons/mod/assign/submission/file/services/handler.ts @@ -278,7 +278,7 @@ export class AddonModAssignSubmissionFileHandlerService implements AddonModAssig plugin: AddonModAssignPlugin, inputData: AddonModAssignSubmissionFileData, pluginData: AddonModAssignSubmissionFilePluginData, - offline?: boolean, + offline = false, userId?: number, siteId?: string, ): Promise { diff --git a/src/addons/mod/data/fields/textarea/component/addon-mod-data-field-textarea.html b/src/addons/mod/data/fields/textarea/component/addon-mod-data-field-textarea.html index e6d25010d..73d83c17d 100644 --- a/src/addons/mod/data/fields/textarea/component/addon-mod-data-field-textarea.html +++ b/src/addons/mod/data/fields/textarea/component/addon-mod-data-field-textarea.html @@ -2,8 +2,8 @@ - diff --git a/src/addons/mod/data/services/data-helper.ts b/src/addons/mod/data/services/data-helper.ts index ef3ba57d1..400ee6752 100644 --- a/src/addons/mod/data/services/data-helper.ts +++ b/src/addons/mod/data/services/data-helper.ts @@ -542,7 +542,7 @@ export class AddonModDataHelperProvider { dataId: number, entryId: number, entryContents: AddonModDataEntryFields, - offline: boolean = false, + offline = false, siteId?: string, ): Promise { if (!inputData) { @@ -766,6 +766,33 @@ export class AddonModDataHelperProvider { * @param siteId Site ID. If not defined, current site. * @return Promise resolved with the itemId for the uploaded file/s. */ + async uploadOrStoreFiles( + dataId: number, + itemId: number, + entryId: number, + fieldId: number, + files: CoreFileEntry[], + offline: true, + siteId?: string, + ): Promise; + async uploadOrStoreFiles( + dataId: number, + itemId: number, + entryId: number, + fieldId: number, + files: CoreFileEntry[], + offline: false, + siteId?: string, + ): Promise; + async uploadOrStoreFiles( + dataId: number, + itemId: number, + entryId: number, + fieldId: number, + files: CoreFileEntry[], + offline: boolean, + siteId?: string, + ): Promise; async uploadOrStoreFiles( dataId: number, itemId: number = 0, diff --git a/src/addons/mod/forum/components/edit-post/edit-post.html b/src/addons/mod/forum/components/edit-post/edit-post.html index 02490139c..c809c770b 100644 --- a/src/addons/mod/forum/components/edit-post/edit-post.html +++ b/src/addons/mod/forum/components/edit-post/edit-post.html @@ -17,7 +17,7 @@ {{ 'addon.mod_forum.message' | translate }} - {{ 'addon.mod_forum.message' | translate }} - {{ 'addon.mod_forum.message' | translate }} - diff --git a/src/addons/mod/forum/services/forum-helper.ts b/src/addons/mod/forum/services/forum-helper.ts index b3a570eef..140853e36 100644 --- a/src/addons/mod/forum/services/forum-helper.ts +++ b/src/addons/mod/forum/services/forum-helper.ts @@ -445,11 +445,17 @@ export class AddonModForumHelperProvider { * @param userId User the reply belongs to. If not defined, current user in site. * @return Promise resolved if success, rejected otherwise. */ - async storeReplyFiles(forumId: number, postId: number, files: any[], siteId?: string, userId?: number): Promise { + async storeReplyFiles( + forumId: number, + postId: number, + files: CoreFileEntry[], + siteId?: string, + userId?: number, + ): Promise { // Get the folder where to store the files. const folderPath = await AddonModForumOffline.getReplyFolder(forumId, postId, siteId, userId); - await CoreFileUploader.storeFilesToUpload(folderPath, files); + return CoreFileUploader.storeFilesToUpload(folderPath, files); } /** @@ -485,9 +491,9 @@ export class AddonModForumHelperProvider { ): Promise { if (offline) { return this.storeNewDiscussionFiles(forumId, timecreated, files, siteId); - } else { - return CoreFileUploader.uploadOrReuploadFiles(files, AddonModForumProvider.COMPONENT, forumId, siteId); } + + return CoreFileUploader.uploadOrReuploadFiles(files, AddonModForumProvider.COMPONENT, forumId, siteId); } /** @@ -501,19 +507,35 @@ export class AddonModForumHelperProvider { * @param userId User the reply belongs to. If not defined, current user in site. * @return Promise resolved if success. */ - uploadOrStoreReplyFiles( + async uploadOrStoreReplyFiles( forumId: number, postId: number, - files: any[], + files: CoreFileEntry[], + offline: true, + siteId?: string, + userId?: number, + ): Promise; + async uploadOrStoreReplyFiles( + forumId: number, + postId: number, + files: CoreFileEntry[], + offline: false, + siteId?: string, + userId?: number, + ): Promise; + async uploadOrStoreReplyFiles( + forumId: number, + postId: number, + files: CoreFileEntry[], offline: boolean, siteId?: string, userId?: number, - ): Promise { + ): Promise { if (offline) { return this.storeReplyFiles(forumId, postId, files, siteId, userId); - } else { - return CoreFileUploader.uploadOrReuploadFiles(files, AddonModForumProvider.COMPONENT, forumId, siteId); } + + return CoreFileUploader.uploadOrReuploadFiles(files, AddonModForumProvider.COMPONENT, forumId, siteId); } } diff --git a/src/addons/mod/forum/services/forum.ts b/src/addons/mod/forum/services/forum.ts index ea4a088a3..582c13e65 100644 --- a/src/addons/mod/forum/services/forum.ts +++ b/src/addons/mod/forum/services/forum.ts @@ -21,7 +21,6 @@ import { CoreTagItem } from '@features/tag/services/tag'; import { CoreUser } from '@features/user/services/user'; import { CoreApp } from '@services/app'; import { CoreFileEntry } from '@services/file-helper'; -import { CoreFilepool } from '@services/filepool'; import { CoreGroups } from '@services/groups'; import { CoreSitesCommonWSOptions, CoreSites, CoreSitesReadingStrategy } from '@services/sites'; import { CoreUrlUtils } from '@services/utils/url'; @@ -870,7 +869,6 @@ export class AddonModForumProvider { /** * Invalidate the prefetched content except files. - * To invalidate files, use AddonModForum#invalidateFiles. * * @param moduleId The module ID. * @param courseId Course ID. @@ -963,18 +961,6 @@ export class AddonModForumProvider { ); } - /** - * Invalidate the prefetched files. - * - * @param moduleId The module ID. - * @return Promise resolved when the files are invalidated. - */ - async invalidateFiles(moduleId: number): Promise { - const siteId = CoreSites.getCurrentSiteId(); - - await CoreFilepool.invalidateFilesByComponent(siteId, AddonModForumProvider.COMPONENT, moduleId); - } - /** * Invalidates forum data. * 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 6561d2eda..1c3e3fe0e 100644 --- a/src/addons/mod/mod.module.ts +++ b/src/addons/mod/mod.module.ts @@ -16,49 +16,51 @@ import { NgModule } from '@angular/core'; import { AddonModAssignModule } from './assign/assign.module'; import { AddonModBookModule } from './book/book.module'; +import { AddonModChatModule } from './chat/chat.module'; +import { AddonModChoiceModule } from './choice/choice.module'; import { AddonModDataModule } from './data/data.module'; +import { AddonModFeedbackModule } from './feedback/feedback.module'; import { AddonModFolderModule } from './folder/folder.module'; import { AddonModForumModule } from './forum/forum.module'; -import { AddonModLabelModule } from './label/label.module'; +import { AddonModGlossaryModule } from './glossary/glossary.module'; +import { AddonModH5PActivityModule } from './h5pactivity/h5pactivity.module'; import { AddonModImscpModule } from './imscp/imscp.module'; +import { AddonModLabelModule } from './label/label.module'; import { AddonModLessonModule } from './lesson/lesson.module'; +import { AddonModLtiModule } from './lti/lti.module'; import { AddonModPageModule } from './page/page.module'; import { AddonModQuizModule } from './quiz/quiz.module'; import { AddonModResourceModule } from './resource/resource.module'; -import { AddonModUrlModule } from './url/url.module'; -import { AddonModLtiModule } from './lti/lti.module'; -import { AddonModH5PActivityModule } from './h5pactivity/h5pactivity.module'; -import { AddonModSurveyModule } from './survey/survey.module'; import { AddonModScormModule } from './scorm/scorm.module'; -import { AddonModChoiceModule } from './choice/choice.module'; +import { AddonModSurveyModule } from './survey/survey.module'; +import { AddonModUrlModule } from './url/url.module'; import { AddonModWikiModule } from './wiki/wiki.module'; -import { AddonModGlossaryModule } from './glossary/glossary.module'; -import { AddonModChatModule } from './chat/chat.module'; -import { AddonModFeedbackModule } from './feedback/feedback.module'; +import { AddonModWorkshopModule } from './workshop/workshop.module'; @NgModule({ imports: [ AddonModAssignModule, AddonModBookModule, + AddonModChatModule, + AddonModChoiceModule, AddonModDataModule, + AddonModFeedbackModule, + AddonModFolderModule, AddonModForumModule, + AddonModGlossaryModule, + AddonModH5PActivityModule, + AddonModImscpModule, + AddonModLabelModule, AddonModLessonModule, + AddonModLtiModule, AddonModPageModule, AddonModQuizModule, - AddonModUrlModule, - AddonModLabelModule, AddonModResourceModule, - AddonModFolderModule, - AddonModImscpModule, - AddonModLtiModule, - AddonModH5PActivityModule, - AddonModSurveyModule, AddonModScormModule, - AddonModChoiceModule, + AddonModSurveyModule, + AddonModUrlModule, AddonModWikiModule, - AddonModGlossaryModule, - AddonModChatModule, - AddonModFeedbackModule, + AddonModWorkshopModule, ], }) export class AddonModModule { } diff --git a/src/addons/mod/quiz/accessrules/accessrules.module.ts b/src/addons/mod/quiz/accessrules/accessrules.module.ts index efd3206b9..6c4c8e2a5 100644 --- a/src/addons/mod/quiz/accessrules/accessrules.module.ts +++ b/src/addons/mod/quiz/accessrules/accessrules.module.ts @@ -25,7 +25,6 @@ import { AddonModQuizAccessSecureWindowModule } from './securewindow/securewindo import { AddonModQuizAccessTimeLimitModule } from './timelimit/timelimit.module'; @NgModule({ - declarations: [], imports: [ AddonModQuizAccessDelayBetweenAttemptsModule, AddonModQuizAccessIpAddressModule, @@ -37,7 +36,5 @@ import { AddonModQuizAccessTimeLimitModule } from './timelimit/timelimit.module' AddonModQuizAccessSecureWindowModule, AddonModQuizAccessTimeLimitModule, ], - providers: [], - exports: [], }) export class AddonModQuizAccessRulesModule { } diff --git a/src/addons/mod/quiz/services/quiz.ts b/src/addons/mod/quiz/services/quiz.ts index c732bc937..16e39cf9a 100644 --- a/src/addons/mod/quiz/services/quiz.ts +++ b/src/addons/mod/quiz/services/quiz.ts @@ -28,7 +28,6 @@ import { CoreQuestionsAnswers, } from '@features/question/services/question'; import { CoreQuestionDelegate } from '@features/question/services/question-delegate'; -import { CoreFilepool } from '@services/filepool'; import { CoreSites, CoreSitesCommonWSOptions, CoreSitesReadingStrategy } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreTextUtils } from '@services/utils/text'; @@ -1255,7 +1254,6 @@ export class AddonModQuizProvider { /** * Invalidate the prefetched content except files. - * To invalidate files, use AddonModQuizProvider.invalidateFiles. * * @param moduleId The module ID. * @param courseId Course ID. @@ -1306,20 +1304,6 @@ export class AddonModQuizProvider { await site.invalidateWsCacheForKey(this.getFeedbackForGradeCacheKey(quizId, grade)); } - /** - * Invalidate the prefetched files. - * - * @param moduleId The module ID. - * @return Promise resolved when the files are invalidated. - */ - async invalidateFiles(moduleId: number): Promise { - await CoreFilepool.invalidateFilesByComponent( - CoreSites.getCurrentSiteId(), - AddonModQuizProvider.COMPONENT, - moduleId, - ); - } - /** * Invalidates grade from gradebook for a certain user. * diff --git a/src/addons/mod/workshop/assessment/accumulative/accumulative.module.ts b/src/addons/mod/workshop/assessment/accumulative/accumulative.module.ts new file mode 100644 index 000000000..7eaf6ccc4 --- /dev/null +++ b/src/addons/mod/workshop/assessment/accumulative/accumulative.module.ts @@ -0,0 +1,44 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { APP_INITIALIZER, NgModule } from '@angular/core'; +import { AddonModWorkshopAssessmentStrategyAccumulativeComponent } from './component/accumulative'; +import { AddonModWorkshopAssessmentStrategyAccumulativeHandler } from './services/handler'; +import { AddonWorkshopAssessmentStrategyDelegate } from '../../services/assessment-strategy-delegate'; +import { CoreSharedModule } from '@/core/shared.module'; + +@NgModule({ + declarations: [ + AddonModWorkshopAssessmentStrategyAccumulativeComponent, + ], + imports: [ + CoreSharedModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + AddonWorkshopAssessmentStrategyDelegate.registerHandler( + AddonModWorkshopAssessmentStrategyAccumulativeHandler.instance, + ); + }, + }, + ], + exports: [ + AddonModWorkshopAssessmentStrategyAccumulativeComponent, + ], +}) +export class AddonModWorkshopAssessmentStrategyAccumulativeModule {} diff --git a/src/addons/mod/workshop/assessment/accumulative/component/accumulative.ts b/src/addons/mod/workshop/assessment/accumulative/component/accumulative.ts new file mode 100644 index 000000000..24f912884 --- /dev/null +++ b/src/addons/mod/workshop/assessment/accumulative/component/accumulative.ts @@ -0,0 +1,25 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component } from '@angular/core'; +import { AddonModWorkshopAssessmentStrategyBaseComponent } from '../../../classes/assessment-strategy-component'; + +/** + * Component for accumulative assessment strategy. + */ +@Component({ + selector: 'addon-mod-workshop-assessment-strategy-accumulative', + templateUrl: 'addon-mod-workshop-assessment-strategy-accumulative.html', +}) +export class AddonModWorkshopAssessmentStrategyAccumulativeComponent extends AddonModWorkshopAssessmentStrategyBaseComponent { } diff --git a/src/addons/mod/workshop/assessment/accumulative/component/addon-mod-workshop-assessment-strategy-accumulative.html b/src/addons/mod/workshop/assessment/accumulative/component/addon-mod-workshop-assessment-strategy-accumulative.html new file mode 100644 index 000000000..ccb21f9c3 --- /dev/null +++ b/src/addons/mod/workshop/assessment/accumulative/component/addon-mod-workshop-assessment-strategy-accumulative.html @@ -0,0 +1,50 @@ + + + + +

{{ field.dimtitle }}

+ + +
+
+ + + + {{ 'addon.mod_workshop_assessment_accumulative.dimensiongradefor' | translate : {'$a': field.dimtitle } }} + + + + {{grade.label}} + + + + + + +

{{ 'addon.mod_workshop_assessment_accumulative.dimensiongradefor' | translate : {'$a': field.dimtitle } }}

+ +

{{grade.label}}

+
+
+
+ + + {{ 'addon.mod_workshop_assessment_accumulative.dimensioncommentfor' | translate : {'$a': field.dimtitle } }} + + + + + +

+ {{ 'addon.mod_workshop_assessment_accumulative.dimensioncommentfor' | translate : {'$a': field.dimtitle } }} +

+

+ + +

+
+
+
+
diff --git a/src/addons/mod/workshop/assessment/accumulative/lang.json b/src/addons/mod/workshop/assessment/accumulative/lang.json new file mode 100644 index 000000000..72c26cfa8 --- /dev/null +++ b/src/addons/mod/workshop/assessment/accumulative/lang.json @@ -0,0 +1,6 @@ +{ + "dimensioncommentfor": "Comment for {{$a}}", + "dimensiongradefor": "Grade for {{$a}}", + "dimensionnumber": "Aspect {{$a}}", + "mustchoosegrade": "You have to select a grade for this aspect" +} diff --git a/src/addons/mod/workshop/assessment/accumulative/services/handler.ts b/src/addons/mod/workshop/assessment/accumulative/services/handler.ts new file mode 100644 index 000000000..e62a71e8e --- /dev/null +++ b/src/addons/mod/workshop/assessment/accumulative/services/handler.ts @@ -0,0 +1,151 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + AddonModWorkshopAssessmentStrategyFieldErrors, +} from '@addons/mod/workshop/components/assessment-strategy/assessment-strategy'; +import { + AddonModWorkshopGetAssessmentFormDefinitionData, + AddonModWorkshopGetAssessmentFormFieldsParsedData, +} from '@addons/mod/workshop/services/workshop'; +import { Injectable, Type } from '@angular/core'; +import { CoreGradesHelper } from '@features/grades/services/grades-helper'; +import { makeSingleton, Translate } from '@singletons'; +import { CoreFormFields } from '@singletons/form'; +import { AddonWorkshopAssessmentStrategyHandler } from '../../../services/assessment-strategy-delegate'; +import { AddonModWorkshopAssessmentStrategyAccumulativeComponent } from '../component/accumulative'; + +/** + * Handler for accumulative assessment strategy plugin. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModWorkshopAssessmentStrategyAccumulativeHandlerService implements AddonWorkshopAssessmentStrategyHandler { + + name = 'AddonModWorkshopAssessmentStrategyAccumulative'; + strategyName = 'accumulative'; + + /** + * @inheritdoc + */ + async isEnabled(): Promise { + return true; + } + + /** + * @inheritdoc + */ + getComponent(): Type { + return AddonModWorkshopAssessmentStrategyAccumulativeComponent; + } + + /** + * @inheritdoc + */ + async getOriginalValues( + form: AddonModWorkshopGetAssessmentFormDefinitionData, + ): Promise { + const defaultGrade = Translate.instant('core.choosedots'); + const originalValues: AddonModWorkshopGetAssessmentFormFieldsParsedData[] = []; + const promises: Promise[] = []; + + form.fields.forEach((field, n) => { + field.dimtitle = Translate.instant('addon.mod_workshop_assessment_accumulative.dimensionnumber', { $a: field.number }); + + if (!form.current[n]) { + form.current[n] = {}; + } + + originalValues[n] = {}; + originalValues[n].peercomment = form.current[n].peercomment || ''; + originalValues[n].number = field.number; // eslint-disable-line id-blacklist + + form.current[n].grade = form.current[n].grade ? parseInt(String(form.current[n].grade), 10) : -1; + + const gradingType = parseInt(String(field.grade), 10); + const dimension = form.dimensionsinfo.find((dimension) => dimension.id == parseInt(field.dimensionid, 10)); + const scale = dimension && gradingType < 0 ? dimension.scale : undefined; + + promises.push(CoreGradesHelper.makeGradesMenu(gradingType, undefined, defaultGrade, -1, scale).then((grades) => { + field.grades = grades; + originalValues[n].grade = form.current[n].grade; + + return; + })); + }); + + await Promise.all(promises); + + return originalValues; + } + + /** + * @inheritdoc + */ + hasDataChanged( + originalValues: AddonModWorkshopGetAssessmentFormFieldsParsedData[], + currentValues: AddonModWorkshopGetAssessmentFormFieldsParsedData[], + ): boolean { + for (const x in originalValues) { + if (originalValues[x].grade != currentValues[x].grade) { + return true; + } + if (originalValues[x].peercomment != currentValues[x].peercomment) { + return true; + } + } + + return false; + } + + /** + * @inheritdoc + */ + async prepareAssessmentData( + currentValues: AddonModWorkshopGetAssessmentFormFieldsParsedData[], + form: AddonModWorkshopGetAssessmentFormDefinitionData, + ): Promise { + const data: CoreFormFields = {}; + const errors: AddonModWorkshopAssessmentStrategyFieldErrors = {}; + let hasErrors = false; + + form.fields.forEach((field, idx) => { + if (idx < form.dimenssionscount) { + const grade = parseInt(String(currentValues[idx].grade), 10); + if (!isNaN(grade) && grade >= 0) { + data['grade__idx_' + idx] = grade; + } else { + errors['grade_' + idx] = Translate.instant('addon.mod_workshop_assessment_accumulative.mustchoosegrade'); + hasErrors = true; + } + + if (currentValues[idx].peercomment) { + data['peercomment__idx_' + idx] = currentValues[idx].peercomment; + } + + data['gradeid__idx_' + idx] = parseInt(form.current[idx].gradeid, 10) || 0; + data['dimensionid__idx_' + idx] = parseInt(field.dimensionid, 10); + data['weight__idx_' + idx] = parseInt(field.weight, 10) || 0; + } + }); + + if (hasErrors) { + throw errors; + } + + return data; + } + +} +export const AddonModWorkshopAssessmentStrategyAccumulativeHandler = + makeSingleton(AddonModWorkshopAssessmentStrategyAccumulativeHandlerService); diff --git a/src/addons/mod/workshop/assessment/assessment.module.ts b/src/addons/mod/workshop/assessment/assessment.module.ts new file mode 100644 index 000000000..dedb5952d --- /dev/null +++ b/src/addons/mod/workshop/assessment/assessment.module.ts @@ -0,0 +1,29 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { AddonModWorkshopAssessmentStrategyAccumulativeModule } from './accumulative/accumulative.module'; +import { AddonModWorkshopAssessmentStrategyCommentsModule } from './comments/comments.module'; +import { AddonModWorkshopAssessmentStrategyNumErrorsModule } from './numerrors/numerrors.module'; +import { AddonModWorkshopAssessmentStrategyRubricModule } from './rubric/rubric.module'; + +@NgModule({ + imports: [ + AddonModWorkshopAssessmentStrategyAccumulativeModule, + AddonModWorkshopAssessmentStrategyCommentsModule, + AddonModWorkshopAssessmentStrategyNumErrorsModule, + AddonModWorkshopAssessmentStrategyRubricModule, + ], +}) +export class AddonModWorkshopAssessmentStrategyModule {} diff --git a/src/addons/mod/workshop/assessment/comments/comments.module.ts b/src/addons/mod/workshop/assessment/comments/comments.module.ts new file mode 100644 index 000000000..2ceaf9a5a --- /dev/null +++ b/src/addons/mod/workshop/assessment/comments/comments.module.ts @@ -0,0 +1,44 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreSharedModule } from '@/core/shared.module'; +import { APP_INITIALIZER, NgModule } from '@angular/core'; +import { AddonWorkshopAssessmentStrategyDelegate } from '../../services/assessment-strategy-delegate'; +import { AddonModWorkshopAssessmentStrategyCommentsComponent } from './component/comments'; +import { AddonModWorkshopAssessmentStrategyCommentsHandler } from './services/handler'; + +@NgModule({ + declarations: [ + AddonModWorkshopAssessmentStrategyCommentsComponent, + ], + imports: [ + CoreSharedModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + AddonWorkshopAssessmentStrategyDelegate.registerHandler( + AddonModWorkshopAssessmentStrategyCommentsHandler.instance, + ); + }, + }, + ], + exports: [ + AddonModWorkshopAssessmentStrategyCommentsComponent, + ], +}) +export class AddonModWorkshopAssessmentStrategyCommentsModule {} diff --git a/src/addons/mod/workshop/assessment/comments/component/addon-mod-workshop-assessment-strategy-comments.html b/src/addons/mod/workshop/assessment/comments/component/addon-mod-workshop-assessment-strategy-comments.html new file mode 100644 index 000000000..74aaacb70 --- /dev/null +++ b/src/addons/mod/workshop/assessment/comments/component/addon-mod-workshop-assessment-strategy-comments.html @@ -0,0 +1,30 @@ + + + + +

{{ field.dimtitle }}

+ + +
+
+ + + + {{ 'addon.mod_workshop_assessment_comments.dimensioncommentfor' | translate : {'$a': field.dimtitle } }} + + + + + + + + +

{{ 'addon.mod_workshop_assessment_comments.dimensioncommentfor' | translate : {'$a': field.dimtitle } }}

+

+

+
+
+
+
diff --git a/src/addons/mod/workshop/assessment/comments/component/comments.ts b/src/addons/mod/workshop/assessment/comments/component/comments.ts new file mode 100644 index 000000000..ebfc620b3 --- /dev/null +++ b/src/addons/mod/workshop/assessment/comments/component/comments.ts @@ -0,0 +1,25 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component } from '@angular/core'; +import { AddonModWorkshopAssessmentStrategyBaseComponent } from '../../../classes/assessment-strategy-component'; + +/** + * Component for comments assessment strategy. + */ +@Component({ + selector: 'addon-mod-workshop-assessment-strategy-comments', + templateUrl: 'addon-mod-workshop-assessment-strategy-comments.html', +}) +export class AddonModWorkshopAssessmentStrategyCommentsComponent extends AddonModWorkshopAssessmentStrategyBaseComponent { } diff --git a/src/addons/mod/workshop/assessment/comments/lang.json b/src/addons/mod/workshop/assessment/comments/lang.json new file mode 100644 index 000000000..6db857323 --- /dev/null +++ b/src/addons/mod/workshop/assessment/comments/lang.json @@ -0,0 +1,4 @@ +{ + "dimensioncommentfor": "Comment for {{$a}}", + "dimensionnumber": "Aspect {{$a}}" +} diff --git a/src/addons/mod/workshop/assessment/comments/services/handler.ts b/src/addons/mod/workshop/assessment/comments/services/handler.ts new file mode 100644 index 000000000..8ab2448cd --- /dev/null +++ b/src/addons/mod/workshop/assessment/comments/services/handler.ts @@ -0,0 +1,124 @@ +// (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 { + AddonModWorkshopAssessmentStrategyFieldErrors, +} from '@addons/mod/workshop/components/assessment-strategy/assessment-strategy'; +import { AddonWorkshopAssessmentStrategyHandler } from '@addons/mod/workshop/services/assessment-strategy-delegate'; +import { + AddonModWorkshopGetAssessmentFormDefinitionData, + AddonModWorkshopGetAssessmentFormFieldsParsedData, +} from '@addons/mod/workshop/services/workshop'; +import { Injectable, Type } from '@angular/core'; +import { makeSingleton, Translate } from '@singletons'; +import { CoreFormFields } from '@singletons/form'; +import { AddonModWorkshopAssessmentStrategyCommentsComponent } from '../component/comments'; + +/** + * Handler for comments assessment strategy plugin. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModWorkshopAssessmentStrategyCommentsHandlerService implements AddonWorkshopAssessmentStrategyHandler { + + name = 'AddonModWorkshopAssessmentStrategyComments'; + strategyName = 'comments'; + + /** + * @inheritdoc + */ + async isEnabled(): Promise { + return true; + } + + /** + * @inheritdoc + */ + getComponent(): Type { + return AddonModWorkshopAssessmentStrategyCommentsComponent; + } + + /** + * @inheritdoc + */ + async getOriginalValues( + form: AddonModWorkshopGetAssessmentFormDefinitionData, + ): Promise { + const originalValues: AddonModWorkshopGetAssessmentFormFieldsParsedData[] = []; + + form.fields.forEach((field, n) => { + field.dimtitle = Translate.instant('addon.mod_workshop_assessment_comments.dimensionnumber', { $a: field.number }); + + if (!form.current[n]) { + form.current[n] = {}; + } + + originalValues[n] = {}; + originalValues[n].peercomment = form.current[n].peercomment || ''; + originalValues[n].number = field.number; // eslint-disable-line id-blacklist + }); + + return originalValues; + } + + /** + * @inheritdoc + */ + hasDataChanged( + originalValues: AddonModWorkshopGetAssessmentFormFieldsParsedData[], + currentValues: AddonModWorkshopGetAssessmentFormFieldsParsedData[], + ): boolean { + for (const x in originalValues) { + if (originalValues[x].peercomment != currentValues[x].peercomment) { + return true; + } + } + + return false; + } + + /** + * @inheritdoc + */ + async prepareAssessmentData( + currentValues: AddonModWorkshopGetAssessmentFormFieldsParsedData[], + form: AddonModWorkshopGetAssessmentFormDefinitionData, + ): Promise { + const data: CoreFormFields = {}; + const errors: AddonModWorkshopAssessmentStrategyFieldErrors = {}; + let hasErrors = false; + + form.fields.forEach((field, idx) => { + if (idx < form.dimenssionscount) { + if (currentValues[idx].peercomment) { + data['peercomment__idx_' + idx] = currentValues[idx].peercomment; + } else { + errors['peercomment_' + idx] = Translate.instant('core.err_required'); + hasErrors = true; + } + + data['gradeid__idx_' + idx] = parseInt(form.current[idx].gradeid, 10) || 0; + data['dimensionid__idx_' + idx] = parseInt(field.dimensionid, 10); + } + }); + + if (hasErrors) { + throw errors; + } + + return data; + } + +} +export const AddonModWorkshopAssessmentStrategyCommentsHandler = + makeSingleton(AddonModWorkshopAssessmentStrategyCommentsHandlerService); diff --git a/src/addons/mod/workshop/assessment/numerrors/component/addon-mod-workshop-assessment-strategy-numerrors.html b/src/addons/mod/workshop/assessment/numerrors/component/addon-mod-workshop-assessment-strategy-numerrors.html new file mode 100644 index 000000000..7a83404f8 --- /dev/null +++ b/src/addons/mod/workshop/assessment/numerrors/component/addon-mod-workshop-assessment-strategy-numerrors.html @@ -0,0 +1,53 @@ + + + + +

{{ field.dimtitle }}

+ + +
+
+ + + + + + {{ 'addon.mod_workshop.yourassessmentfor' | translate : {'$a': field.dimtitle } }} + + + + + + + + + + + + + + + + + + + + {{ 'addon.mod_workshop_assessment_numerrors.dimensioncommentfor' | translate : {'$a': field.dimtitle } }} + + + + + + +

{{ 'addon.mod_workshop_assessment_numerrors.dimensioncommentfor' | translate : {'$a': field.dimtitle } }}

+

+ + +

+
+
+
+
diff --git a/src/addons/mod/workshop/assessment/numerrors/component/numerrors.ts b/src/addons/mod/workshop/assessment/numerrors/component/numerrors.ts new file mode 100644 index 000000000..4f3d37d57 --- /dev/null +++ b/src/addons/mod/workshop/assessment/numerrors/component/numerrors.ts @@ -0,0 +1,25 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component } from '@angular/core'; +import { AddonModWorkshopAssessmentStrategyBaseComponent } from '../../../classes/assessment-strategy-component'; + +/** + * Component for numerrors assessment strategy. + */ +@Component({ + selector: 'addon-mod-workshop-assessment-strategy-numerrors', + templateUrl: 'addon-mod-workshop-assessment-strategy-numerrors.html', +}) +export class AddonModWorkshopAssessmentStrategyNumErrorsComponent extends AddonModWorkshopAssessmentStrategyBaseComponent { } diff --git a/src/addons/mod/workshop/assessment/numerrors/lang.json b/src/addons/mod/workshop/assessment/numerrors/lang.json new file mode 100644 index 000000000..d12b03206 --- /dev/null +++ b/src/addons/mod/workshop/assessment/numerrors/lang.json @@ -0,0 +1,5 @@ +{ + "dimensioncommentfor": "Comment for {{$a}}", + "dimensiongradefor": "Grade for {{$a}}", + "dimensionnumber": "Assertion {{$a}}" +} diff --git a/src/addons/mod/workshop/assessment/numerrors/numerrors.module.ts b/src/addons/mod/workshop/assessment/numerrors/numerrors.module.ts new file mode 100644 index 000000000..dab6f1f38 --- /dev/null +++ b/src/addons/mod/workshop/assessment/numerrors/numerrors.module.ts @@ -0,0 +1,44 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreSharedModule } from '@/core/shared.module'; +import { APP_INITIALIZER, NgModule } from '@angular/core'; +import { AddonWorkshopAssessmentStrategyDelegate } from '../../services/assessment-strategy-delegate'; +import { AddonModWorkshopAssessmentStrategyNumErrorsComponent } from './component/numerrors'; +import { AddonModWorkshopAssessmentStrategyNumErrorsHandler } from './services/handler'; + +@NgModule({ + declarations: [ + AddonModWorkshopAssessmentStrategyNumErrorsComponent, + ], + imports: [ + CoreSharedModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + AddonWorkshopAssessmentStrategyDelegate.registerHandler( + AddonModWorkshopAssessmentStrategyNumErrorsHandler.instance, + ); + }, + }, + ], + exports: [ + AddonModWorkshopAssessmentStrategyNumErrorsComponent, + ], +}) +export class AddonModWorkshopAssessmentStrategyNumErrorsModule {} diff --git a/src/addons/mod/workshop/assessment/numerrors/services/handler.ts b/src/addons/mod/workshop/assessment/numerrors/services/handler.ts new file mode 100644 index 000000000..195998169 --- /dev/null +++ b/src/addons/mod/workshop/assessment/numerrors/services/handler.ts @@ -0,0 +1,134 @@ +// (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 { + AddonModWorkshopAssessmentStrategyFieldErrors, +} from '@addons/mod/workshop/components/assessment-strategy/assessment-strategy'; +import { AddonWorkshopAssessmentStrategyHandler } from '@addons/mod/workshop/services/assessment-strategy-delegate'; +import { + AddonModWorkshopGetAssessmentFormDefinitionData, + AddonModWorkshopGetAssessmentFormFieldsParsedData, +} from '@addons/mod/workshop/services/workshop'; +import { Injectable, Type } from '@angular/core'; +import { Translate, makeSingleton } from '@singletons'; +import { CoreFormFields } from '@singletons/form'; +import { AddonModWorkshopAssessmentStrategyNumErrorsComponent } from '../component/numerrors'; + +/** + * Handler for numerrors assessment strategy plugin. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModWorkshopAssessmentStrategyNumErrorsHandlerService implements AddonWorkshopAssessmentStrategyHandler { + + name = 'AddonModWorkshopAssessmentStrategyNumErrors'; + strategyName = 'numerrors'; + + /** + * @inheritdoc + */ + async isEnabled(): Promise { + return true; + } + + /** + * @inheritdoc + */ + getComponent(): Type { + return AddonModWorkshopAssessmentStrategyNumErrorsComponent; + } + + /** + * @inheritdoc + */ + async getOriginalValues( + form: AddonModWorkshopGetAssessmentFormDefinitionData, + ): Promise { + const originalValues: AddonModWorkshopGetAssessmentFormFieldsParsedData[] = []; + + form.fields.forEach((field, n) => { + field.dimtitle = Translate.instant('addon.mod_workshop_assessment_numerrors.dimensionnumber', { $a: field.number }); + + if (!form.current[n]) { + form.current[n] = {}; + } + + originalValues[n] = {}; + originalValues[n].peercomment = form.current[n].peercomment || ''; + originalValues[n].number = field.number; // eslint-disable-line id-blacklist + originalValues[n].grade = form.current[n].grade || ''; + }); + + return originalValues; + } + + /** + * @inheritdoc + */ + hasDataChanged( + originalValues: AddonModWorkshopGetAssessmentFormFieldsParsedData[], + currentValues: AddonModWorkshopGetAssessmentFormFieldsParsedData[], + ): boolean { + for (const x in originalValues) { + if (originalValues[x].grade != currentValues[x].grade) { + return true; + } + if (originalValues[x].peercomment != currentValues[x].peercomment) { + return true; + } + } + + return false; + } + + /** + * @inheritdoc + */ + async prepareAssessmentData( + currentValues: AddonModWorkshopGetAssessmentFormFieldsParsedData[], + form: AddonModWorkshopGetAssessmentFormDefinitionData, + ): Promise { + const data: CoreFormFields = {}; + const errors: AddonModWorkshopAssessmentStrategyFieldErrors = {}; + let hasErrors = false; + + form.fields.forEach((field, idx) => { + if (idx < form.dimenssionscount) { + const grade = parseInt(String(currentValues[idx].grade), 10); + if (!isNaN(grade) && (grade == 1 || grade == -1)) { + data['grade__idx_' + idx] = grade; + } else { + errors['grade_' + idx] = Translate.instant('core.required'); + hasErrors = true; + } + + if (currentValues[idx].peercomment) { + data['peercomment__idx_' + idx] = currentValues[idx].peercomment; + } + + data['gradeid__idx_' + idx] = parseInt(form.current[idx].gradeid, 10) || 0; + data['dimensionid__idx_' + idx] = parseInt(field.dimensionid, 10); + data['weight__idx_' + idx] = parseInt(field.weight, 10) || 0; + } + }); + + if (hasErrors) { + throw errors; + } + + return data; + } + +} +export const AddonModWorkshopAssessmentStrategyNumErrorsHandler = + makeSingleton(AddonModWorkshopAssessmentStrategyNumErrorsHandlerService); diff --git a/src/addons/mod/workshop/assessment/rubric/component/addon-mod-workshop-assessment-strategy-rubric.html b/src/addons/mod/workshop/assessment/rubric/component/addon-mod-workshop-assessment-strategy-rubric.html new file mode 100644 index 000000000..1b4387116 --- /dev/null +++ b/src/addons/mod/workshop/assessment/rubric/component/addon-mod-workshop-assessment-strategy-rubric.html @@ -0,0 +1,26 @@ + + + + +

{{ field.dimtitle }}

+ + +
+ + +
+ + + + +

+

+
+ +
+
+
+
+
diff --git a/src/addons/mod/workshop/assessment/rubric/component/rubric.ts b/src/addons/mod/workshop/assessment/rubric/component/rubric.ts new file mode 100644 index 000000000..bd8de3a09 --- /dev/null +++ b/src/addons/mod/workshop/assessment/rubric/component/rubric.ts @@ -0,0 +1,25 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component } from '@angular/core'; +import { AddonModWorkshopAssessmentStrategyBaseComponent } from '../../../classes/assessment-strategy-component'; + +/** + * Component for rubric assessment strategy. + */ +@Component({ + selector: 'addon-mod-workshop-assessment-strategy-rubric', + templateUrl: 'addon-mod-workshop-assessment-strategy-rubric.html', +}) +export class AddonModWorkshopAssessmentStrategyRubricComponent extends AddonModWorkshopAssessmentStrategyBaseComponent { } diff --git a/src/addons/mod/workshop/assessment/rubric/lang.json b/src/addons/mod/workshop/assessment/rubric/lang.json new file mode 100644 index 000000000..8b0b20c4a --- /dev/null +++ b/src/addons/mod/workshop/assessment/rubric/lang.json @@ -0,0 +1,4 @@ +{ + "dimensionnumber": "Criterion {{$a}}", + "mustchooseone": "You have to select one of these items" +} \ No newline at end of file diff --git a/src/addons/mod/workshop/assessment/rubric/rubric.module.ts b/src/addons/mod/workshop/assessment/rubric/rubric.module.ts new file mode 100644 index 000000000..04885352e --- /dev/null +++ b/src/addons/mod/workshop/assessment/rubric/rubric.module.ts @@ -0,0 +1,44 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreSharedModule } from '@/core/shared.module'; +import { APP_INITIALIZER, NgModule } from '@angular/core'; +import { AddonWorkshopAssessmentStrategyDelegate } from '../../services/assessment-strategy-delegate'; +import { AddonModWorkshopAssessmentStrategyRubricComponent } from './component/rubric'; +import { AddonModWorkshopAssessmentStrategyRubricHandler } from './services/handler'; + +@NgModule({ + declarations: [ + AddonModWorkshopAssessmentStrategyRubricComponent, + ], + imports: [ + CoreSharedModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + AddonWorkshopAssessmentStrategyDelegate.registerHandler( + AddonModWorkshopAssessmentStrategyRubricHandler.instance, + ); + }, + }, + ], + exports: [ + AddonModWorkshopAssessmentStrategyRubricComponent, + ], +}) +export class AddonModWorkshopAssessmentStrategyRubricModule {} diff --git a/src/addons/mod/workshop/assessment/rubric/services/handler.ts b/src/addons/mod/workshop/assessment/rubric/services/handler.ts new file mode 100644 index 000000000..a4bbd1d91 --- /dev/null +++ b/src/addons/mod/workshop/assessment/rubric/services/handler.ts @@ -0,0 +1,125 @@ +// (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 { + AddonModWorkshopAssessmentStrategyFieldErrors, +} from '@addons/mod/workshop/components/assessment-strategy/assessment-strategy'; +import { AddonWorkshopAssessmentStrategyHandler } from '@addons/mod/workshop/services/assessment-strategy-delegate'; +import { + AddonModWorkshopGetAssessmentFormDefinitionData, + AddonModWorkshopGetAssessmentFormFieldsParsedData, +} from '@addons/mod/workshop/services/workshop'; +import { Injectable, Type } from '@angular/core'; +import { Translate, makeSingleton } from '@singletons'; +import { CoreFormFields } from '@singletons/form'; +import { AddonModWorkshopAssessmentStrategyRubricComponent } from '../component/rubric'; + +/** + * Handler for rubric assessment strategy plugin. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModWorkshopAssessmentStrategyRubricHandlerService implements AddonWorkshopAssessmentStrategyHandler { + + name = 'AddonModWorkshopAssessmentStrategyRubric'; + strategyName = 'rubric'; + + /** + * @inheritdoc + */ + async isEnabled(): Promise { + return true; + } + + /** + * @inheritdoc + */ + getComponent(): Type { + return AddonModWorkshopAssessmentStrategyRubricComponent; + } + + /** + * @inheritdoc + */ + async getOriginalValues( + form: AddonModWorkshopGetAssessmentFormDefinitionData, + ): Promise { + const originalValues: AddonModWorkshopGetAssessmentFormFieldsParsedData[] = []; + + form.fields.forEach((field, n) => { + field.dimtitle = Translate.instant('addon.mod_workshop_assessment_rubric.dimensionnumber', { $a: field.number }); + + if (!form.current[n]) { + form.current[n] = {}; + } + + originalValues[n] = {}; + originalValues[n].chosenlevelid = form.current[n].chosenlevelid || ''; + originalValues[n].number = field.number; // eslint-disable-line id-blacklist + }); + + return originalValues; + } + + /** + * @inheritdoc + */ + hasDataChanged( + originalValues: AddonModWorkshopGetAssessmentFormFieldsParsedData[], + currentValues: AddonModWorkshopGetAssessmentFormFieldsParsedData[], + ): boolean { + for (const x in originalValues) { + if (originalValues[x].chosenlevelid != (currentValues[x].chosenlevelid || '')) { + return true; + } + } + + return false; + } + + /** + * @inheritdoc + */ + async prepareAssessmentData( + currentValues: AddonModWorkshopGetAssessmentFormFieldsParsedData[], + form: AddonModWorkshopGetAssessmentFormDefinitionData, + ): Promise { + const data: CoreFormFields = {}; + const errors: AddonModWorkshopAssessmentStrategyFieldErrors = {}; + let hasErrors = false; + + form.fields.forEach((field, idx) => { + if (idx < form.dimenssionscount) { + const id = parseInt(currentValues[idx].chosenlevelid, 10); + if (!isNaN(id) && id >= 0) { + data['chosenlevelid__idx_' + idx] = id; + } else { + errors['chosenlevelid_' + idx] = Translate.instant('addon.mod_workshop_assessment_rubric.mustchooseone'); + hasErrors = true; + } + + data['gradeid__idx_' + idx] = parseInt(form.current[idx].gradeid, 10) || 0; + data['dimensionid__idx_' + idx] = parseInt(field.dimensionid, 10); + } + }); + + if (hasErrors) { + throw errors; + } + + return data; + } + +} +export const AddonModWorkshopAssessmentStrategyRubricHandler = + makeSingleton(AddonModWorkshopAssessmentStrategyRubricHandlerService); diff --git a/src/addons/mod/workshop/classes/assessment-strategy-component.ts b/src/addons/mod/workshop/classes/assessment-strategy-component.ts new file mode 100644 index 000000000..1e9c85548 --- /dev/null +++ b/src/addons/mod/workshop/classes/assessment-strategy-component.ts @@ -0,0 +1,36 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input } from '@angular/core'; +import { AddonModWorkshopGetAssessmentFormFieldsParsedData } from '../services/workshop'; +import { AddonModWorkshopSubmissionAssessmentWithFormData } from '../services/workshop-helper'; + +/** + * Base class for component to render an assessment strategy. + */ +@Component({ + template: '', +}) +export class AddonModWorkshopAssessmentStrategyBaseComponent { + + @Input() workshopId!: number; + @Input() assessment!: AddonModWorkshopSubmissionAssessmentWithFormData; + @Input() edit!: boolean; + @Input() selectedValues!: AddonModWorkshopGetAssessmentFormFieldsParsedData[]; + @Input() fieldErrors!: Record; + @Input() strategy!: string; + @Input() moduleId!: number; + @Input() courseId?: number; + +} diff --git a/src/addons/mod/workshop/components/assessment-strategy/addon-mod-workshop-assessment-strategy.html b/src/addons/mod/workshop/components/assessment-strategy/addon-mod-workshop-assessment-strategy.html new file mode 100644 index 000000000..d4da19737 --- /dev/null +++ b/src/addons/mod/workshop/components/assessment-strategy/addon-mod-workshop-assessment-strategy.html @@ -0,0 +1,68 @@ +

{{ 'addon.mod_workshop.assessmentform' | translate }}

+ +
+ + + + + + + + +

{{ 'addon.mod_workshop.assessmentstrategynotsupported' | translate:{$a: strategy} }}

+
+
+
+ + + +

{{ 'addon.mod_workshop.overallfeedback' | translate }}

+
+ + + + {{ 'addon.mod_workshop.feedbackauthor' | translate }} + + + + + + + + + + + + + {{ 'addon.mod_workshop.assessmentweight' | translate }} + + + + {{w}} + + + + + + + + + + + + + +
+
+
diff --git a/src/addons/mod/workshop/components/assessment-strategy/assessment-strategy.ts b/src/addons/mod/workshop/components/assessment-strategy/assessment-strategy.ts new file mode 100644 index 000000000..f46b85d90 --- /dev/null +++ b/src/addons/mod/workshop/components/assessment-strategy/assessment-strategy.ts @@ -0,0 +1,426 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, OnInit, ViewChild, ElementRef, Type, OnDestroy } from '@angular/core'; +import { FormControl } from '@angular/forms'; +import { CoreError } from '@classes/errors/error'; +import { CoreFileUploader, CoreFileUploaderStoreFilesResult } from '@features/fileuploader/services/fileuploader'; +import { CoreFile } from '@services/file'; +import { CoreFileEntry, CoreFileHelper } from '@services/file-helper'; +import { CoreFileSession } from '@services/file-session'; +import { CoreSites } from '@services/sites'; +import { CoreSync } from '@services/sync'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { Translate } from '@singletons'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { CoreFormFields, CoreForms } from '@singletons/form'; +import { AddonWorkshopAssessmentStrategyDelegate } from '../../services/assessment-strategy-delegate'; +import { + AddonModWorkshopProvider, + AddonModWorkshopOverallFeedbackMode, + AddonModWorkshop, + AddonModWorkshopData, + AddonModWorkshopGetWorkshopAccessInformationWSResponse, + AddonModWorkshopGetAssessmentFormFieldsParsedData, +} from '../../services/workshop'; +import { AddonModWorkshopHelper, AddonModWorkshopSubmissionAssessmentWithFormData } from '../../services/workshop-helper'; +import { AddonModWorkshopOffline } from '../../services/workshop-offline'; + +/** + * Component that displays workshop assessment strategy form. + */ +@Component({ + selector: 'addon-mod-workshop-assessment-strategy', + templateUrl: 'addon-mod-workshop-assessment-strategy.html', +}) +export class AddonModWorkshopAssessmentStrategyComponent implements OnInit, OnDestroy { + + @Input() workshop!: AddonModWorkshopData; + @Input() access!: AddonModWorkshopGetWorkshopAccessInformationWSResponse; + @Input() assessmentId!: number; + @Input() userId!: number; + @Input() strategy!: string; + @Input() edit = false; + + @ViewChild('assessmentForm') formElement!: ElementRef; + + componentClass?: Type; + data: AddonModWorkshopAssessmentStrategyData = { + workshopId: 0, + assessment: undefined, + edit: false, + selectedValues: [], + fieldErrors: {}, + strategy: '', + moduleId: 0, + courseId: undefined, + }; + + assessmentStrategyLoaded = false; + notSupported = false; + feedbackText = ''; + feedbackControl = new FormControl(); + overallFeedkback = false; + overallFeedkbackRequired = false; + component = AddonModWorkshopProvider.COMPONENT; + componentId?: number; + weights: number[] = []; + weight?: number; + + protected obsInvalidated?: CoreEventObserver; + protected hasOffline = false; + protected originalData: { + text: string; + files: CoreFileEntry[]; + weight: number; + selectedValues: AddonModWorkshopGetAssessmentFormFieldsParsedData[]; + } = { + text: '', + files: [], + weight: 1, + selectedValues: [], + }; + + /** + * Component being initialized. + */ + async ngOnInit(): Promise { + if (!this.assessmentId || !this.strategy) { + this.assessmentStrategyLoaded = true; + + return; + } + + this.data.workshopId = this.workshop.id; + this.data.edit = this.edit; + this.data.strategy = this.strategy; + this.data.moduleId = this.workshop.coursemodule; + this.data.courseId = this.workshop.course; + + this.componentClass = AddonWorkshopAssessmentStrategyDelegate.getComponentForPlugin(this.strategy); + if (this.componentClass) { + this.overallFeedkback = this.workshop.overallfeedbackmode != AddonModWorkshopOverallFeedbackMode.DISABLED; + this.overallFeedkbackRequired = + this.workshop.overallfeedbackmode == AddonModWorkshopOverallFeedbackMode.ENABLED_REQUIRED; + this.componentId = this.workshop.coursemodule; + + // Load Weights selector. + if (this.edit && this.access.canallocate) { + this.weights; + for (let i = 16; i >= 0; i--) { + this.weights[i] = i; + } + } + + // Check if rich text editor is enabled. + if (this.edit) { + // Block the workshop. + CoreSync.blockOperation(AddonModWorkshopProvider.COMPONENT, this.workshop.id); + } + + try { + await this.load(); + this.obsInvalidated = CoreEvents.on( + AddonModWorkshopProvider.ASSESSMENT_INVALIDATED, + this.load.bind(this), + + CoreSites.getCurrentSiteId(), + ); + } catch (error) { + this.componentClass = undefined; + CoreDomUtils.showErrorModalDefault(error, 'Error loading assessment.'); + } finally { + this.assessmentStrategyLoaded = true; + } + } else { + // Helper data and fallback. + this.notSupported = !AddonWorkshopAssessmentStrategyDelegate.isPluginSupported(this.strategy); + this.assessmentStrategyLoaded = true; + } + } + + /** + * Convenience function to load the assessment data. + * + * @return Promised resvoled when data is loaded. + */ + protected async load(): Promise { + this.data.assessment = await AddonModWorkshopHelper.getReviewerAssessmentById(this.workshop.id, this.assessmentId, { + userId: this.userId, + cmId: this.workshop.coursemodule, + }); + + if (this.edit) { + try { + const offlineAssessment = await AddonModWorkshopOffline.getAssessment(this.workshop.id, this.assessmentId); + const offlineData = offlineAssessment.inputdata; + + this.hasOffline = true; + + this.data.assessment.feedbackauthor = offlineData.feedbackauthor; + + if (this.access.canallocate) { + this.data.assessment.weight = offlineData.weight; + } + + // Override assessment plugins values. + this.data.assessment.form!.current = AddonModWorkshop.parseFields( + CoreUtils.objectToArrayOfObjects(offlineData, 'name', 'value'), + ); + + // Override offline files. + if (offlineData) { + this.data.assessment.feedbackattachmentfiles = + await AddonModWorkshopHelper.getAssessmentFilesFromOfflineFilesObject( + offlineData.feedbackauthorattachmentsid, + this.workshop.id, + this.assessmentId, + ); + } + } catch { + this.hasOffline = false; + // Ignore errors. + } finally { + this.feedbackText = this.data.assessment.feedbackauthor; + this.feedbackControl.setValue(this.feedbackText); + + this.originalData.text = this.data.assessment.feedbackauthor; + + if (this.access.canallocate) { + this.originalData.weight = this.data.assessment.weight; + } + + this.originalData.files = []; + this.data.assessment.feedbackattachmentfiles.forEach((file) => { + let filename = CoreFile.getFileName(file); + if (!filename) { + // We don't have filename, extract it from the path. + filename = CoreFileHelper.getFilenameFromPath(file) || ''; + } + + this.originalData.files.push({ + filename, + fileurl: '', // No needed to compare. + }); + }); + } + } + + try { + this.data.selectedValues = await AddonWorkshopAssessmentStrategyDelegate.getOriginalValues( + this.strategy, + this.data.assessment.form!, + this.workshop.id, + ); + } finally { + this.originalData.selectedValues = CoreUtils.clone(this.data.selectedValues); + if (this.edit) { + CoreFileSession.setFiles( + AddonModWorkshopProvider.COMPONENT, + this.workshop.id + '_' + this.assessmentId, + this.data.assessment.feedbackattachmentfiles, + ); + if (this.access.canallocate) { + this.weight = this.data.assessment.weight; + } + } + } + } + + /** + * Check if data has changed. + * + * @return True if data has changed. + */ + hasDataChanged(): boolean { + if (!this.assessmentStrategyLoaded) { + return false; + } + + // Compare feedback text. + const text = CoreTextUtils.restorePluginfileUrls(this.feedbackText, this.data.assessment?.feedbackcontentfiles || []); + if (this.originalData.text != text) { + return true; + } + + if (this.access.canallocate && this.originalData.weight != this.weight) { + return true; + } + + // Compare feedback files. + const files = CoreFileSession.getFiles( + AddonModWorkshopProvider.COMPONENT, + this.workshop.id + '_' + this.assessmentId, + ) || []; + if (CoreFileUploader.areFileListDifferent(files, this.originalData.files)) { + return true; + } + + return AddonWorkshopAssessmentStrategyDelegate.hasDataChanged( + this.workshop.strategy!, + this.originalData.selectedValues, + this.data.selectedValues, + ); + } + + /** + * Save the assessment. + * + * @return Promise resolved when done, rejected if assessment could not be saved. + */ + async saveAssessment(): Promise { + const files = CoreFileSession.getFiles( + AddonModWorkshopProvider.COMPONENT, + this.workshop.id + '_' + this.assessmentId, + ) || []; + + let saveOffline = false; + let allowOffline = !files.length; + + const modal = await CoreDomUtils.showModalLoading('core.sending', true); + + this.data.fieldErrors = {}; + + try { + let attachmentsId: CoreFileUploaderStoreFilesResult | number; + try { + // Upload attachments first if any. + attachmentsId = await AddonModWorkshopHelper.uploadOrStoreAssessmentFiles( + this.workshop.id, + this.assessmentId, + files, + saveOffline, + ); + } catch { + // Cannot upload them in online, save them in offline. + saveOffline = true; + allowOffline = true; + + attachmentsId = await AddonModWorkshopHelper.uploadOrStoreAssessmentFiles( + this.workshop.id, + this.assessmentId, + files, + saveOffline, + ); + } + + const text = CoreTextUtils.restorePluginfileUrls(this.feedbackText, this.data.assessment?.feedbackcontentfiles || []); + + let assessmentData: CoreFormFields; + try { + assessmentData = await AddonModWorkshopHelper.prepareAssessmentData( + this.workshop, + this.data.selectedValues, + text, + this.data.assessment!.form!, + attachmentsId, + ); + } catch (errors) { + this.data.fieldErrors = errors; + + throw new CoreError(Translate.instant('core.errorinvalidform')); + } + + let gradeUpdated = false; + if (saveOffline) { + // Save assessment in offline. + await AddonModWorkshopOffline.saveAssessment( + this.workshop.id, + this.assessmentId, + this.workshop.course, + assessmentData, + ); + + gradeUpdated = false; + } else { + + // Try to send it to server. + // Don't allow offline if there are attachments since they were uploaded fine. + gradeUpdated = await AddonModWorkshop.updateAssessment( + this.workshop.id, + this.assessmentId, + this.workshop.course, + assessmentData, + undefined, + allowOffline, + ); + } + + CoreForms.triggerFormSubmittedEvent(this.formElement, !!gradeUpdated, CoreSites.getCurrentSiteId()); + + const promises: Promise[] = []; + + // If sent to the server, invalidate and clean. + if (gradeUpdated) { + promises.push(AddonModWorkshopHelper.deleteAssessmentStoredFiles(this.workshop.id, this.assessmentId)); + promises.push(AddonModWorkshop.invalidateAssessmentFormData(this.workshop.id, this.assessmentId)); + promises.push(AddonModWorkshop.invalidateAssessmentData(this.workshop.id, this.assessmentId)); + } + + await CoreUtils.ignoreErrors(Promise.all(promises)); + + CoreEvents.trigger(AddonModWorkshopProvider.ASSESSMENT_SAVED, { + workshopId: this.workshop.id, + assessmentId: this.assessmentId, + userId: CoreSites.getCurrentSiteUserId(), + }, CoreSites.getCurrentSiteId()); + + if (files) { + // Delete the local files from the tmp folder. + CoreFileUploader.clearTmpFiles(files); + } + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'Error saving assessment.'); + } finally { + modal.dismiss(); + } + } + + /** + * Feedback text changed. + * + * @param text The new text. + */ + onFeedbackChange(text: string): void { + this.feedbackText = text; + } + + /** + * Component destroyed. + */ + ngOnDestroy(): void { + this.obsInvalidated?.off(); + + if (this.data.assessment?.feedbackattachmentfiles) { + // Delete the local files from the tmp folder. + CoreFileUploader.clearTmpFiles(this.data.assessment.feedbackattachmentfiles); + } + } + +} + +type AddonModWorkshopAssessmentStrategyData = { + workshopId: number; + assessment?: AddonModWorkshopSubmissionAssessmentWithFormData; + edit: boolean; + selectedValues: AddonModWorkshopGetAssessmentFormFieldsParsedData[]; + fieldErrors: AddonModWorkshopAssessmentStrategyFieldErrors; + strategy: string; + moduleId: number; + courseId?: number; +}; + +export type AddonModWorkshopAssessmentStrategyFieldErrors = Record; diff --git a/src/addons/mod/workshop/components/assessment/addon-mod-workshop-assessment.html b/src/addons/mod/workshop/components/assessment/addon-mod-workshop-assessment.html new file mode 100644 index 000000000..6439a8eaa --- /dev/null +++ b/src/addons/mod/workshop/components/assessment/addon-mod-workshop-assessment.html @@ -0,0 +1,27 @@ + + + + +

{{profile.fullname}}

+

+ {{ 'addon.mod_workshop.submissiongradeof' | translate:{$a: workshop.grade } }}: {{assessment.grade}} +

+

+ {{ 'addon.mod_workshop.gradinggradeof' | translate:{$a: workshop.gradinggrade } }}: {{assessment.gradinggrade}} +

+

+ {{ 'addon.mod_workshop.gradinggradeof' | translate:{$a: workshop.gradinggrade } }}: {{assessment.gradinggradeover}} +

+

+ {{ 'addon.mod_workshop.weightinfo' | translate:{$a: assessment.weight } }} +

+ {{ 'addon.mod_workshop.notassessed' | translate }} + + {{ 'addon.mod_workshop.assess' | translate }} + +
+ + {{ 'core.notsent' | translate }} + +
+
diff --git a/src/addons/mod/workshop/components/assessment/assessment.ts b/src/addons/mod/workshop/components/assessment/assessment.ts new file mode 100644 index 000000000..42fc54af1 --- /dev/null +++ b/src/addons/mod/workshop/components/assessment/assessment.ts @@ -0,0 +1,170 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, OnInit } from '@angular/core'; +import { Params } from '@angular/router'; +import { CoreCourseModule } from '@features/course/services/course-helper'; +import { CoreUser, CoreUserProfile } from '@features/user/services/user'; +import { CoreNavigator } from '@services/navigator'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { AddonModWorkshopData, AddonModWorkshopGetWorkshopAccessInformationWSResponse } from '../../services/workshop'; +import { + AddonModWorkshopHelper, + AddonModWorkshopSubmissionAssessmentWithFormData, + AddonModWorkshopSubmissionDataWithOfflineData, +} from '../../services/workshop-helper'; +import { AddonModWorkshopOffline } from '../../services/workshop-offline'; + +/** + * Component that displays workshop assessment. + */ +@Component({ + selector: 'addon-mod-workshop-assessment', + templateUrl: 'addon-mod-workshop-assessment.html', +}) +export class AddonModWorkshopAssessmentComponent implements OnInit { + + @Input() assessment!: AddonModWorkshopSubmissionAssessmentWithFormData; + @Input() courseId!: number; + @Input() workshop!: AddonModWorkshopData; + @Input() access!: AddonModWorkshopGetWorkshopAccessInformationWSResponse; + @Input() protected submission!: AddonModWorkshopSubmissionDataWithOfflineData; + @Input() protected module!: CoreCourseModule; + + canViewAssessment = false; + canSelfAssess = false; + profile?: CoreUserProfile; + showGrade: (grade?: string | number) => boolean; + offline = false; + loaded = false; + + protected currentUserId: number; + protected assessmentId?: number; + + constructor() { + this.currentUserId = CoreSites.getCurrentSiteUserId(); + this.showGrade = AddonModWorkshopHelper.showGrade; + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + const canAssess = this.access && this.access.assessingallowed; + const userId = this.assessment.reviewerid; + const promises: Promise[] = []; + + this.assessmentId = this.assessment.id; + this.canViewAssessment = !!this.assessment.grade; + this.canSelfAssess = canAssess && userId == this.currentUserId; + + if (userId) { + promises.push(CoreUser.getProfile(userId, this.courseId, true).then((profile) => { + this.profile = profile; + + return; + })); + } + + let assessOffline: Promise; + if (userId == this.currentUserId) { + assessOffline = AddonModWorkshopOffline.getAssessment(this.workshop.id, this.assessmentId) .then((offlineAssess) => { + this.offline = true; + this.assessment.weight = offlineAssess.inputdata.weight; + + return; + }); + } else { + assessOffline = AddonModWorkshopOffline.getEvaluateAssessment(this.workshop.id, this.assessmentId) + .then((offlineAssess) => { + this.offline = true; + this.assessment.gradinggradeover = offlineAssess.gradinggradeover; + this.assessment.weight = offlineAssess.weight; + + return; + + }); + } + + promises.push(assessOffline.catch(() => { + this.offline = false; + // Ignore errors. + })); + + Promise.all(promises).finally(() => { + this.loaded = true; + }); + } + + /** + * Navigate to the assessment. + */ + async gotoAssessment(event: Event): Promise { + if (!this.canSelfAssess && this.canViewAssessment) { + event.preventDefault(); + event.stopPropagation(); + + const params: Params = { + assessment: this.assessment, + submission: this.submission, + profile: this.profile, + }; + + if (!this.submission) { + const modal = await CoreDomUtils.showModalLoading(); + + try { + params.submission = await AddonModWorkshopHelper.getSubmissionById( + this.workshop.id, + this.assessment.submissionid, + { cmId: this.workshop.coursemodule }, + ); + + CoreNavigator.navigate(String(this.assessmentId), { params }); + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'Cannot load submission'); + } finally { + modal.dismiss(); + } + } else { + CoreNavigator.navigate(String(this.assessmentId), { params }); + } + } + } + + /** + * Navigate to my own assessment. + */ + gotoOwnAssessment(event: Event): void { + if (!this.canSelfAssess) { + return; + } + event.preventDefault(); + event.stopPropagation(); + + const params: Params = { + module: this.module, + workshop: this.workshop, + access: this.access, + profile: this.profile, + submission: this.submission, + assessment: this.assessment, + }; + + CoreNavigator.navigate(String(this.submission.id), params); + + } + +} diff --git a/src/addons/mod/workshop/components/components.module.ts b/src/addons/mod/workshop/components/components.module.ts new file mode 100644 index 000000000..cc3cb1961 --- /dev/null +++ b/src/addons/mod/workshop/components/components.module.ts @@ -0,0 +1,46 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { AddonModWorkshopIndexComponent } from './index/index'; +import { AddonModWorkshopSubmissionComponent } from './submission/submission'; +import { CoreCourseComponentsModule } from '@features/course/components/components.module'; +import { CoreEditorComponentsModule } from '@features/editor/components/components.module'; +import { CoreSharedModule } from '@/core/shared.module'; +import { AddonModWorkshopPhaseInfoComponent } from './phase/phase'; +import { AddonModWorkshopAssessmentComponent } from './assessment/assessment'; +import { AddonModWorkshopAssessmentStrategyComponent } from './assessment-strategy/assessment-strategy'; + +@NgModule({ + declarations: [ + AddonModWorkshopIndexComponent, + AddonModWorkshopSubmissionComponent, + AddonModWorkshopPhaseInfoComponent, + AddonModWorkshopAssessmentComponent, + AddonModWorkshopAssessmentStrategyComponent, + ], + imports: [ + CoreSharedModule, + CoreCourseComponentsModule, + CoreEditorComponentsModule, + ], + exports: [ + AddonModWorkshopIndexComponent, + AddonModWorkshopSubmissionComponent, + AddonModWorkshopPhaseInfoComponent, + AddonModWorkshopAssessmentComponent, + AddonModWorkshopAssessmentStrategyComponent, + ], +}) +export class AddonModWorkshopComponentsModule {} diff --git a/src/addons/mod/workshop/components/index/addon-mod-workshop-index.html b/src/addons/mod/workshop/components/index/addon-mod-workshop-index.html new file mode 100644 index 000000000..3f3526b13 --- /dev/null +++ b/src/addons/mod/workshop/components/index/addon-mod-workshop-index.html @@ -0,0 +1,250 @@ + + + + + + + + + + + + + + + + + + + + + + + + +

{{ phases[workshop!.phase].title }}

+
+
+ + + + + + + +

{{task.title}}

+

+
+ +
+
+
+ + + + + + {{ 'core.hasdatatosync' | translate: {$a: moduleName} }} + + + + + + + +

{{ 'core.description' | translate }}

+ + +
+
+
+ +
+ + + + + +

{{ 'addon.mod_workshop.conclusion' | translate }}

+ + +
+
+
+ + + +

{{ 'addon.mod_workshop.yourgrades' | translate }}

+
+ + +

{{ 'addon.mod_workshop.submissiongrade' | translate }}

+

{{ userGrades.submissionlongstrgrade }}

+
+
+ + +

{{ 'addon.mod_workshop.gradinggrade' | translate }}

+

{{ userGrades.assessmentlongstrgrade }}

+
+
+
+
+ + + + + +

{{ 'addon.mod_workshop.areainstructauthors' | translate }}

+ + +
+
+
+ + + + +

{{ 'addon.mod_workshop.yoursubmission' | translate }}

+

{{ 'addon.mod_workshop.noyoursubmission' | translate }}

+
+
+ + + + +

{{ 'addon.mod_workshop.yoursubmission' | translate }}

+

+ {{ 'addon.mod_workshop.yoursubmissionwithassessments' | translate }} +

+
+
+ + +
+
+ + + + + + + + {{ 'addon.mod_workshop.createsubmission' | translate }} + + + + {{ 'addon.mod_workshop.editsubmission' | translate }} + + + + + + + + +

{{ 'addon.mod_workshop.publishedsubmissions' | translate }}

+
+ + + + +
+
+ + + + + + +

{{ 'addon.mod_workshop.areainstructreviewers' | translate }}

+ + +
+
+
+ + + +

{{ 'addon.mod_workshop.assignedassessments' | translate }}

+
+ +

{{ 'addon.mod_workshop.assignedassessmentsnone' | translate }}

+
+ + + + +
+
+ + + + + +

{{ 'addon.mod_workshop.submissionsreport' | translate }}

+

{{ 'addon.mod_workshop.gradesreport' | translate }}

+
+
+ + + {{ 'core.groupsseparate' | translate }} + + + {{ 'core.groupsvisible' | translate }} + + + + {{groupOpt.name}} + + + + + + + + + + + + + + + {{ 'core.previous' | translate }} + + + + + {{ 'core.next' | translate }} + + + + + +
+
+
diff --git a/src/addons/mod/workshop/components/index/index.ts b/src/addons/mod/workshop/components/index/index.ts new file mode 100644 index 000000000..cacda71fd --- /dev/null +++ b/src/addons/mod/workshop/components/index/index.ts @@ -0,0 +1,555 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, OnDestroy, OnInit, Optional } from '@angular/core'; +import { Params } from '@angular/router'; +import { CoreCourseModuleMainActivityComponent } from '@features/course/classes/main-activity-component'; +import { CoreCourseContentsPage } from '@features/course/pages/contents/contents'; +import { CoreCourse } from '@features/course/services/course'; +import { IonContent } from '@ionic/angular'; +import { CoreGroupInfo, CoreGroups } from '@services/groups'; +import { CoreNavigator } from '@services/navigator'; +import { CoreUtils } from '@services/utils/utils'; +import { ModalController, Platform } from '@singletons'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { Subscription } from 'rxjs'; +import { AddonModWorkshopModuleHandlerService } from '../../services/handlers/module'; +import { + AddonModWorkshopProvider, + AddonModWorkshopPhase, + AddonModWorkshop, + AddonModWorkshopData, + AddonModWorkshopGetWorkshopAccessInformationWSResponse, + AddonModWorkshopPhaseData, + AddonModWorkshopGetGradesWSResponse, + AddonModWorkshopAssessmentSavedChangedEventData, + AddonModWorkshopSubmissionChangedEventData, + AddonModWorkshopGradesData, + AddonModWorkshopPhaseTaskData, + AddonModWorkshopReviewer, +} from '../../services/workshop'; +import { + AddonModWorkshopHelper, + AddonModWorkshopSubmissionAssessmentWithFormData, + AddonModWorkshopSubmissionDataWithOfflineData, +} from '../../services/workshop-helper'; +import { AddonModWorkshopOffline, AddonModWorkshopOfflineSubmission } from '../../services/workshop-offline'; +import { + AddonModWorkshopSyncProvider, + AddonModWorkshopSync, + AddonModWorkshopAutoSyncData, + AddonModWorkshopSyncResult, +} from '../../services/workshop-sync'; +import { AddonModWorkshopPhaseInfoComponent } from '../phase/phase'; + +/** + * Component that displays a workshop index page. + */ +@Component({ + selector: 'addon-mod-workshop-index', + templateUrl: 'addon-mod-workshop-index.html', +}) +export class AddonModWorkshopIndexComponent extends CoreCourseModuleMainActivityComponent implements OnInit, OnDestroy { + + @Input() group = 0; + + component = AddonModWorkshopProvider.COMPONENT; + moduleName = 'workshop'; + + workshop?: AddonModWorkshopData; + page = 0; + access?: AddonModWorkshopGetWorkshopAccessInformationWSResponse; + phases?: Record; + grades: AddonModWorkshopSubmissionDataWithOfflineData[] = []; + assessments: AddonModWorkshopSubmissionAssessmentWithFormData[] = []; + userGrades?: AddonModWorkshopGetGradesWSResponse; + publishedSubmissions: AddonModWorkshopSubmissionDataWithOfflineData[] = []; + submission?: AddonModWorkshopSubmissionDataWithOfflineData; + groupInfo: CoreGroupInfo = { + groups: [], + separateGroups: false, + visibleGroups: false, + defaultGroupId: 0, + }; + + canSubmit = false; + showSubmit = false; + canAssess = false; + hasNextPage = false; + + readonly PHASE_SETUP = AddonModWorkshopPhase.PHASE_SETUP; + readonly PHASE_SUBMISSION = AddonModWorkshopPhase.PHASE_SUBMISSION; + readonly PHASE_ASSESSMENT = AddonModWorkshopPhase.PHASE_ASSESSMENT; + readonly PHASE_EVALUATION = AddonModWorkshopPhase.PHASE_EVALUATION; + readonly PHASE_CLOSED = AddonModWorkshopPhase.PHASE_CLOSED; + + protected offlineSubmissions: AddonModWorkshopOfflineSubmission[] = []; + protected obsSubmissionChanged: CoreEventObserver; + protected obsAssessmentSaved: CoreEventObserver; + protected appResumeSubscription: Subscription; + protected syncObserver?: CoreEventObserver; + protected syncEventName = AddonModWorkshopSyncProvider.AUTO_SYNCED; + + constructor ( + @Optional() content: IonContent, + @Optional() courseContentsPage?: CoreCourseContentsPage, + ) { + super('AddonModWorkshopIndexComponent', content, courseContentsPage); + + // Listen to submission and assessment changes. + this.obsSubmissionChanged = CoreEvents.on(AddonModWorkshopProvider.SUBMISSION_CHANGED, (data) => { + this.eventReceived(data); + }, this.siteId); + + // Listen to submission and assessment changes. + this.obsAssessmentSaved = CoreEvents.on(AddonModWorkshopProvider.ASSESSMENT_SAVED, (data) => { + this.eventReceived(data); + }, this.siteId); + + // Since most actions will take the user out of the app, we should refresh the view when the app is resumed. + this.appResumeSubscription = Platform.resume.subscribe(() => { + this.showLoadingAndRefresh(true); + }); + + // Refresh workshop on sync. + this.syncObserver = CoreEvents.on(AddonModWorkshopSyncProvider.AUTO_SYNCED, (data) => { + // Update just when all database is synced. + this.eventReceived(data); + }, this.siteId); + } + + /** + * @inheritdoc + */ + async ngOnInit(): Promise { + super.ngOnInit(); + + await this.loadContent(false, true); + if (!this.workshop) { + return; + } + + try { + await AddonModWorkshop.logView(this.workshop.id, this.workshop.name); + CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); + } catch (error) { + // Ignore errors. + } + } + + /** + * Function called when we receive an event of submission changes. + * + * @param data Data received by the event. + */ + protected eventReceived( + data: AddonModWorkshopAutoSyncData | + AddonModWorkshopSubmissionChangedEventData | + AddonModWorkshopAssessmentSavedChangedEventData, + ): void { + if (this.workshop?.id === data.workshopId) { + this.showLoadingAndRefresh(true); + + // Check completion since it could be configured to complete once the user adds a new discussion or replies. + CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); + } + } + + /** + * Perform the invalidate content function. + * + * @return Resolved when done. + */ + protected async invalidateContent(): Promise { + const promises: Promise[] = []; + + promises.push(AddonModWorkshop.invalidateWorkshopData(this.courseId)); + if (this.workshop) { + promises.push(AddonModWorkshop.invalidateWorkshopAccessInformationData(this.workshop.id)); + promises.push(AddonModWorkshop.invalidateUserPlanPhasesData(this.workshop.id)); + if (this.canSubmit) { + promises.push(AddonModWorkshop.invalidateSubmissionsData(this.workshop.id)); + } + if (this.access?.canviewallsubmissions) { + promises.push(AddonModWorkshop.invalidateGradeReportData(this.workshop.id)); + promises.push(CoreGroups.invalidateActivityAllowedGroups(this.workshop.coursemodule)); + promises.push(CoreGroups.invalidateActivityGroupMode(this.workshop.coursemodule)); + } + if (this.canAssess) { + promises.push(AddonModWorkshop.invalidateReviewerAssesmentsData(this.workshop.id)); + } + promises.push(AddonModWorkshop.invalidateGradesData(this.workshop.id)); + promises.push(AddonModWorkshop.invalidateWorkshopWSData(this.workshop.id)); + } + + await Promise.all(promises); + } + + /** + * Compares sync event data with current data to check if refresh content is needed. + * + * @param syncEventData Data receiven on sync observer. + * @return True if refresh is needed, false otherwise. + */ + protected isRefreshSyncNeeded(syncEventData: AddonModWorkshopAutoSyncData): boolean { + if (this.workshop && syncEventData.workshopId == this.workshop.id) { + // Refresh the data. + this.content?.scrollToTop(); + + return true; + } + + return false; + } + + /** + * Download feedback contents. + * + * @param refresh If it's refreshing content. + * @param sync If it should try to sync. + * @param showErrors If show errors to the user of hide them. + * @return Promise resolved when done. + */ + protected async fetchContent(refresh = false, sync = false, showErrors = false): Promise { + try { + this.workshop = await AddonModWorkshop.getWorkshop(this.courseId, this.module.id); + + this.description = this.workshop.intro; + this.dataRetrieved.emit(this.workshop); + + if (sync) { + // Try to synchronize the feedback. + await this.syncActivity(showErrors); + } + + // Check if there are answers stored in offline. + this.access = await AddonModWorkshop.getWorkshopAccessInformation(this.workshop.id, { cmId: this.module.id }); + + if (this.access.canviewallsubmissions) { + this.groupInfo = await CoreGroups.getActivityGroupInfo(this.workshop.coursemodule); + this.group = CoreGroups.validateGroupId(this.group, this.groupInfo); + } + + this.phases = await AddonModWorkshop.getUserPlanPhases(this.workshop.id, { cmId: this.module.id }); + + this.phases[this.workshop.phase].tasks.forEach((task) => { + if (!task.link && (task.code == 'examples' || task.code == 'prepareexamples')) { + // Add links to manage examples. + task.link = this.externalUrl!; + } + }); + + // Check if there are info stored in offline. + this.hasOffline = await AddonModWorkshopOffline.hasWorkshopOfflineData(this.workshop.id); + if (this.hasOffline) { + this.offlineSubmissions = await AddonModWorkshopOffline.getSubmissions(this.workshop.id); + } else { + this.offlineSubmissions = []; + } + + await this.setPhaseInfo(); + + } finally { + this.fillContextMenu(refresh); + } + } + + /** + * Retrieves and shows submissions grade page. + * + * @param page Page number to be retrieved. + * @return Resolved when done. + */ + async gotoSubmissionsPage(page: number): Promise { + const report = await AddonModWorkshop.getGradesReport(this.workshop!.id, { + groupId: this.group, + page, + cmId: this.module.id, + }); + + const numEntries = (report && report.grades && report.grades.length) || 0; + + this.page = page; + + this.hasNextPage = numEntries >= AddonModWorkshopProvider.PER_PAGE && ((this.page + 1) * + AddonModWorkshopProvider.PER_PAGE) < report.totalcount; + + const grades: AddonModWorkshopGradesData[] = report.grades || []; + + this.grades = []; + + await Promise.all(grades.map(async (grade) => { + const submission: AddonModWorkshopSubmissionDataWithOfflineData = { + id: grade.submissionid, + workshopid: this.workshop!.id, + example: false, + authorid: grade.userid, + timecreated: grade.submissionmodified, + timemodified: grade.submissionmodified, + title: grade.submissiontitle, + content: '', + contenttrust: 0, + attachment: 0, + grade: grade.submissiongrade, + gradeover: grade.submissiongradeover, + gradeoverby: grade.submissiongradeoverby, + published: !!grade.submissionpublished, + gradinggrade: grade.gradinggrade, + late: 0, + reviewedby: this.parseReviewer(grade.reviewedby), + reviewerof: this.parseReviewer(grade.reviewerof), + }; + + if (this.workshop!.phase == AddonModWorkshopPhase.PHASE_ASSESSMENT) { + submission.reviewedbydone = grade.reviewedby?.reduce((a, b) => a + (b.grade ? 1 : 0), 0) || 0; + submission.reviewerofdone = grade.reviewerof?.reduce((a, b) => a + (b.grade ? 1 : 0), 0) || 0; + submission.reviewedbycount = grade.reviewedby?.length || 0; + submission.reviewerofcount = grade.reviewerof?.length || 0; + } + + const offlineData = await AddonModWorkshopHelper.applyOfflineData(submission, this.offlineSubmissions); + + if (typeof offlineData != 'undefined') { + this.grades!.push(offlineData); + } + })); + } + + protected parseReviewer(reviewers: AddonModWorkshopReviewer[] = []): AddonModWorkshopSubmissionAssessmentWithFormData[] { + return reviewers.map((reviewer: AddonModWorkshopReviewer) => { + const parsed: AddonModWorkshopSubmissionAssessmentWithFormData = { + grade: reviewer.grade, + gradinggrade: reviewer.gradinggrade, + gradinggradeover: reviewer.gradinggradeover, + id: reviewer.assessmentid, + reviewerid: reviewer.userid, + submissionid: reviewer.submissionid, + weight: reviewer.weight, + timecreated: 0, + timemodified: 0, + feedbackauthor: '', + gradinggradeoverby: 0, + feedbackattachmentfiles: [], + feedbackcontentfiles: [], + feedbackauthorattachment: 0, + }; + + return parsed; + }); + } + + /** + * Open task. + * + * @param task Task to be done. + */ + runTask(task: AddonModWorkshopPhaseTaskData): void { + if (task.code == 'submit') { + this.gotoSubmit(); + } else if (task.link) { + CoreUtils.openInBrowser(task.link); + } + } + + /** + * Go to submit page. + */ + gotoSubmit(): void { + if (this.canSubmit && ((this.access!.creatingsubmissionallowed && !this.submission) || + (this.access!.modifyingsubmissionallowed && this.submission))) { + const params: Params = { + module: this.module, + access: this.access, + }; + + const submissionId = this.submission?.id || 0; + CoreNavigator.navigateToSitePath( + AddonModWorkshopModuleHandlerService.PAGE_NAME + `/${this.courseId}/${this.module.id}/${submissionId}/edit`, + { params }, + ); + + } + } + + /** + * View Phase info. + */ + async viewPhaseInfo(): Promise { + if (this.phases) { + const modal = await ModalController.create({ + component: AddonModWorkshopPhaseInfoComponent, + componentProps: { + phases: CoreUtils.objectToArray(this.phases), + workshopPhase: this.workshop!.phase, + externalUrl: this.externalUrl, + showSubmit: this.showSubmit, + }, + }); + await modal.present(); + + const result = await modal.onDidDismiss(); + if (result.data === true) { + this.gotoSubmit(); + } + } + } + + /** + * Set group to see the workshop. + * + * @param groupId Group Id. + * @return Promise resolved when done. + */ + async setGroup(groupId: number): Promise { + this.group = groupId; + + await this.gotoSubmissionsPage(0); + } + + /** + * Convenience function to set current phase information. + * + * @return Promise resolved when done. + */ + protected async setPhaseInfo(): Promise { + this.submission = undefined; + this.canAssess = false; + this.assessments = []; + this.userGrades = undefined; + this.publishedSubmissions = []; + + this.canSubmit = AddonModWorkshopHelper.canSubmit( + this.workshop!, + this.access!, + this.phases![AddonModWorkshopPhase.PHASE_SUBMISSION].tasks, + ); + + this.showSubmit = this.workshop!.phase == AddonModWorkshopPhase.PHASE_SUBMISSION && this.canSubmit && + ((this.access!.creatingsubmissionallowed && !this.submission) || + (this.access!.modifyingsubmissionallowed && !!this.submission)); + + const promises: Promise[] = []; + + if (this.canSubmit) { + promises.push(AddonModWorkshopHelper.getUserSubmission(this.workshop!.id, { cmId: this.module.id }) + .then(async (submission) => { + this.submission = await AddonModWorkshopHelper.applyOfflineData(submission, this.offlineSubmissions); + + return; + })); + } + + if (this.access!.canviewallsubmissions && this.workshop!.phase >= AddonModWorkshopPhase.PHASE_SUBMISSION) { + promises.push(this.gotoSubmissionsPage(this.page)); + } + + let assessPromise = Promise.resolve(); + + if (this.workshop!.phase >= AddonModWorkshopPhase.PHASE_ASSESSMENT) { + this.canAssess = AddonModWorkshopHelper.canAssess(this.workshop!, this.access!); + + if (this.canAssess) { + assessPromise = AddonModWorkshopHelper.getReviewerAssessments(this.workshop!.id, { + cmId: this.module.id, + }).then(async (assessments) => { + await Promise.all(assessments.map(async (assessment) => { + assessment.strategy = this.workshop!.strategy; + if (!this.hasOffline) { + return; + } + + try { + const offlineAssessment = await AddonModWorkshopOffline.getAssessment(this.workshop!.id, assessment.id); + + assessment.offline = true; + assessment.timemodified = Math.floor(offlineAssessment.timemodified / 1000); + } catch { + // Ignore errors. + } + })); + + this.assessments = assessments; + + return; + }); + + } + } + + if (this.workshop!.phase == AddonModWorkshopPhase.PHASE_CLOSED) { + promises.push(AddonModWorkshop.getGrades(this.workshop!.id, { cmId: this.module.id }).then((grades) => { + this.userGrades = grades.submissionlongstrgrade || grades.assessmentlongstrgrade ? grades : undefined; + + return; + })); + + if (this.access!.canviewpublishedsubmissions) { + promises.push(assessPromise.then(async () => { + const submissions: AddonModWorkshopSubmissionDataWithOfflineData[] = + await AddonModWorkshop.getSubmissions(this.workshop!.id, { cmId: this.module.id }); + + this.publishedSubmissions = submissions.filter((submission) => { + if (submission.published) { + submission.reviewedby = []; + + this.assessments.forEach((assessment) => { + if (assessment.submissionid == submission.id) { + submission.reviewedby!.push(AddonModWorkshopHelper.realGradeValue(this.workshop!, assessment)); + } + }); + + return true; + } + + return false; + }); + + return; + })); + } + } + + await Promise.all(promises); + } + + /** + * Performs the sync of the activity. + * + * @return Promise resolved when done. + */ + protected sync(): Promise { + return AddonModWorkshopSync.syncWorkshop(this.workshop!.id); + } + + /** + * Checks if sync has succeed from result sync data. + * + * @param result Data returned on the sync function. + * @return If suceed or not. + */ + protected hasSyncSucceed(result: AddonModWorkshopSyncResult): boolean { + return result.updated; + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + super.ngOnDestroy(); + this.obsSubmissionChanged?.off(); + this.obsAssessmentSaved?.off(); + this.appResumeSubscription?.unsubscribe(); + } + +} diff --git a/src/addons/mod/workshop/components/phase/phase.html b/src/addons/mod/workshop/components/phase/phase.html new file mode 100644 index 000000000..1f98c8b52 --- /dev/null +++ b/src/addons/mod/workshop/components/phase/phase.html @@ -0,0 +1,48 @@ + + + + + + {{ 'addon.mod_workshop.userplan' | translate }} + + + + + + + + + + + + +

{{ phase.title }}

+

+ {{ 'addon.mod_workshop.userplancurrentphase' | translate }} +

+
+
+ + + +

{{ 'addon.mod_workshop.switchphase' + phase.code | translate }}

+
+ +
+ + + + + + + +

{{task.title}}

+

+
+ +
+
+
+
diff --git a/src/addons/mod/workshop/components/phase/phase.ts b/src/addons/mod/workshop/components/phase/phase.ts new file mode 100644 index 000000000..1e4976d40 --- /dev/null +++ b/src/addons/mod/workshop/components/phase/phase.ts @@ -0,0 +1,73 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, OnInit } from '@angular/core'; +import { CoreUtils } from '@services/utils/utils'; +import { ModalController } from '@singletons'; +import { AddonModWorkshopPhaseData, AddonModWorkshopPhase, AddonModWorkshopPhaseTaskData } from '../../services/workshop'; + +/** + * Page that displays the phase info modal. + */ +@Component({ + templateUrl: 'phase.html', +}) +export class AddonModWorkshopPhaseInfoComponent implements OnInit { + + @Input() phases!: AddonModWorkshopPhaseDataWithSwitch[]; + @Input() workshopPhase!: AddonModWorkshopPhase; + @Input() showSubmit = false; + @Input() protected externalUrl!: string; + + ngOnInit(): void { + + // Treat phases. + for (const x in this.phases) { + this.phases[x].tasks.forEach((task) => { + if (!task.link && (task.code == 'examples' || task.code == 'prepareexamples')) { + // Add links to manage examples. + task.link = this.externalUrl; + } + }); + const action = this.phases[x].actions.find((action) => action.url && action.type == 'switchphase'); + this.phases[x].switchUrl = action ? action.url : ''; + } + } + + /** + * Close modal. + */ + closeModal(): void { + ModalController.dismiss(); + } + + /** + * Open task. + * + * @param task Task to be done. + */ + runTask(task: AddonModWorkshopPhaseTaskData): void { + if (task.code == 'submit') { + // This will close the modal and go to the submit. + ModalController.dismiss(true); + } else if (task.link) { + CoreUtils.openInBrowser(task.link); + } + } + +} + +type AddonModWorkshopPhaseDataWithSwitch = AddonModWorkshopPhaseData & { + switchUrl?: string; +}; diff --git a/src/addons/mod/workshop/components/submission/addon-mod-workshop-submission.html b/src/addons/mod/workshop/components/submission/addon-mod-workshop-submission.html new file mode 100644 index 000000000..fafe401f3 --- /dev/null +++ b/src/addons/mod/workshop/components/submission/addon-mod-workshop-submission.html @@ -0,0 +1,108 @@ + +
+ + + + +

+ + +

+

{{profile.fullname}}

+

+ {{ 'addon.mod_workshop.submissiongradeof' | translate:{$a: workshop.grade } }}: {{submission.grade}} +

+

+ {{ 'addon.mod_workshop.gradeover' | translate }}: {{submission.gradeover}} +

+

+ {{ 'addon.mod_workshop.gradinggradeof' | translate:{$a: workshop.gradinggrade } }}: {{submission.gradinggrade}} +

+
+ + {{ 'core.notsent' | translate }} + + + {{submission.timemodified | coreDateDayOrTime}} + + {{ 'core.notsent' | translate }} + + + {{ 'core.deletedoffline' | translate }} + + +
+ + + + + + + + + + + +

+ {{ 'addon.mod_workshop.feedbackby' | translate : {$a: evaluateByProfile.fullname} }} +

+ + +
+
+ + + + {{ 'core.showmore' | translate }} + + + + +
+ + + + + +

+ + +

+

{{profile.fullname}}

+

+ {{ 'addon.mod_workshop.receivedgrades' | translate }}: {{submission.reviewedbydone}} / {{submission.reviewedbycount}} +

+

+ {{ 'addon.mod_workshop.givengrades' | translate }}: {{submission.reviewerofdone}} / {{submission.reviewerofcount}} +

+

+ {{ 'addon.mod_workshop.submissiongradeof' | translate:{$a: workshop.grade } }}: {{submission.grade}} +

+

+ {{ 'addon.mod_workshop.submissiongradeof' | translate:{$a: workshop.grade } }}: {{submission.gradeover}} +

+

+ {{ 'addon.mod_workshop.gradinggradeof' | translate:{$a: workshop.gradinggrade } }}: {{submission.gradinggrade}} +

+ + + {{ 'addon.mod_workshop.assessedsubmission' | translate }} + + + {{ 'addon.mod_workshop.notassessed' | translate }} + + +
+ + {{submission.timemodified | coreDateDayOrTime}} +
{{ 'core.notsent' | translate }}
+
{{ 'core.deletedoffline' | translate }}
+
+
+
diff --git a/src/addons/mod/workshop/components/submission/submission.scss b/src/addons/mod/workshop/components/submission/submission.scss new file mode 100644 index 000000000..5490cd6b4 --- /dev/null +++ b/src/addons/mod/workshop/components/submission/submission.scss @@ -0,0 +1,10 @@ +:host { + p.addon-overriden-grade { + color: var(--ion-color-success); + } + + p.addon-has-overriden-grade { + color: var(--ion-color-danger); + text-decoration: line-through; + } +} diff --git a/src/addons/mod/workshop/components/submission/submission.ts b/src/addons/mod/workshop/components/submission/submission.ts new file mode 100644 index 000000000..ef3a0cf4d --- /dev/null +++ b/src/addons/mod/workshop/components/submission/submission.ts @@ -0,0 +1,138 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, OnInit } from '@angular/core'; +import { Params } from '@angular/router'; +import { CoreCourseModule } from '@features/course/services/course-helper'; +import { CoreUser, CoreUserProfile } from '@features/user/services/user'; +import { CoreNavigator } from '@services/navigator'; +import { CoreSites } from '@services/sites'; +import { AddonModWorkshopSubmissionPage } from '../../pages/submission/submission'; +import { AddonModWorkshopModuleHandlerService } from '../../services/handlers/module'; +import { + AddonModWorkshopProvider, + AddonModWorkshopPhase, + AddonModWorkshopData, + AddonModWorkshopGetWorkshopAccessInformationWSResponse, +} from '../../services/workshop'; +import { + AddonModWorkshopHelper, + AddonModWorkshopSubmissionAssessmentWithFormData, + AddonModWorkshopSubmissionDataWithOfflineData, +} from '../../services/workshop-helper'; +import { AddonModWorkshopOffline } from '../../services/workshop-offline'; + +/** + * Component that displays workshop submission. + */ +@Component({ + selector: 'addon-mod-workshop-submission', + templateUrl: 'addon-mod-workshop-submission.html', + styleUrls: ['submission.scss'], +}) +export class AddonModWorkshopSubmissionComponent implements OnInit { + + @Input() submission!: AddonModWorkshopSubmissionDataWithOfflineData; + @Input() module!: CoreCourseModule; + @Input() workshop!: AddonModWorkshopData; + @Input() access!: AddonModWorkshopGetWorkshopAccessInformationWSResponse; + @Input() courseId!: number; + @Input() assessment?: AddonModWorkshopSubmissionAssessmentWithFormData; + @Input() summary = false; + + component = AddonModWorkshopProvider.COMPONENT; + componentId?: number; + userId: number; + loaded = false; + offline = false; + viewDetails = false; + profile?: CoreUserProfile; + showGrade: (grade?: number|string) => boolean; + evaluateByProfile?: CoreUserProfile; + + constructor() { + this.userId = CoreSites.getCurrentSiteUserId(); + this.showGrade = AddonModWorkshopHelper.showGrade; + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.componentId = this.module.instance; + this.userId = this.submission.authorid || this.userId; + + const promises: Promise[] = []; + + this.offline = !!this.submission?.offline || !!this.assessment?.offline; + + if (this.submission.id) { + promises.push(AddonModWorkshopOffline.getEvaluateSubmission(this.workshop.id, this.submission.id) + .then((offlineSubmission) => { + this.submission.gradeover = parseInt(offlineSubmission.gradeover, 10); + this.offline = true; + + return; + }).catch(() => { + // Ignore errors. + })); + } + + if (this.userId) { + promises.push(CoreUser.getProfile(this.userId, this.courseId, true).then((profile) => { + this.profile = profile; + + return; + })); + } + + this.viewDetails = !this.summary && this.workshop.phase == AddonModWorkshopPhase.PHASE_CLOSED && + CoreNavigator.getCurrentRoute().component != AddonModWorkshopSubmissionPage; + + if (this.viewDetails && this.submission.gradeoverby) { + promises.push(CoreUser.getProfile(this.submission.gradeoverby, this.courseId, true).then((profile) => { + this.evaluateByProfile = profile; + + return; + })); + } + + Promise.all(promises).finally(() => { + this.loaded = true; + }); + } + + /** + * Navigate to the submission. + */ + gotoSubmission(): void { + if (this.submission.timemodified) { + const params: Params = { + module: this.module, + workshop: this.workshop, + access: this.access, + profile: this.profile, + submission: this.submission, + assessment: this.assessment, + }; + + CoreNavigator.navigateToSitePath( + AddonModWorkshopModuleHandlerService.PAGE_NAME + `/${this.courseId}/${this.module.id}/${this.submission.id}`, + { params }, + ); + + } + } + +} diff --git a/src/addons/mod/workshop/lang.json b/src/addons/mod/workshop/lang.json new file mode 100644 index 000000000..3b8f78e33 --- /dev/null +++ b/src/addons/mod/workshop/lang.json @@ -0,0 +1,64 @@ +{ + "alreadygraded": "Already graded", + "areainstructauthors": "Instructions for submission", + "areainstructreviewers": "Instructions for assessment", + "assess": "Assess", + "assessedsubmission": "Assessed submission", + "assessmentform": "Assessment form", + "assessmentsettings": "Assessment settings", + "assessmentstrategynotsupported": "Assessment strategy {{$a}} not supported", + "assessmentweight": "Assessment weight", + "assignedassessments": "Assigned submissions to assess", + "assignedassessmentsnone": "You have no assigned submission to assess", + "conclusion": "Conclusion", + "createsubmission": "Add submission", + "deletesubmission": "Delete submission", + "editsubmission": "Edit submission", + "feedbackauthor": "Feedback for the author", + "feedbackby": "Feedback by {{$a}}", + "feedbackreviewer": "Feedback for the reviewer", + "givengrades": "Grades given", + "gradecalculated": "Calculated grade for submission", + "gradeinfo": "Grade: {{$a.received}} of {{$a.max}}", + "gradeover": "Override grade for submission", + "gradesreport": "Workshop grades report", + "gradinggrade": "Grade for assessment", + "gradinggradecalculated": "Calculated grade for assessment", + "gradinggradeof": "Grade for assessment (of {{$a}})", + "gradinggradeover": "Override grade for assessment", + "modulenameplural": "Workshops", + "nogradeyet": "No grade yet", + "notassessed": "Not assessed yet", + "notoverridden": "Not overridden", + "noyoursubmission": "You have not submitted your work yet", + "overallfeedback": "Overall feedback", + "publishedsubmissions": "Published submissions", + "publishsubmission": "Publish submission", + "publishsubmission_help": "Published submissions are available to the others when the workshop is closed.", + "reassess": "Re-assess", + "receivedgrades": "Grades received", + "submissionattachment": "Attachment", + "submissioncontent": "Submission content", + "submissiondeleteconfirm": "Are you sure you want to delete the following submission?", + "submissiongrade": "Grade for submission", + "submissiongradeof": "Grade for submission (of {{$a}})", + "submissionrequiredcontent": "You need to enter some text or add a file.", + "submissionrequiredtitle": "You need to enter a title.", + "submissionsreport": "Workshop submissions report", + "submissiontitle": "Title", + "switchphase10": "Switch to the setup phase", + "switchphase20": "Switch to the submission phase", + "switchphase30": "Switch to the assessment phase", + "switchphase40": "Switch to the evaluation phase", + "switchphase50": "Close workshop", + "userplan": "Workshop planner", + "userplancurrentphase": "Current phase", + "warningassessmentmodified": "The submission was modified on the site.", + "warningsubmissionmodified": "The assessment was modified on the site.", + "weightinfo": "Weight: {{$a}}", + "yourassessment": "Your assessment", + "yourassessmentfor": "Your assessment for {{$a}}", + "yourgrades": "Your grades", + "yoursubmission": "Your submission", + "yoursubmissionwithassessments": "Your submission with assessments" +} diff --git a/src/addons/mod/workshop/pages/assessment/assessment.html b/src/addons/mod/workshop/pages/assessment/assessment.html new file mode 100644 index 000000000..3e6f44b5b --- /dev/null +++ b/src/addons/mod/workshop/pages/assessment/assessment.html @@ -0,0 +1,107 @@ + + + + + + + + + + + + {{ 'core.save' | translate }} + + + + + + + + + + + + + + + +

{{profile.fullname}}

+ +

+ {{ 'addon.mod_workshop.submissiongradeof' | translate:{$a: workshop.grade } }}: {{assessment.grade}} +

+

+ {{ 'addon.mod_workshop.gradinggradeof' | translate:{$a: workshop.gradinggrade } }}: {{assessment.gradinggrade}} +

+

+ {{ 'addon.mod_workshop.gradinggradeover' | translate }}: {{assessment.gradinggradeover}} +

+

+ {{ 'addon.mod_workshop.weightinfo' | translate:{$a: assessment.weight } }} +

+ + {{ 'addon.mod_workshop.notassessed' | translate }} + +
+
+ + + + +
+ +

{{ 'addon.mod_workshop.assessmentsettings' | translate }}

+
+ + + + {{ 'addon.mod_workshop.assessmentweight' | translate }} + + + + {{ w }} + + + + +

{{ 'addon.mod_workshop.gradinggradecalculated' | translate }}

+

{{ assessment.gradinggrade }}

+
+
+ + {{ 'addon.mod_workshop.gradinggradeover' | translate }} + + + {{grade.label}} + + + + + {{ 'addon.mod_workshop.feedbackreviewer' | translate }} + + + +
+ + + + +

+ {{ 'addon.mod_workshop.feedbackby' | translate : {$a: evaluateByProfile.fullname} }} +

+ + +
+
+
+
+
diff --git a/src/addons/mod/workshop/pages/assessment/assessment.ts b/src/addons/mod/workshop/pages/assessment/assessment.ts new file mode 100644 index 000000000..2085832e1 --- /dev/null +++ b/src/addons/mod/workshop/pages/assessment/assessment.ts @@ -0,0 +1,398 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, OnInit, OnDestroy, ViewChild, ElementRef } from '@angular/core'; +import { FormGroup, FormBuilder, Validators } from '@angular/forms'; +import { CoreCourse } from '@features/course/services/course'; +import { CoreGradesHelper, CoreGradesMenuItem } from '@features/grades/services/grades-helper'; +import { CoreUser, CoreUserProfile } from '@features/user/services/user'; +import { CanLeave } from '@guards/can-leave'; +import { IonRefresher } from '@ionic/angular'; +import { CoreNavigator } from '@services/navigator'; +import { CoreSites } from '@services/sites'; +import { CoreSync } from '@services/sync'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreTextUtils } from '@services/utils/text'; +import { Translate } from '@singletons'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { CoreForms } from '@singletons/form'; +import { + AddonModWorkshop, + AddonModWorkshopAssessmentSavedChangedEventData, + AddonModWorkshopData, + AddonModWorkshopGetWorkshopAccessInformationWSResponse, + AddonModWorkshopPhase, + AddonModWorkshopProvider, + AddonModWorkshopSubmissionData, +} from '../../services/workshop'; +import { AddonModWorkshopHelper, AddonModWorkshopSubmissionAssessmentWithFormData } from '../../services/workshop-helper'; +import { AddonModWorkshopOffline } from '../../services/workshop-offline'; +import { AddonModWorkshopSyncProvider } from '../../services/workshop-sync'; + +/** + * Page that displays a workshop assessment. + */ +@Component({ + selector: 'page-addon-mod-workshop-assessment-page', + templateUrl: 'assessment.html', +}) +export class AddonModWorkshopAssessmentPage implements OnInit, OnDestroy, CanLeave { + + @ViewChild('evaluateFormEl') formElement!: ElementRef; + + assessment!: AddonModWorkshopSubmissionAssessmentWithFormData; + submission!: AddonModWorkshopSubmissionData; + profile!: CoreUserProfile; + courseId!: number; + access?: AddonModWorkshopGetWorkshopAccessInformationWSResponse; + assessmentId!: number; + evaluating = false; + loaded = false; + showGrade: (grade?: string | number) => boolean; + evaluateForm: FormGroup; + maxGrade?: number; + workshop?: AddonModWorkshopData; + strategy?: string; + title = ''; + evaluate: AddonModWorkshopAssessmentEvaluation = { + text: '', + grade: -1, + weight: 1, + }; + + weights: number[] = []; + evaluateByProfile?: CoreUserProfile; + evaluationGrades: CoreGradesMenuItem[] =[]; + + protected workshopId!: number; + protected originalEvaluation: AddonModWorkshopAssessmentEvaluation = { + text: '', + grade: -1, + weight: 1, + }; + + protected hasOffline = false; + protected syncObserver: CoreEventObserver; + protected isDestroyed = false; + protected siteId: string; + protected currentUserId: number; + protected forceLeave = false; + + constructor( + protected fb: FormBuilder, + ) { + this.siteId = CoreSites.getCurrentSiteId(); + this.currentUserId = CoreSites.getCurrentSiteUserId(); + + this.showGrade = AddonModWorkshopHelper.showGrade; + + this.evaluateForm = new FormGroup({}); + this.evaluateForm.addControl('weight', this.fb.control('', Validators.required)); + this.evaluateForm.addControl('grade', this.fb.control('')); + this.evaluateForm.addControl('text', this.fb.control('')); + + // Refresh workshop on sync. + this.syncObserver = CoreEvents.on(AddonModWorkshopSyncProvider.AUTO_SYNCED, (data) => { + // Update just when all database is synced. + if (this.workshopId === data.workshopId) { + this.loaded = false; + this.refreshAllData(); + } + }, this.siteId); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.assessment = CoreNavigator.getRouteParam('assessment')!; + this.submission = CoreNavigator.getRouteParam('submission')!; + this.profile = CoreNavigator.getRouteParam('profile')!; + this.courseId = CoreNavigator.getRouteNumberParam('courseId')!; + + this.assessmentId = this.assessment.id; + this.workshopId = this.submission.workshopid; + + this.fetchAssessmentData(); + } + + /** + * Check if we can leave the page or not. + * + * @return Resolved if we can leave it, rejected if not. + */ + async canLeave(): Promise { + if (this.forceLeave || !this.evaluating) { + return true; + } + + if (!this.hasEvaluationChanged()) { + return true; + } + + // Show confirmation if some data has been modified. + await CoreDomUtils.showConfirm(Translate.instant('core.confirmcanceledit')); + + CoreForms.triggerFormCancelledEvent(this.formElement, this.siteId); + + return true; + } + + /** + * Fetch the assessment data. + * + * @return Resolved when done. + */ + protected async fetchAssessmentData(): Promise { + try { + this.workshop = await AddonModWorkshop.getWorkshopById(this.courseId, this.workshopId); + this.title = this.workshop.name; + this.strategy = this.workshop.strategy; + + const gradeInfo = await CoreCourse.getModuleBasicGradeInfo(this.workshop.coursemodule); + this.maxGrade = gradeInfo?.grade; + + this.access = await AddonModWorkshop.getWorkshopAccessInformation( + this.workshopId, + { cmId: this.workshop.coursemodule }, + ); + + // Load Weights selector. + if (this.assessmentId && (this.access.canallocate || this.access.canoverridegrades)) { + if (!this.isDestroyed) { + // Block the workshop. + CoreSync.blockOperation(AddonModWorkshopProvider.COMPONENT, this.workshopId); + } + + this.evaluating = true; + } else { + this.evaluating = false; + } + + if (!this.evaluating && this.workshop.phase != AddonModWorkshopPhase.PHASE_CLOSED) { + return; + } + + // Get all info of the assessment. + const assessment = await AddonModWorkshopHelper.getReviewerAssessmentById(this.workshopId, this.assessmentId, { + userId: this.profile && this.profile.id, + cmId: this.workshop.coursemodule, + }); + + this.assessment = AddonModWorkshopHelper.realGradeValue(this.workshop, assessment); + this.evaluate.text = this.assessment.feedbackreviewer || ''; + this.evaluate.weight = this.assessment.weight; + + if (this.evaluating) { + if (this.access.canallocate) { + this.weights = []; + for (let i = 16; i >= 0; i--) { + this.weights[i] = i; + } + } + + if (this.access.canoverridegrades) { + const defaultGrade = Translate.instant('addon.mod_workshop.notoverridden'); + this.evaluationGrades = + await CoreGradesHelper.makeGradesMenu(this.workshop.gradinggrade, undefined, defaultGrade, -1); + } + + try { + const offlineAssess = await AddonModWorkshopOffline.getEvaluateAssessment(this.workshopId, this.assessmentId); + this.hasOffline = true; + this.evaluate.weight = offlineAssess.weight; + if (this.access.canoverridegrades) { + this.evaluate.text = offlineAssess.feedbacktext || ''; + this.evaluate.grade = parseInt(offlineAssess.gradinggradeover, 10) || -1; + } + } catch { + this.hasOffline = false; + // No offline, load online. + if (this.access.canoverridegrades) { + this.evaluate.text = this.assessment.feedbackreviewer || ''; + this.evaluate.grade = parseInt(String(this.assessment.gradinggradeover), 10) || -1; + } + } finally { + this.originalEvaluation.weight = this.evaluate.weight; + if (this.access.canoverridegrades) { + this.originalEvaluation.text = this.evaluate.text; + this.originalEvaluation.grade = this.evaluate.grade; + } + + this.evaluateForm.controls['weight'].setValue(this.evaluate.weight); + if (this.access.canoverridegrades) { + this.evaluateForm.controls['grade'].setValue(this.evaluate.grade); + this.evaluateForm.controls['text'].setValue(this.evaluate.text); + } + } + + } else if (this.workshop.phase == AddonModWorkshopPhase.PHASE_CLOSED && this.assessment.gradinggradeoverby) { + this.evaluateByProfile = await CoreUser.getProfile(this.assessment.gradinggradeoverby, this.courseId, true); + } + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'mm.course.errorgetmodule', true); + } finally { + this.loaded = true; + } + } + + /** + * Force leaving the page, without checking for changes. + */ + protected forceLeavePage(): void { + this.forceLeave = true; + CoreNavigator.back(); + } + + /** + * Check if data has changed. + * + * @return True if changed, false otherwise. + */ + protected hasEvaluationChanged(): boolean { + if (!this.loaded || !this.evaluating) { + return false; + } + + const inputData = this.evaluateForm.value; + + if (this.originalEvaluation.weight != inputData.weight) { + return true; + } + + if (this.access && this.access.canoverridegrades) { + if (this.originalEvaluation.text != inputData.text) { + return true; + } + + if (this.originalEvaluation.grade != inputData.grade) { + return true; + } + } + + return false; + } + + /** + * Convenience function to refresh all the data. + * + * @return Resolved when done. + */ + protected async refreshAllData(): Promise { + const promises: Promise[] = []; + + promises.push(AddonModWorkshop.invalidateWorkshopData(this.courseId)); + promises.push(AddonModWorkshop.invalidateWorkshopAccessInformationData(this.workshopId)); + promises.push(AddonModWorkshop.invalidateReviewerAssesmentsData(this.workshopId)); + + if (this.assessmentId) { + promises.push(AddonModWorkshop.invalidateAssessmentFormData(this.workshopId, this.assessmentId)); + promises.push(AddonModWorkshop.invalidateAssessmentData(this.workshopId, this.assessmentId)); + } + + try { + await Promise.all(promises); + } finally { + CoreEvents.trigger(AddonModWorkshopProvider.ASSESSMENT_INVALIDATED, null, this.siteId); + + await this.fetchAssessmentData(); + } + } + + /** + * Pull to refresh. + * + * @param refresher Refresher. + */ + refreshAssessment(refresher: IonRefresher): void { + if (this.loaded) { + this.refreshAllData().finally(() => { + refresher?.complete(); + }); + } + } + + /** + * Save the assessment evaluation. + */ + async saveEvaluation(): Promise { + // Check if data has changed. + if (this.hasEvaluationChanged()) { + await this.sendEvaluation(); + } + + // Go back. + this.forceLeavePage(); + } + + /** + * Sends the evaluation to be saved on the server. + * + * @return Resolved when done. + */ + protected async sendEvaluation(): Promise { + const modal = await CoreDomUtils.showModalLoading('core.sending', true); + const inputData: AddonModWorkshopAssessmentEvaluation = this.evaluateForm.value; + + const grade = inputData.grade >= 0 ? String(inputData.grade) : ''; + // Add some HTML to the message if needed. + const text = CoreTextUtils.formatHtmlLines(inputData.text); + + try { + // Try to send it to server. + const result = await AddonModWorkshop.evaluateAssessment( + this.workshopId, + this.assessmentId, + this.courseId, + text, + inputData.weight, + grade, + ); + + CoreForms.triggerFormSubmittedEvent(this.formElement, !!result, this.siteId); + + const data: AddonModWorkshopAssessmentSavedChangedEventData = { + workshopId: this.workshopId, + assessmentId: this.assessmentId, + userId: this.currentUserId, + }; + + return AddonModWorkshop.invalidateAssessmentData(this.workshopId, this.assessmentId).finally(() => { + CoreEvents.trigger(AddonModWorkshopProvider.ASSESSMENT_SAVED, data, this.siteId); + }); + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'Cannot save assessment evaluation'); + } finally { + modal.dismiss(); + } + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + this.isDestroyed = true; + + this.syncObserver?.off(); + // Restore original back functions. + CoreSync.unblockOperation(AddonModWorkshopProvider.COMPONENT, this.workshopId); + } + +} + +type AddonModWorkshopAssessmentEvaluation = { + text: string; + grade: number; + weight: number; +}; diff --git a/src/addons/mod/workshop/pages/edit-submission/edit-submission.html b/src/addons/mod/workshop/pages/edit-submission/edit-submission.html new file mode 100644 index 000000000..b1a771b6c --- /dev/null +++ b/src/addons/mod/workshop/pages/edit-submission/edit-submission.html @@ -0,0 +1,46 @@ + + + + + + {{ 'addon.mod_workshop.editsubmission' | translate }} + + + {{ 'core.save' | translate }} + + + + + + +
+ + + + {{ 'addon.mod_workshop.submissiontitle' | translate }} + + + + + + + + + + {{ 'addon.mod_workshop.submissioncontent' | translate }} + + + + + + + +
+
+
diff --git a/src/addons/mod/workshop/pages/edit-submission/edit-submission.ts b/src/addons/mod/workshop/pages/edit-submission/edit-submission.ts new file mode 100644 index 000000000..ffa567c29 --- /dev/null +++ b/src/addons/mod/workshop/pages/edit-submission/edit-submission.ts @@ -0,0 +1,476 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, OnInit, OnDestroy, ViewChild, ElementRef } from '@angular/core'; +import { FormGroup, FormBuilder, Validators } from '@angular/forms'; +import { CoreError } from '@classes/errors/error'; +import { CoreCourseModule } from '@features/course/services/course-helper'; +import { CoreFileUploader, CoreFileUploaderStoreFilesResult } from '@features/fileuploader/services/fileuploader'; +import { CanLeave } from '@guards/can-leave'; +import { CoreFile } from '@services/file'; +import { CoreFileEntry, CoreFileHelper } from '@services/file-helper'; +import { CoreFileSession } from '@services/file-session'; +import { CoreNavigator } from '@services/navigator'; +import { CoreSites } from '@services/sites'; +import { CoreSync } from '@services/sync'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreTextUtils } from '@services/utils/text'; +import { Translate } from '@singletons'; +import { CoreEvents } from '@singletons/events'; +import { CoreForms } from '@singletons/form'; +import { + AddonModWorkshopProvider, + AddonModWorkshop, + AddonModWorkshopSubmissionType, + AddonModWorkshopSubmissionChangedEventData, + AddonModWorkshopAction, + AddonModWorkshopGetWorkshopAccessInformationWSResponse, + AddonModWorkshopData, +} from '../../services/workshop'; +import { AddonModWorkshopHelper, AddonModWorkshopSubmissionDataWithOfflineData } from '../../services/workshop-helper'; +import { AddonModWorkshopOffline } from '../../services/workshop-offline'; + +/** + * Page that displays the workshop edit submission. + */ +@Component({ + selector: 'page-addon-mod-workshop-edit-submission', + templateUrl: 'edit-submission.html', +}) +export class AddonModWorkshopEditSubmissionPage implements OnInit, OnDestroy, CanLeave { + + @ViewChild('editFormEl') formElement!: ElementRef; + + module!: CoreCourseModule; + courseId!: number; + access!: AddonModWorkshopGetWorkshopAccessInformationWSResponse; + submission?: AddonModWorkshopSubmissionDataWithOfflineData; + + loaded = false; + component = AddonModWorkshopProvider.COMPONENT; + componentId!: number; + editForm: FormGroup; // The form group. + editorExtraParams: Record = {}; // Extra params to identify the draft. + workshop?: AddonModWorkshopData; + textAvailable = false; + textRequired = false; + fileAvailable = false; + fileRequired = false; + + protected workshopId!: number; + protected submissionId = 0; + protected userId: number; + protected originalData: AddonModWorkshopEditSubmissionInputData = { + title: '', + content: '', + attachmentfiles: [], + }; + + protected hasOffline = false; + protected editing = false; + protected forceLeave = false; + protected siteId: string; + protected isDestroyed = false; + + constructor( + protected fb: FormBuilder, + ) { + + this.userId = CoreSites.getCurrentSiteUserId(); + this.siteId = CoreSites.getCurrentSiteId(); + + this.editForm = new FormGroup({}); + this.editForm.addControl('title', this.fb.control('', Validators.required)); + this.editForm.addControl('content', this.fb.control('')); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.module = CoreNavigator.getRouteParam('module')!; + this.courseId = CoreNavigator.getRouteNumberParam('courseId')!; + this.access = CoreNavigator.getRouteParam('access')!; + this.submissionId = CoreNavigator.getRouteNumberParam('submissionId') || 0; + + if (this.submissionId > 0) { + this.editorExtraParams.id = this.submissionId; + } + + this.workshopId = this.module.instance!; + this.componentId = this.module.id; + + if (!this.isDestroyed) { + // Block the workshop. + CoreSync.blockOperation(this.component, this.workshopId); + } + + this.fetchSubmissionData(); + } + + /** + * Check if we can leave the page or not. + * + * @return Resolved if we can leave it, rejected if not. + */ + async canLeave(): Promise { + if (this.forceLeave) { + return true; + } + + // Check if data has changed. + if (this.hasDataChanged()) { + // Show confirmation if some data has been modified. + await CoreDomUtils.showConfirm(Translate.instant('core.confirmcanceledit')); + } + + if (this.submission?.attachmentfiles) { + // Delete the local files from the tmp folder. + CoreFileUploader.clearTmpFiles(this.submission.attachmentfiles); + } + + CoreForms.triggerFormCancelledEvent(this.formElement, this.siteId); + + return true; + } + + /** + * Fetch the submission data. + * + * @return Resolved when done. + */ + protected async fetchSubmissionData(): Promise { + try { + this.workshop = await AddonModWorkshop.getWorkshop(this.courseId, this.module.id); + this.textAvailable = (this.workshop.submissiontypetext != AddonModWorkshopSubmissionType.SUBMISSION_TYPE_DISABLED); + this.textRequired = (this.workshop.submissiontypetext == AddonModWorkshopSubmissionType.SUBMISSION_TYPE_REQUIRED); + this.fileAvailable = (this.workshop.submissiontypefile != AddonModWorkshopSubmissionType.SUBMISSION_TYPE_DISABLED); + this.fileRequired = (this.workshop.submissiontypefile == AddonModWorkshopSubmissionType.SUBMISSION_TYPE_REQUIRED); + + this.editForm.controls.content.setValidators(this.textRequired ? Validators.required : null); + + if (this.submissionId > 0) { + this.editing = true; + + this.submission = + await AddonModWorkshopHelper.getSubmissionById(this.workshopId, this.submissionId, { cmId: this.module.id }); + + const canEdit = this.userId == this.submission.authorid && + this.access.cansubmit && + this.access.modifyingsubmissionallowed; + + if (!canEdit) { + // Should not happen, but go back if does. + this.forceLeavePage(); + + return; + } + } else if (!this.access.cansubmit || !this.access.creatingsubmissionallowed) { + // Should not happen, but go back if does. + this.forceLeavePage(); + + return; + } + + const submissionsActions = await AddonModWorkshopOffline.getSubmissions(this.workshopId); + if (submissionsActions && submissionsActions.length) { + this.hasOffline = true; + this.submission = await AddonModWorkshopHelper.applyOfflineData(this.submission, submissionsActions); + } else { + this.hasOffline = false; + } + + if (this.submission) { + this.originalData.title = this.submission.title || ''; + this.originalData.content = this.submission.content || ''; + this.originalData.attachmentfiles = []; + + (this.submission.attachmentfiles || []).forEach((file) => { + let filename = CoreFile.getFileName(file); + if (!filename) { + // We don't have filename, extract it from the path. + filename = CoreFileHelper.getFilenameFromPath(file) || ''; + } + + this.originalData.attachmentfiles.push({ + filename, + fileurl: 'fileurl' in file ? file.fileurl : '', + }); + }); + + this.editForm.controls['title'].setValue(this.submission.title); + this.editForm.controls['content'].setValue(this.submission.content); + } + + CoreFileSession.setFiles( + this.component, + this.getFilesComponentId(), + this.submission?.attachmentfiles || [], + ); + + this.loaded = true; + } catch (error) { + this.loaded = false; + + CoreDomUtils.showErrorModalDefault(error, 'core.course.errorgetmodule', true); + + this.forceLeavePage(); + } + } + + /** + * Force leaving the page, without checking for changes. + */ + protected forceLeavePage(): void { + this.forceLeave = true; + CoreNavigator.back(); + } + + /** + * Get the form input data. + * + * @return Object with all the info. + */ + protected getInputData(): AddonModWorkshopEditSubmissionInputData { + const values: AddonModWorkshopEditSubmissionInputData = { + title: this.editForm.value.title, + content: '', + attachmentfiles: [], + }; + + if (this.textAvailable) { + values.content = this.editForm.value.content || ''; + } + + if (this.fileAvailable) { + values.attachmentfiles = CoreFileSession.getFiles(this.component, this.getFilesComponentId()) || []; + } + + return values; + } + + /** + * Check if data has changed. + * + * @return True if changed or false if not. + */ + protected hasDataChanged(): boolean { + if (!this.loaded) { + return false; + } + + const inputData = this.getInputData(); + if (this.originalData.title != inputData.title || this.textAvailable && this.originalData.content != inputData.content) { + return true; + } + + if (this.fileAvailable) { + return CoreFileUploader.areFileListDifferent(inputData.attachmentfiles, this.originalData.attachmentfiles); + } + + return false; + } + + /** + * Save the submission. + */ + async save(): Promise { + // Check if data has changed. + if (this.hasDataChanged()) { + try { + await this.saveSubmission(); + // Go back to entry list. + this.forceLeavePage(); + } catch{ + // Nothing to do. + } + } else { + // Nothing to save, just go back. + this.forceLeavePage(); + } + } + + /** + * Send submission and save. + * + * @return Resolved when done. + */ + protected async saveSubmission(): Promise { + const inputData = this.getInputData(); + + if (!inputData.title) { + CoreDomUtils.showAlertTranslated('core.notice', 'addon.mod_workshop.submissionrequiredtitle'); + + throw new CoreError(Translate.instant('addon.mod_workshop.submissionrequiredtitle')); + } + + const noText = CoreTextUtils.htmlIsBlank(inputData.content); + const noFiles = !inputData.attachmentfiles.length; + + if ((this.textRequired && noText) || (this.fileRequired && noFiles) || (noText && noFiles)) { + CoreDomUtils.showAlertTranslated('core.notice', 'addon.mod_workshop.submissionrequiredcontent'); + + throw new CoreError(Translate.instant('addon.mod_workshop.submissionrequiredcontent')); + } + + let saveOffline = false; + + const modal = await CoreDomUtils.showModalLoading('core.sending', true); + const submissionId = this.submission?.id; + + // Add some HTML to the message if needed. + if (this.textAvailable) { + inputData.content = CoreTextUtils.formatHtmlLines(inputData.content); + } + + // Upload attachments first if any. + let allowOffline = !inputData.attachmentfiles.length; + try { + let attachmentsId: CoreFileUploaderStoreFilesResult | number | undefined; + try { + attachmentsId = await AddonModWorkshopHelper.uploadOrStoreSubmissionFiles( + this.workshopId, + inputData.attachmentfiles, + false, + ); + } catch { + // Cannot upload them in online, save them in offline. + saveOffline = true; + allowOffline = true; + + attachmentsId = await AddonModWorkshopHelper.uploadOrStoreSubmissionFiles( + this.workshopId, + inputData.attachmentfiles, + true, + ); + } + + if (!saveOffline && !this.fileAvailable) { + attachmentsId = undefined; + } + + let newSubmissionId: number | false; + if (this.editing) { + if (saveOffline) { + // Save submission in offline. + await AddonModWorkshopOffline.saveSubmission( + this.workshopId, + this.courseId, + inputData.title, + inputData.content, + attachmentsId as CoreFileUploaderStoreFilesResult, + submissionId, + AddonModWorkshopAction.UPDATE, + ); + newSubmissionId = false; + } else { + // Try to send it to server. + // Don't allow offline if there are attachments since they were uploaded fine. + newSubmissionId = await AddonModWorkshop.updateSubmission( + this.workshopId, + submissionId!, + this.courseId, + inputData.title, + inputData.content, + attachmentsId, + undefined, + allowOffline, + ); + } + } else { + if (saveOffline) { + // Save submission in offline. + await AddonModWorkshopOffline.saveSubmission( + this.workshopId, + this.courseId, + inputData.title, + inputData.content, + attachmentsId as CoreFileUploaderStoreFilesResult, + undefined, + AddonModWorkshopAction.ADD, + ); + newSubmissionId = false; + } else { + // Try to send it to server. + // Don't allow offline if there are attachments since they were uploaded fine. + newSubmissionId = await AddonModWorkshop.addSubmission( + this.workshopId, + this.courseId, + inputData.title, + inputData.content, + attachmentsId, + undefined, + allowOffline, + ); + } + } + + CoreForms.triggerFormSubmittedEvent(this.formElement, !!newSubmissionId, this.siteId); + + const data: AddonModWorkshopSubmissionChangedEventData = { + workshopId: this.workshopId, + }; + + if (newSubmissionId) { + // Data sent to server, delete stored files (if any). + AddonModWorkshopOffline.deleteSubmissionAction( + this.workshopId, + this.editing ? AddonModWorkshopAction.UPDATE : AddonModWorkshopAction.ADD, + ); + + AddonModWorkshopHelper.deleteSubmissionStoredFiles(this.workshopId, this.siteId); + data.submissionId = newSubmissionId; + } + + CoreEvents.trigger(CoreEvents.ACTIVITY_DATA_SENT, { module: 'workshop' }); + + const promise = newSubmissionId ? AddonModWorkshop.invalidateSubmissionData(this.workshopId, newSubmissionId) : + Promise.resolve(); + + await promise.finally(() => { + CoreEvents.trigger(AddonModWorkshopProvider.SUBMISSION_CHANGED, data, this.siteId); + + // Delete the local files from the tmp folder. + CoreFileUploader.clearTmpFiles(inputData.attachmentfiles); + }); + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'Cannot save submission'); + } finally { + modal.dismiss(); + } + } + + protected getFilesComponentId(): string { + const id = this.submissionId > 0 + ? this.submissionId + : 'newsub'; + + return this.workshopId + '_' + id; + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + this.isDestroyed = true; + CoreSync.unblockOperation(this.component, this.workshopId); + } + +} + +type AddonModWorkshopEditSubmissionInputData = { + title: string; + content: string; + attachmentfiles: CoreFileEntry[]; +}; diff --git a/src/addons/mod/workshop/pages/index/index.html b/src/addons/mod/workshop/pages/index/index.html new file mode 100644 index 000000000..f9cefab96 --- /dev/null +++ b/src/addons/mod/workshop/pages/index/index.html @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/addons/mod/workshop/pages/index/index.ts b/src/addons/mod/workshop/pages/index/index.ts new file mode 100644 index 000000000..cdef213cc --- /dev/null +++ b/src/addons/mod/workshop/pages/index/index.ts @@ -0,0 +1,41 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, OnInit, ViewChild } from '@angular/core'; +import { CoreCourseModuleMainActivityPage } from '@features/course/classes/main-activity-page'; +import { CoreNavigator } from '@services/navigator'; +import { AddonModWorkshopIndexComponent } from '../../components/index/index'; + +/** + * Page that displays a workshop. + */ +@Component({ + selector: 'page-addon-mod-workshop-index', + templateUrl: 'index.html', +}) +export class AddonModWorkshopIndexPage extends CoreCourseModuleMainActivityPage implements OnInit { + + @ViewChild(AddonModWorkshopIndexComponent) activityComponent?: AddonModWorkshopIndexComponent; + + selectedGroup = 0; + + /** + * @inheritdoc + */ + ngOnInit(): void { + super.ngOnInit(); + this.selectedGroup = CoreNavigator.getRouteNumberParam('group') || 0; + } + +} diff --git a/src/addons/mod/workshop/pages/submission/submission.html b/src/addons/mod/workshop/pages/submission/submission.html new file mode 100644 index 000000000..557fb0dc7 --- /dev/null +++ b/src/addons/mod/workshop/pages/submission/submission.html @@ -0,0 +1,154 @@ + + + + + + + + + + + + {{ 'core.save' | translate }} + + + {{ 'core.save' | translate }} + + + + + + + + + + + + + + + + + {{ 'addon.mod_workshop.editsubmission' | translate }} + + + + {{ 'addon.mod_workshop.deletesubmission' | translate }} + + + + {{ 'core.restore' | translate }} + + + + + + + + + +

+ {{ 'addon.mod_workshop.feedbackby' | translate : {$a: evaluateByProfile.fullname} }} +

+ + +
+
+
+ + + + +

{{ 'addon.mod_workshop.yourassessment' | translate }}

+
+
+ + +
+ + + + +

{{ 'addon.mod_workshop.receivedgrades' | translate }}

+
+
+ + + + +
+ + + + +

{{ 'addon.mod_workshop.givengrades' | translate }}

+
+
+ + +
+ +
+ + +

{{ 'addon.mod_workshop.feedbackauthor' | translate }}

+
+
+ + {{ 'addon.mod_workshop.publishsubmission' | translate }} + +

{{ 'addon.mod_workshop.publishsubmission_help' | translate }}

+
+ + + +

{{ 'addon.mod_workshop.gradecalculated' | translate }}

+

{{ submission.grade }}

+
+
+ + {{ 'addon.mod_workshop.gradeover' | translate }} + + + {{grade.label}} + + + + + {{ 'addon.mod_workshop.feedbackauthor' | translate }} + + + +
+ + + + + + + + +

+ {{ 'addon.mod_workshop.feedbackby' | translate : {$a: evaluateGradingByProfile.fullname} }} +

+ + +
+
+
+
+
diff --git a/src/addons/mod/workshop/pages/submission/submission.ts b/src/addons/mod/workshop/pages/submission/submission.ts new file mode 100644 index 000000000..b415c04f5 --- /dev/null +++ b/src/addons/mod/workshop/pages/submission/submission.ts @@ -0,0 +1,610 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, OnInit, OnDestroy, Optional, ViewChild, ElementRef } from '@angular/core'; +import { FormGroup, FormBuilder } from '@angular/forms'; +import { Params } from '@angular/router'; +import { CoreCourse } from '@features/course/services/course'; +import { CoreCourseModule } from '@features/course/services/course-helper'; +import { CoreGradesHelper, CoreGradesMenuItem } from '@features/grades/services/grades-helper'; +import { CoreUser, CoreUserProfile } from '@features/user/services/user'; +import { CanLeave } from '@guards/can-leave'; +import { IonContent, IonRefresher } from '@ionic/angular'; +import { CoreNavigator } from '@services/navigator'; +import { CoreSites } from '@services/sites'; +import { CoreSync } from '@services/sync'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreTextUtils } from '@services/utils/text'; +import { Translate } from '@singletons'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { CoreForms } from '@singletons/form'; +import { AddonModWorkshopAssessmentStrategyComponent } from '../../components/assessment-strategy/assessment-strategy'; +import { + AddonModWorkshopProvider, + AddonModWorkshop, + AddonModWorkshopPhase, + AddonModWorkshopSubmissionChangedEventData, + AddonModWorkshopAction, + AddonModWorkshopData, + AddonModWorkshopGetWorkshopAccessInformationWSResponse, + AddonModWorkshopAssessmentSavedChangedEventData, +} from '../../services/workshop'; +import { + AddonModWorkshopHelper, + AddonModWorkshopSubmissionAssessmentWithFormData, + AddonModWorkshopSubmissionDataWithOfflineData, +} from '../../services/workshop-helper'; +import { AddonModWorkshopOffline } from '../../services/workshop-offline'; +import { AddonModWorkshopSyncProvider, AddonModWorkshopAutoSyncData } from '../../services/workshop-sync'; + +/** + * Page that displays a workshop submission. + */ +@Component({ + selector: 'page-addon-mod-workshop-submission-page', + templateUrl: 'submission.html', +}) +export class AddonModWorkshopSubmissionPage implements OnInit, OnDestroy, CanLeave { + + @ViewChild(AddonModWorkshopAssessmentStrategyComponent) assessmentStrategy?: AddonModWorkshopAssessmentStrategyComponent; + + @ViewChild('feedbackFormEl') formElement?: ElementRef; + + module!: CoreCourseModule; + workshop!: AddonModWorkshopData; + access!: AddonModWorkshopGetWorkshopAccessInformationWSResponse; + assessment?: AddonModWorkshopSubmissionAssessmentWithFormData; + submissionInfo!: AddonModWorkshopSubmissionDataWithOfflineData; + profile?: CoreUserProfile; + courseId!: number; + + submission?: AddonModWorkshopSubmissionDataWithOfflineData; + title?: string; + loaded = false; + ownAssessment?: AddonModWorkshopSubmissionAssessmentWithFormData; + strategy?: string; + assessmentId?: number; + assessmentUserId?: number; + evaluate?: AddonWorkshopSubmissionEvaluateData; + canAddFeedback = false; + canEdit = false; + canDelete = false; + evaluationGrades: CoreGradesMenuItem[] = []; + evaluateGradingByProfile?: CoreUserProfile; + evaluateByProfile?: CoreUserProfile; + feedbackForm: FormGroup; // The form group. + submissionId!: number; + + protected workshopId!: number; + protected currentUserId: number; + protected userId?: number; + protected siteId: string; + protected originalEvaluation: Omit & { grade: number | string} = { + published: false, + text: '', + grade: '', + }; + + protected hasOffline = false; + protected component = AddonModWorkshopProvider.COMPONENT; + protected forceLeave = false; + protected obsAssessmentSaved: CoreEventObserver; + protected syncObserver: CoreEventObserver; + protected isDestroyed = false; + + constructor( + protected fb: FormBuilder, + @Optional() protected content: IonContent, + ) { + this.currentUserId = CoreSites.getCurrentSiteUserId(); + this.siteId = CoreSites.getCurrentSiteId(); + + this.feedbackForm = new FormGroup({}); + this.feedbackForm.addControl('published', this.fb.control('')); + this.feedbackForm.addControl('grade', this.fb.control('')); + this.feedbackForm.addControl('text', this.fb.control('')); + + this.obsAssessmentSaved = CoreEvents.on(AddonModWorkshopProvider.ASSESSMENT_SAVED, (data) => { + this.eventReceived(data); + }, this.siteId); + + // Refresh workshop on sync. + this.syncObserver = CoreEvents.on(AddonModWorkshopSyncProvider.AUTO_SYNCED, (data) => { + // Update just when all database is synced. + this.eventReceived(data); + }, this.siteId); + } + + /** + * Component being initialized. + */ + async ngOnInit(): Promise { + + this.submissionId = CoreNavigator.getRouteNumberParam('submissionId')!; + this.module = CoreNavigator.getRouteParam('module')!; + this.workshop = CoreNavigator.getRouteParam('workshop')!; + this.access = CoreNavigator.getRouteParam('access')!; + this.courseId = CoreNavigator.getRouteNumberParam('courseId')!; + this.profile = CoreNavigator.getRouteParam('profile'); + this.submissionInfo = CoreNavigator.getRouteParam('submission')!; + this.assessment = CoreNavigator.getRouteParam('assessment'); + + this.title = this.module.name; + this.workshopId = this.module.instance || this.workshop.id; + + this.userId = this.submissionInfo?.authorid; + this.strategy = (this.assessment && this.assessment.strategy) || (this.workshop && this.workshop.strategy); + this.assessmentId = this.assessment?.id; + this.assessmentUserId = this.assessment?.reviewerid; + + await this.fetchSubmissionData(); + + try { + await AddonModWorkshop.logViewSubmission(this.submissionId, this.workshopId, this.workshop.name); + CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); + } catch { + // Ignore errors. + } + } + + /** + * Check if we can leave the page or not. + * + * @return Resolved if we can leave it, rejected if not. + */ + async canLeave(): Promise { + const assessmentHasChanged = this.assessmentStrategy?.hasDataChanged(); + if (this.forceLeave || (!this.hasEvaluationChanged() && !assessmentHasChanged)) { + return true; + } + + // Show confirmation if some data has been modified. + await CoreDomUtils.showConfirm(Translate.instant('core.confirmcanceledit')); + + CoreForms.triggerFormCancelledEvent(this.formElement, this.siteId); + + return true; + } + + /** + * Goto edit submission page. + */ + editSubmission(): void { + const params: Params = { + module: module, + access: this.access, + }; + + CoreNavigator.navigate(String(this.submissionId) + '/edit', params); + } + + /** + * Function called when we receive an event of submission changes. + * + * @param data Event data received. + */ + protected eventReceived(data: AddonModWorkshopAutoSyncData | + AddonModWorkshopAssessmentSavedChangedEventData): void { + if (this.workshopId === data.workshopId) { + this.content?.scrollToTop(); + + this.loaded = false; + this.refreshAllData(); + } + } + + /** + * Fetch the submission data. + * + * @return Resolved when done. + */ + protected async fetchSubmissionData(): Promise { + try { + this.submission = await AddonModWorkshopHelper.getSubmissionById(this.workshopId, this.submissionId, { + cmId: this.module.id, + }); + + const promises: Promise[] = []; + + this.submission.grade = this.submissionInfo?.grade; + this.submission.gradinggrade = this.submissionInfo?.gradinggrade; + this.submission.gradeover = this.submissionInfo?.gradeover; + this.userId = this.submission.authorid || this.userId; + this.canEdit = this.currentUserId == this.userId && this.access.cansubmit && this.access.modifyingsubmissionallowed; + this.canDelete = this.access.candeletesubmissions; + + this.canAddFeedback = !this.assessmentId && this.workshop.phase > AddonModWorkshopPhase.PHASE_ASSESSMENT && + this.workshop.phase < AddonModWorkshopPhase.PHASE_CLOSED && this.access.canoverridegrades; + this.ownAssessment = undefined; + + if (this.access.canviewallassessments) { + // Get new data, different that came from stateParams. + promises.push(AddonModWorkshop.getSubmissionAssessments(this.workshopId, this.submissionId, { + cmId: this.module.id, + }).then((subAssessments) => { + // Only allow the student to delete their own submission if it's still editable and hasn't been assessed. + if (this.canDelete) { + this.canDelete = !subAssessments.length; + } + + this.submissionInfo.reviewedby = subAssessments; + + this.submissionInfo.reviewedby.forEach((assessment) => { + assessment = AddonModWorkshopHelper.realGradeValue(this.workshop, assessment); + + if (this.currentUserId == assessment.reviewerid) { + this.ownAssessment = assessment; + assessment.ownAssessment = true; + } + }); + + return; + })); + } else if (this.currentUserId == this.userId && this.assessmentId) { + // Get new data, different that came from stateParams. + promises.push(AddonModWorkshop.getAssessment(this.workshopId, this.assessmentId, { + cmId: this.module.id, + }).then((assessment) => { + // Only allow the student to delete their own submission if it's still editable and hasn't been assessed. + if (this.canDelete) { + this.canDelete = !assessment; + } + + this.submissionInfo.reviewedby = [this.parseAssessment(assessment)]; + + return; + })); + } else if (this.workshop.phase == AddonModWorkshopPhase.PHASE_CLOSED && this.userId == this.currentUserId) { + const assessments = await AddonModWorkshop.getSubmissionAssessments(this.workshopId, this.submissionId, { + cmId: this.module.id, + }); + + this.submissionInfo.reviewedby = assessments.map((assessment) => this.parseAssessment(assessment)); + } + + if (this.canAddFeedback || this.workshop.phase == AddonModWorkshopPhase.PHASE_CLOSED) { + this.evaluate = { + published: this.submission.published, + text: this.submission.feedbackauthor || '', + }; + } + + if (this.canAddFeedback) { + + if (!this.isDestroyed) { + // Block the workshop. + CoreSync.blockOperation(this.component, this.workshopId); + } + + const defaultGrade = Translate.instant('addon.mod_workshop.notoverridden'); + + promises.push(CoreGradesHelper.makeGradesMenu(this.workshop.grade || 0, undefined, defaultGrade, -1) + .then(async (grades) => { + this.evaluationGrades = grades; + + this.evaluate!.grade = { + label: CoreGradesHelper.getGradeLabelFromValue(grades, this.submissionInfo.gradeover) || + defaultGrade, + value: this.submissionInfo.gradeover || -1, + }; + + try { + const offlineSubmission = + await AddonModWorkshopOffline.getEvaluateSubmission(this.workshopId, this.submissionId); + + this.hasOffline = true; + this.evaluate!.published = offlineSubmission.published; + this.evaluate!.text = offlineSubmission.feedbacktext; + this.evaluate!.grade = { + label: CoreGradesHelper.getGradeLabelFromValue( + grades, + parseInt(offlineSubmission.gradeover, 10), + ) || defaultGrade, + value: offlineSubmission.gradeover || -1, + }; + } catch { + // Ignore errors. + this.hasOffline = false; + } finally { + this.originalEvaluation.published = this.evaluate!.published; + this.originalEvaluation.text = this.evaluate!.text; + this.originalEvaluation.grade = this.evaluate!.grade.value; + + this.feedbackForm.controls['published'].setValue(this.evaluate!.published); + this.feedbackForm.controls['grade'].setValue(this.evaluate!.grade.value); + this.feedbackForm.controls['text'].setValue(this.evaluate!.text); + } + + return; + })); + } else if (this.workshop.phase == AddonModWorkshopPhase.PHASE_CLOSED && this.submission.gradeoverby && + this.evaluate && this.evaluate.text) { + promises.push(CoreUser.getProfile(this.submission.gradeoverby, this.courseId, true).then((profile) => { + this.evaluateByProfile = profile; + + return; + })); + } + + if (this.assessment && !this.access.assessingallowed && this.assessment.feedbackreviewer && + this.assessment.gradinggradeoverby) { + promises.push(CoreUser.getProfile(this.assessment.gradinggradeoverby, this.courseId, true) + .then((profile) => { + this.evaluateGradingByProfile = profile; + + return; + })); + } + + await Promise.all(promises); + + const submissionsActions = await AddonModWorkshopOffline.getSubmissions(this.workshopId); + + this.submission = await AddonModWorkshopHelper.applyOfflineData(this.submission, submissionsActions); + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'core.course.errorgetmodule', true); + } finally { + this.loaded = true; + } + } + + /** + * Parse assessment to be shown. + * + * @param assessment Original assessment. + * @return Parsed assessment. + */ + protected parseAssessment( + assessment: AddonModWorkshopSubmissionAssessmentWithFormData, + ): AddonModWorkshopSubmissionAssessmentWithFormData { + assessment = AddonModWorkshopHelper.realGradeValue(this.workshop, assessment); + + if (this.currentUserId == assessment.reviewerid) { + this.ownAssessment = assessment; + assessment.ownAssessment = true; + } + + return assessment; + } + + /** + * Force leaving the page, without checking for changes. + */ + protected forceLeavePage(): void { + this.forceLeave = true; + CoreNavigator.back(); + } + + /** + * Check if data has changed. + * + * @return True if changed, false otherwise. + */ + protected hasEvaluationChanged(): boolean { + if (!this.loaded || !this.access.canoverridegrades) { + return false; + } + + const inputData = this.feedbackForm.value; + + if (this.originalEvaluation.published != inputData.published) { + return true; + } + + if (this.originalEvaluation.text != inputData.text) { + return true; + } + + if (this.originalEvaluation.grade != inputData.grade) { + return true; + } + + return false; + } + + /** + * Convenience function to refresh all the data. + * + * @return Resolved when done. + */ + protected async refreshAllData(): Promise { + const promises: Promise[] = []; + + promises.push(AddonModWorkshop.invalidateSubmissionData(this.workshopId, this.submissionId)); + promises.push(AddonModWorkshop.invalidateSubmissionsData(this.workshopId)); + promises.push(AddonModWorkshop.invalidateSubmissionAssesmentsData(this.workshopId, this.submissionId)); + + if (this.assessmentId) { + promises.push(AddonModWorkshop.invalidateAssessmentFormData(this.workshopId, this.assessmentId)); + promises.push(AddonModWorkshop.invalidateAssessmentData(this.workshopId, this.assessmentId)); + } + + if (this.assessmentUserId) { + promises.push(AddonModWorkshop.invalidateReviewerAssesmentsData(this.workshopId, this.assessmentId)); + } + + try { + await Promise.all(promises); + } finally { + CoreEvents.trigger(AddonModWorkshopProvider.ASSESSMENT_INVALIDATED, null, this.siteId); + + await this.fetchSubmissionData(); + } + } + + /** + * Pull to refresh. + * + * @param refresher Refresher. + */ + refreshSubmission(refresher: IonRefresher): void { + if (this.loaded) { + this.refreshAllData().finally(() => { + refresher?.complete(); + }); + } + } + + /** + * Save the assessment. + */ + async saveAssessment(): Promise { + if (this.assessmentStrategy?.hasDataChanged()) { + try { + await this.assessmentStrategy.saveAssessment(); + this.forceLeavePage(); + } catch { + // Error, stay on the page. + } + } else { + // Nothing to save, just go back. + this.forceLeavePage(); + } + } + + /** + * Save the submission evaluation. + */ + async saveEvaluation(): Promise { + // Check if data has changed. + if (this.hasEvaluationChanged()) { + await this.sendEvaluation(); + this.forceLeavePage(); + } else { + // Nothing to save, just go back. + this.forceLeavePage(); + } + } + + /** + * Sends the evaluation to be saved on the server. + * + * @return Resolved when done. + */ + protected async sendEvaluation(): Promise { + const modal = await CoreDomUtils.showModalLoading('core.sending', true); + + const inputData: { + grade: number | string; + text: string; + published: boolean; + } = this.feedbackForm.value; + + inputData.grade = inputData.grade >= 0 ? inputData.grade : ''; + // Add some HTML to the message if needed. + inputData.text = CoreTextUtils.formatHtmlLines(inputData.text); + + // Try to send it to server. + try { + const result = await AddonModWorkshop.evaluateSubmission( + this.workshopId, + this.submissionId, + this.courseId, + inputData.text, + inputData.published, + String(inputData.grade), + ); + CoreForms.triggerFormSubmittedEvent(this.formElement, !!result, this.siteId); + + await AddonModWorkshop.invalidateSubmissionData(this.workshopId, this.submissionId).finally(() => { + const data: AddonModWorkshopSubmissionChangedEventData = { + workshopId: this.workshopId, + submissionId: this.submissionId, + }; + + CoreEvents.trigger(AddonModWorkshopProvider.SUBMISSION_CHANGED, data, this.siteId); + }); + } catch (message) { + CoreDomUtils.showErrorModalDefault(message, 'Cannot save submission evaluation'); + } finally { + modal.dismiss(); + } + } + + /** + * Perform the submission delete action. + */ + async deleteSubmission(): Promise { + try { + await CoreDomUtils.showDeleteConfirm('addon.mod_workshop.submissiondeleteconfirm'); + } catch { + return; + } + + const modal = await CoreDomUtils.showModalLoading('core.deleting', true); + + let success = false; + try { + await AddonModWorkshop.deleteSubmission(this.workshopId, this.submissionId, this.courseId); + success = true; + + await AddonModWorkshop.invalidateSubmissionData(this.workshopId, this.submissionId); + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'Cannot delete submission'); + } finally { + modal.dismiss(); + if (success) { + const data: AddonModWorkshopSubmissionChangedEventData = { + workshopId: this.workshopId, + submissionId: this.submissionId, + }; + + CoreEvents.trigger(AddonModWorkshopProvider.SUBMISSION_CHANGED, data, this.siteId); + + this.forceLeavePage(); + } + } + } + + /** + * Undo the submission delete action. + * + * @return Resolved when done. + */ + async undoDeleteSubmission(): Promise { + await AddonModWorkshopOffline.deleteSubmissionAction( + this.workshopId, + AddonModWorkshopAction.DELETE, + ).finally(async () => { + + const data: AddonModWorkshopSubmissionChangedEventData = { + workshopId: this.workshopId, + submissionId: this.submissionId, + }; + + CoreEvents.trigger(AddonModWorkshopProvider.SUBMISSION_CHANGED, data, this.siteId); + + await this.refreshAllData(); + }); + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + this.isDestroyed = true; + + this.syncObserver?.off(); + this.obsAssessmentSaved?.off(); + // Restore original back functions. + CoreSync.unblockOperation(this.component, this.workshopId); + } + +} + +type AddonWorkshopSubmissionEvaluateData = { + published: boolean; + text: string; + grade?: CoreGradesMenuItem; +}; diff --git a/src/addons/mod/workshop/services/assessment-strategy-delegate.ts b/src/addons/mod/workshop/services/assessment-strategy-delegate.ts new file mode 100644 index 000000000..b3ad2256d --- /dev/null +++ b/src/addons/mod/workshop/services/assessment-strategy-delegate.ts @@ -0,0 +1,159 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable, Type } from '@angular/core'; +import { CoreDelegateHandler, CoreDelegate } from '@classes/delegate'; +import { makeSingleton } from '@singletons'; +import { CoreFormFields } from '@singletons/form'; +import { AddonModWorkshopGetAssessmentFormDefinitionData, AddonModWorkshopGetAssessmentFormFieldsParsedData } from './workshop'; + +/** + * Interface that all assessment strategy handlers must implement. + */ +export interface AddonWorkshopAssessmentStrategyHandler extends CoreDelegateHandler { + /** + * The name of the assessment strategy. E.g. 'accumulative'. + */ + strategyName: string; + + /** + * Return the Component to render the plugin. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @param injector Injector. + * @return The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent?(): Type; + + /** + * Prepare original values to be shown and compared. + * + * @param form Original data of the form. + * @param workshopId WorkShop Id + * @return Promise resolved with original values sorted. + */ + getOriginalValues?( + form: AddonModWorkshopGetAssessmentFormDefinitionData, + workshopId: number, + ): Promise; + + /** + * Check if the assessment data has changed for a certain submission and workshop for a this strategy plugin. + * + * @param originalValues Original values of the form. + * @param currentValues Current values of the form. + * @return True if data has changed, false otherwise. + */ + hasDataChanged?( + originalValues: AddonModWorkshopGetAssessmentFormFieldsParsedData[], + currentValues: AddonModWorkshopGetAssessmentFormFieldsParsedData[], + ): boolean; + + /** + * Prepare assessment data to be sent to the server depending on the strategy selected. + * + * @param currentValues Current values of the form. + * @param form Assessment form data. + * @return Promise resolved with the data to be sent. Or rejected with the input errors object. + */ + prepareAssessmentData( + currentValues: AddonModWorkshopGetAssessmentFormFieldsParsedData[], + form: AddonModWorkshopGetAssessmentFormDefinitionData, + ): Promise>; +} + +/** + * Delegate to register workshop assessment strategy handlers. + * You can use this service to register your own assessment strategy handlers to be used in a workshop. + */ +@Injectable({ providedIn: 'root' }) +export class AddonWorkshopAssessmentStrategyDelegateService extends CoreDelegate { + + protected handlerNameProperty = 'strategyName'; + + constructor() { + super('AddonWorkshopAssessmentStrategyDelegate', true); + } + + /** + * Check if an assessment strategy plugin is supported. + * + * @param workshopStrategy Assessment strategy name. + * @return True if supported, false otherwise. + */ + isPluginSupported(workshopStrategy: string): boolean { + return this.hasHandler(workshopStrategy, true); + } + + /** + * Get the directive to use for a certain assessment strategy plugin. + * + * @param injector Injector. + * @param workshopStrategy Assessment strategy name. + * @return The component, undefined if not found. + */ + getComponentForPlugin(workshopStrategy: string): Type | undefined { + return this.executeFunctionOnEnabled(workshopStrategy, 'getComponent'); + } + + /** + * Prepare original values to be shown and compared depending on the strategy selected. + * + * @param workshopStrategy Workshop strategy. + * @param form Original data of the form. + * @param workshopId Workshop ID. + * @return Resolved with original values sorted. + */ + getOriginalValues( + workshopStrategy: string, + form: AddonModWorkshopGetAssessmentFormDefinitionData, + workshopId: number, + ): Promise { + return Promise.resolve(this.executeFunctionOnEnabled(workshopStrategy, 'getOriginalValues', [form, workshopId]) || []); + } + + /** + * Check if the assessment data has changed for a certain submission and workshop for a this strategy plugin. + * + * @param workshopStrategy Workshop strategy. + * @param originalValues Original values of the form. + * @param currentValues Current values of the form. + * @return True if data has changed, false otherwise. + */ + hasDataChanged( + workshopStrategy: string, + originalValues: AddonModWorkshopGetAssessmentFormFieldsParsedData[], + currentValues: AddonModWorkshopGetAssessmentFormFieldsParsedData[], + ): boolean { + return this.executeFunctionOnEnabled(workshopStrategy, 'hasDataChanged', [originalValues, currentValues]) || false; + } + + /** + * Prepare assessment data to be sent to the server depending on the strategy selected. + * + * @param workshopStrategy Workshop strategy to follow. + * @param currentValues Current values of the form. + * @param form Assessment form data. + * @return Promise resolved with the data to be sent. Or rejected with the input errors object. + */ + prepareAssessmentData( + workshopStrategy: string, + currentValues: AddonModWorkshopGetAssessmentFormFieldsParsedData[], + form: AddonModWorkshopGetAssessmentFormDefinitionData, + ): Promise | undefined> { + return Promise.resolve(this.executeFunctionOnEnabled(workshopStrategy, 'prepareAssessmentData', [currentValues, form])); + } + +} +export const AddonWorkshopAssessmentStrategyDelegate = makeSingleton(AddonWorkshopAssessmentStrategyDelegateService); 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-lazy.module.ts b/src/addons/mod/workshop/workshop-lazy.module.ts new file mode 100644 index 000000000..14fec0473 --- /dev/null +++ b/src/addons/mod/workshop/workshop-lazy.module.ts @@ -0,0 +1,63 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { CanLeaveGuard } from '@guards/can-leave'; + +import { CoreSharedModule } from '@/core/shared.module'; +import { AddonModWorkshopIndexPage } from './pages/index/index'; +import { AddonModWorkshopComponentsModule } from './components/components.module'; +import { AddonModWorkshopSubmissionPage } from './pages/submission/submission'; +import { CoreEditorComponentsModule } from '@features/editor/components/components.module'; +import { AddonModWorkshopAssessmentPage } from './pages/assessment/assessment'; +import { AddonModWorkshopEditSubmissionPage } from './pages/edit-submission/edit-submission'; + +const routes: Routes = [ + { + path: ':courseId/:cmId', + component: AddonModWorkshopIndexPage, + }, + { + path: ':courseId/:cmId/:submissionId', + component: AddonModWorkshopSubmissionPage, + canDeactivate: [CanLeaveGuard], + }, + { + path: ':courseId/:cmId/:submissionId/edit', // @todo + component: AddonModWorkshopEditSubmissionPage, + canDeactivate: [CanLeaveGuard], + }, + { + path: ':courseId/:cmId/:submissionId/:assessmentId', + component: AddonModWorkshopAssessmentPage, + canDeactivate: [CanLeaveGuard], + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CoreSharedModule, + AddonModWorkshopComponentsModule, + CoreEditorComponentsModule, + ], + declarations: [ + AddonModWorkshopIndexPage, + AddonModWorkshopSubmissionPage, + AddonModWorkshopAssessmentPage, + AddonModWorkshopEditSubmissionPage, + ], +}) +export class AddonModWorkshopLazyModule {} 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/addons/qbehaviour/qbehaviour.module.ts b/src/addons/qbehaviour/qbehaviour.module.ts index 499ce1334..93f38bd98 100644 --- a/src/addons/qbehaviour/qbehaviour.module.ts +++ b/src/addons/qbehaviour/qbehaviour.module.ts @@ -20,12 +20,11 @@ import { AddonQbehaviourDeferredFeedbackModule } from './deferredfeedback/deferr import { AddonQbehaviourImmediateCBMModule } from './immediatecbm/immediatecbm.module'; import { AddonQbehaviourImmediateFeedbackModule } from './immediatefeedback/immediatefeedback.module'; import { AddonQbehaviourInformationItemModule } from './informationitem/informationitem.module'; -import { AddonQbehaviourInteractiveModule } from './interactive/interactive.module'; import { AddonQbehaviourInteractiveCountbackModule } from './interactivecountback/interactivecountback.module'; +import { AddonQbehaviourInteractiveModule } from './interactive/interactive.module'; import { AddonQbehaviourManualGradedModule } from './manualgraded/manualgraded.module'; @NgModule({ - declarations: [], imports: [ AddonQbehaviourAdaptiveModule, AddonQbehaviourAdaptiveNoPenaltyModule, @@ -34,12 +33,9 @@ import { AddonQbehaviourManualGradedModule } from './manualgraded/manualgraded.m AddonQbehaviourImmediateCBMModule, AddonQbehaviourImmediateFeedbackModule, AddonQbehaviourInformationItemModule, - AddonQbehaviourInteractiveModule, AddonQbehaviourInteractiveCountbackModule, + AddonQbehaviourInteractiveModule, AddonQbehaviourManualGradedModule, ], - providers: [ - ], - exports: [], }) export class AddonQbehaviourModule { } diff --git a/src/addons/qtype/qtype.module.ts b/src/addons/qtype/qtype.module.ts index 716b5b586..bd7730bf4 100644 --- a/src/addons/qtype/qtype.module.ts +++ b/src/addons/qtype/qtype.module.ts @@ -13,6 +13,7 @@ // limitations under the License. import { NgModule } from '@angular/core'; + import { AddonQtypeCalculatedModule } from './calculated/calculated.module'; import { AddonQtypeCalculatedMultiModule } from './calculatedmulti/calculatedmulti.module'; import { AddonQtypeCalculatedSimpleModule } from './calculatedsimple/calculatedsimple.module'; @@ -31,7 +32,6 @@ import { AddonQtypeShortAnswerModule } from './shortanswer/shortanswer.module'; import { AddonQtypeTrueFalseModule } from './truefalse/truefalse.module'; @NgModule({ - declarations: [], imports: [ AddonQtypeCalculatedModule, AddonQtypeCalculatedMultiModule, @@ -50,8 +50,5 @@ import { AddonQtypeTrueFalseModule } from './truefalse/truefalse.module'; AddonQtypeShortAnswerModule, AddonQtypeTrueFalseModule, ], - providers: [ - ], - exports: [], }) export class AddonQtypeModule { } diff --git a/src/addons/remotethemes/remotethemes.module.ts b/src/addons/remotethemes/remotethemes.module.ts index 7a2b74e1d..322d7744e 100644 --- a/src/addons/remotethemes/remotethemes.module.ts +++ b/src/addons/remotethemes/remotethemes.module.ts @@ -21,8 +21,6 @@ export const ADDON_REMOTETHEMES_SERVICES: Type[] = [ ]; @NgModule({ - declarations: [], - imports: [], providers: [ { provide: APP_INITIALIZER, diff --git a/src/addons/userprofilefield/textarea/component/addon-user-profile-field-textarea.html b/src/addons/userprofilefield/textarea/component/addon-user-profile-field-textarea.html index 0286d08ec..5efeb1254 100644 --- a/src/addons/userprofilefield/textarea/component/addon-user-profile-field-textarea.html +++ b/src/addons/userprofilefield/textarea/component/addon-user-profile-field-textarea.html @@ -14,7 +14,7 @@ {{ field.name }} -
diff --git a/src/addons/userprofilefield/userprofilefield.module.ts b/src/addons/userprofilefield/userprofilefield.module.ts index 50910d3a2..2b6096c2a 100644 --- a/src/addons/userprofilefield/userprofilefield.module.ts +++ b/src/addons/userprofilefield/userprofilefield.module.ts @@ -16,18 +16,16 @@ import { NgModule } from '@angular/core'; import { AddonUserProfileFieldCheckboxModule } from './checkbox/checkbox.module'; import { AddonUserProfileFieldDatetimeModule } from './datetime/datetime.module'; import { AddonUserProfileFieldMenuModule } from './menu/menu.module'; -import { AddonUserProfileFieldTextModule } from './text/text.module'; import { AddonUserProfileFieldTextareaModule } from './textarea/textarea.module'; +import { AddonUserProfileFieldTextModule } from './text/text.module'; @NgModule({ - declarations: [], imports: [ AddonUserProfileFieldCheckboxModule, AddonUserProfileFieldDatetimeModule, AddonUserProfileFieldMenuModule, - AddonUserProfileFieldTextModule, AddonUserProfileFieldTextareaModule, + AddonUserProfileFieldTextModule, ], - exports: [], }) export class AddonUserProfileFieldModule { } diff --git a/src/core/components/components.module.ts b/src/core/components/components.module.ts index 33596bb6b..ed321c8fa 100644 --- a/src/core/components/components.module.ts +++ b/src/core/components/components.module.ts @@ -18,80 +18,80 @@ import { IonicModule } from '@ionic/angular'; import { TranslateModule } from '@ngx-translate/core'; import { FormsModule } from '@angular/forms'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { CorePipesModule } from '@pipes/pipes.module'; + +import { CoreAttachmentsComponent } from './attachments/attachments'; +import { CoreBSTooltipComponent } from './bs-tooltip/bs-tooltip'; +import { CoreChartComponent } from './chart/chart'; import { CoreChronoComponent } from './chrono/chrono'; -import { CoreDownloadRefreshComponent } from './download-refresh/download-refresh'; -import { CoreFileComponent } from './file/file'; -import { CoreIconComponent } from './icon/icon'; -import { CoreIframeComponent } from './iframe/iframe'; -import { CoreInputErrorsComponent } from './input-errors/input-errors'; -import { CoreLoadingComponent } from './loading/loading'; -import { CoreMarkRequiredComponent } from './mark-required/mark-required'; -import { CoreRecaptchaComponent } from './recaptcha/recaptcha'; -import { CoreRecaptchaModalComponent } from './recaptcha/recaptcha-modal'; -import { CoreShowPasswordComponent } from './show-password/show-password'; -import { CoreSplitViewComponent } from './split-view/split-view'; -import { CoreEmptyBoxComponent } from './empty-box/empty-box'; -import { CoreTabsComponent } from './tabs/tabs'; -import { CoreTabComponent } from './tabs/tab'; -import { CoreTabsOutletComponent } from './tabs-outlet/tabs-outlet'; -import { CoreInfiniteLoadingComponent } from './infinite-loading/infinite-loading'; -import { CoreProgressBarComponent } from './progress-bar/progress-bar'; import { CoreContextMenuComponent } from './context-menu/context-menu'; import { CoreContextMenuItemComponent } from './context-menu/context-menu-item'; import { CoreContextMenuPopoverComponent } from './context-menu/context-menu-popover'; -import { CoreUserAvatarComponent } from './user-avatar/user-avatar'; +import { CoreDownloadRefreshComponent } from './download-refresh/download-refresh'; import { CoreDynamicComponent } from './dynamic-component/dynamic-component'; -import { CoreNavBarButtonsComponent } from './navbar-buttons/navbar-buttons'; -import { CoreSendMessageFormComponent } from './send-message-form/send-message-form'; -import { CoreTimerComponent } from './timer/timer'; -import { CoreNavigationBarComponent } from './navigation-bar/navigation-bar'; - -import { CoreDirectivesModule } from '@directives/directives.module'; -import { CorePipesModule } from '@pipes/pipes.module'; -import { CoreAttachmentsComponent } from './attachments/attachments'; +import { CoreEmptyBoxComponent } from './empty-box/empty-box'; +import { CoreFileComponent } from './file/file'; import { CoreFilesComponent } from './files/files'; +import { CoreIconComponent } from './icon/icon'; +import { CoreIframeComponent } from './iframe/iframe'; +import { CoreInfiniteLoadingComponent } from './infinite-loading/infinite-loading'; +import { CoreInputErrorsComponent } from './input-errors/input-errors'; +import { CoreLoadingComponent } from './loading/loading'; import { CoreLocalFileComponent } from './local-file/local-file'; -import { CoreBSTooltipComponent } from './bs-tooltip/bs-tooltip'; +import { CoreMarkRequiredComponent } from './mark-required/mark-required'; +import { CoreNavBarButtonsComponent } from './navbar-buttons/navbar-buttons'; +import { CoreNavigationBarComponent } from './navigation-bar/navigation-bar'; +import { CoreProgressBarComponent } from './progress-bar/progress-bar'; +import { CoreRecaptchaComponent } from './recaptcha/recaptcha'; +import { CoreRecaptchaModalComponent } from './recaptcha/recaptcha-modal'; +import { CoreSendMessageFormComponent } from './send-message-form/send-message-form'; +import { CoreShowPasswordComponent } from './show-password/show-password'; import { CoreSitePickerComponent } from './site-picker/site-picker'; -import { CoreChartComponent } from './chart/chart'; +import { CoreSplitViewComponent } from './split-view/split-view'; import { CoreStyleComponent } from './style/style'; +import { CoreTabComponent } from './tabs/tab'; +import { CoreTabsComponent } from './tabs/tabs'; +import { CoreTabsOutletComponent } from './tabs-outlet/tabs-outlet'; +import { CoreTimerComponent } from './timer/timer'; +import { CoreUserAvatarComponent } from './user-avatar/user-avatar'; @NgModule({ declarations: [ + CoreAttachmentsComponent, + CoreBSTooltipComponent, + CoreChartComponent, CoreChronoComponent, - CoreDownloadRefreshComponent, - CoreFileComponent, - CoreIconComponent, - CoreIframeComponent, - CoreInputErrorsComponent, - CoreLoadingComponent, - CoreMarkRequiredComponent, - CoreRecaptchaComponent, - CoreRecaptchaModalComponent, - CoreShowPasswordComponent, - CoreSplitViewComponent, - CoreStyleComponent, - CoreEmptyBoxComponent, - CoreTabsComponent, - CoreTabComponent, - CoreTabsOutletComponent, - CoreInfiniteLoadingComponent, - CoreProgressBarComponent, CoreContextMenuComponent, CoreContextMenuItemComponent, CoreContextMenuPopoverComponent, - CoreNavBarButtonsComponent, - CoreUserAvatarComponent, + CoreDownloadRefreshComponent, CoreDynamicComponent, - CoreSendMessageFormComponent, - CoreTimerComponent, - CoreNavigationBarComponent, - CoreAttachmentsComponent, + CoreEmptyBoxComponent, + CoreFileComponent, CoreFilesComponent, + CoreIconComponent, + CoreIframeComponent, + CoreInfiniteLoadingComponent, + CoreInputErrorsComponent, + CoreLoadingComponent, CoreLocalFileComponent, - CoreBSTooltipComponent, + CoreMarkRequiredComponent, + CoreNavBarButtonsComponent, + CoreNavigationBarComponent, + CoreProgressBarComponent, + CoreRecaptchaComponent, + CoreRecaptchaModalComponent, + CoreSendMessageFormComponent, + CoreShowPasswordComponent, CoreSitePickerComponent, - CoreChartComponent, + CoreSplitViewComponent, + CoreStyleComponent, + CoreTabComponent, + CoreTabsComponent, + CoreTabsOutletComponent, + CoreTimerComponent, + CoreUserAvatarComponent, ], imports: [ CommonModule, @@ -102,40 +102,40 @@ import { CoreStyleComponent } from './style/style'; CorePipesModule, ], exports: [ + CoreAttachmentsComponent, + CoreBSTooltipComponent, + CoreChartComponent, CoreChronoComponent, - CoreDownloadRefreshComponent, - CoreFileComponent, - CoreIconComponent, - CoreIframeComponent, - CoreInputErrorsComponent, - CoreLoadingComponent, - CoreMarkRequiredComponent, - CoreRecaptchaComponent, - CoreRecaptchaModalComponent, - CoreShowPasswordComponent, - CoreSplitViewComponent, - CoreStyleComponent, - CoreEmptyBoxComponent, - CoreTabsComponent, - CoreTabComponent, - CoreTabsOutletComponent, - CoreInfiniteLoadingComponent, - CoreProgressBarComponent, CoreContextMenuComponent, CoreContextMenuItemComponent, CoreContextMenuPopoverComponent, - CoreNavBarButtonsComponent, - CoreUserAvatarComponent, + CoreDownloadRefreshComponent, CoreDynamicComponent, - CoreSendMessageFormComponent, - CoreTimerComponent, - CoreNavigationBarComponent, - CoreAttachmentsComponent, + CoreEmptyBoxComponent, + CoreFileComponent, CoreFilesComponent, + CoreIconComponent, + CoreIframeComponent, + CoreInfiniteLoadingComponent, + CoreInputErrorsComponent, + CoreLoadingComponent, CoreLocalFileComponent, - CoreBSTooltipComponent, + CoreMarkRequiredComponent, + CoreNavBarButtonsComponent, + CoreNavigationBarComponent, + CoreProgressBarComponent, + CoreRecaptchaComponent, + CoreRecaptchaModalComponent, + CoreSendMessageFormComponent, + CoreShowPasswordComponent, CoreSitePickerComponent, - CoreChartComponent, + CoreSplitViewComponent, + CoreStyleComponent, + CoreTabComponent, + CoreTabsComponent, + CoreTabsOutletComponent, + CoreTimerComponent, + CoreUserAvatarComponent, ], }) export class CoreComponentsModule {} diff --git a/src/core/components/input-errors/input-errors.ts b/src/core/components/input-errors/input-errors.ts index 5024bd9eb..0458c7cc7 100644 --- a/src/core/components/input-errors/input-errors.ts +++ b/src/core/components/input-errors/input-errors.ts @@ -27,15 +27,12 @@ import { Translate } from '@singletons'; * * Please notice that the inputs need to have a FormControl to make it work. That FormControl needs to be passed to this component. * - * If this component is placed in the same ion-item as a ion-label or ion-input, then it should have the attribute "item-content", - * otherwise Ionic will remove it. - * * Example usage: * * * {{ 'core.login.username' | translate }} * - * + * * */ @Component({ diff --git a/src/core/core.module.ts b/src/core/core.module.ts index 3ccf73400..56d5a0ee8 100644 --- a/src/core/core.module.ts +++ b/src/core/core.module.ts @@ -21,34 +21,34 @@ import { CoreInterceptor } from './classes/interceptor'; import { getDatabaseProviders } from './services/database'; import { getInitializerProviders } from './initializers'; -import { CoreDbProvider } from '@services/db'; import { CoreAppProvider } from '@services/app'; import { CoreConfigProvider } from '@services/config'; -import { CoreLangProvider } from '@services/lang'; -import { CoreTextUtilsProvider } from '@services/utils/text'; +import { CoreCronDelegateService } from '@services/cron'; +import { CoreCustomURLSchemesProvider } from '@services/urlschemes'; +import { CoreDbProvider } from '@services/db'; import { CoreDomUtilsProvider } from '@services/utils/dom'; +import { CoreFileHelperProvider } from '@services/file-helper'; +import { CoreFilepoolProvider } from '@services/filepool'; +import { CoreFileProvider } from '@services/file'; +import { CoreFileSessionProvider } from '@services/file-session'; +import { CoreForms } from '@singletons/form'; +import { CoreGeolocationProvider } from '@services/geolocation'; +import { CoreGroupsProvider } from '@services/groups'; import { CoreIframeUtilsProvider } from '@services/utils/iframe'; +import { CoreLangProvider } from '@services/lang'; +import { CoreLocalNotificationsProvider } from '@services/local-notifications'; +import { CoreMimetypeUtilsProvider } from '@services/utils/mimetype'; +import { CoreNavigatorService } from '@services/navigator'; +import { CorePluginFileDelegateService } from '@services/plugin-file-delegate'; +import { CoreScreenService } from '@services/screen'; +import { CoreSitesProvider } from '@services/sites'; +import { CoreSyncProvider } from '@services/sync'; +import { CoreTextUtilsProvider } from '@services/utils/text'; import { CoreTimeUtilsProvider } from '@services/utils/time'; +import { CoreUpdateManagerProvider } from '@services/update-manager'; import { CoreUrlUtilsProvider } from '@services/utils/url'; import { CoreUtilsProvider } from '@services/utils/utils'; -import { CoreMimetypeUtilsProvider } from '@services/utils/mimetype'; -import { CoreFileProvider } from '@services/file'; import { CoreWSProvider } from '@services/ws'; -import { CoreSitesProvider } from '@services/sites'; -import { CoreLocalNotificationsProvider } from '@services/local-notifications'; -import { CoreGroupsProvider } from '@services/groups'; -import { CoreCronDelegateService } from '@services/cron'; -import { CoreFileSessionProvider } from '@services/file-session'; -import { CoreFilepoolProvider } from '@services/filepool'; -import { CoreUpdateManagerProvider } from '@services/update-manager'; -import { CorePluginFileDelegateService } from '@services/plugin-file-delegate'; -import { CoreSyncProvider } from '@services/sync'; -import { CoreFileHelperProvider } from '@services/file-helper'; -import { CoreGeolocationProvider } from '@services/geolocation'; -import { CoreNavigatorService } from '@services/navigator'; -import { CoreScreenService } from '@services/screen'; -import { CoreCustomURLSchemesProvider } from '@services/urlschemes'; -import { CoreForms } from '@singletons/form'; export const CORE_SERVICES: Type[] = [ CoreAppProvider, @@ -56,26 +56,26 @@ export const CORE_SERVICES: Type[] = [ CoreCronDelegateService, CoreCustomURLSchemesProvider, CoreDbProvider, + CoreDomUtilsProvider, CoreFileHelperProvider, - CoreFileSessionProvider, - CoreFileProvider, CoreFilepoolProvider, + CoreFileProvider, + CoreFileSessionProvider, + CoreForms, CoreGeolocationProvider, CoreGroupsProvider, + CoreIframeUtilsProvider, CoreLangProvider, CoreLocalNotificationsProvider, + CoreMimetypeUtilsProvider, CoreNavigatorService, CorePluginFileDelegateService, CoreScreenService, CoreSitesProvider, CoreSyncProvider, - CoreUpdateManagerProvider, - CoreDomUtilsProvider, - CoreForms, - CoreIframeUtilsProvider, - CoreMimetypeUtilsProvider, CoreTextUtilsProvider, CoreTimeUtilsProvider, + CoreUpdateManagerProvider, CoreUrlUtilsProvider, CoreUtilsProvider, CoreWSProvider, diff --git a/src/core/directives/directives.module.ts b/src/core/directives/directives.module.ts index 494c1e1d2..3cf6d3303 100644 --- a/src/core/directives/directives.module.ts +++ b/src/core/directives/directives.module.ts @@ -15,41 +15,40 @@ import { NgModule } from '@angular/core'; import { CoreAutoFocusDirective } from './auto-focus'; +import { CoreAutoRowsDirective } from './auto-rows'; import { CoreExternalContentDirective } from './external-content'; import { CoreFabDirective } from './fab'; +import { CoreFaIconDirective } from './fa-icon'; import { CoreFormatTextDirective } from './format-text'; import { CoreLinkDirective } from './link'; import { CoreLongPressDirective } from './long-press'; import { CoreSupressEventsDirective } from './supress-events'; -import { CoreFaIconDirective } from './fa-icon'; import { CoreUserLinkDirective } from './user-link'; -import { CoreAutoRowsDirective } from './auto-rows'; @NgModule({ declarations: [ CoreAutoFocusDirective, + CoreAutoRowsDirective, CoreExternalContentDirective, + CoreFabDirective, + CoreFaIconDirective, CoreFormatTextDirective, CoreLinkDirective, CoreLongPressDirective, CoreSupressEventsDirective, - CoreFabDirective, - CoreFaIconDirective, CoreUserLinkDirective, - CoreAutoRowsDirective, ], - imports: [], exports: [ CoreAutoFocusDirective, + CoreAutoRowsDirective, CoreExternalContentDirective, + CoreFabDirective, + CoreFaIconDirective, CoreFormatTextDirective, CoreLinkDirective, CoreLongPressDirective, CoreSupressEventsDirective, - CoreFabDirective, - CoreFaIconDirective, CoreUserLinkDirective, - CoreAutoRowsDirective, ], }) export class CoreDirectivesModule {} diff --git a/src/core/features/block/block.module.ts b/src/core/features/block/block.module.ts index 27a447f09..736c1db78 100644 --- a/src/core/features/block/block.module.ts +++ b/src/core/features/block/block.module.ts @@ -22,7 +22,5 @@ export const CORE_BLOCK_SERVICES: Type[] = [ CoreBlockHelperProvider, ]; -@NgModule({ - providers: [], -}) +@NgModule({}) export class CoreBlockModule {} 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/features/contentlinks/contentlinks.module.ts b/src/core/features/contentlinks/contentlinks.module.ts index c7d9d1210..127464bfe 100644 --- a/src/core/features/contentlinks/contentlinks.module.ts +++ b/src/core/features/contentlinks/contentlinks.module.ts @@ -22,10 +22,5 @@ export const CORE_CONTENTLINKS_SERVICES: Type[] = [ CoreContentLinksHelperProvider, ]; -@NgModule({ - declarations: [], - imports: [], - providers: [], - exports: [], -}) +@NgModule({}) export class CoreContentLinksModule {} diff --git a/src/core/features/course/classes/main-resource-component.ts b/src/core/features/course/classes/main-resource-component.ts index e39121412..4aa56ece4 100644 --- a/src/core/features/course/classes/main-resource-component.ts +++ b/src/core/features/course/classes/main-resource-component.ts @@ -67,12 +67,12 @@ export class CoreCourseModuleMainResourceComponent implements OnInit, OnDestroy, prefetchStatus?: string; // Used when calling fillContextMenu. prefetchText?: string; // Used when calling fillContextMenu. size?: string; // Used when calling fillContextMenu. - isDestroyed?: boolean; // Whether the component is destroyed, used when calling fillContextMenu. + isDestroyed = false; // Whether the component is destroyed, used when calling fillContextMenu. contextMenuStatusObserver?: CoreEventObserver; // Observer of package status, used when calling fillContextMenu. contextFileStatusObserver?: CoreEventObserver; // Observer of file status, used when calling fillContextMenu. protected fetchContentDefaultError = 'core.course.errorgetmodule'; // Default error to show when loading contents. - protected isCurrentView?: boolean; // Whether the component is in the current view. + protected isCurrentView = false; // Whether the component is in the current view. protected siteId?: string; // Current Site ID. protected statusObserver?: CoreEventObserver; // Observer of package status. Only if setStatusListener is called. protected currentStatus?: string; // The current status of the module. Only if setStatusListener is called. diff --git a/src/core/features/course/directives/directives.module.ts b/src/core/features/course/directives/directives.module.ts index 41dfe1b61..ac12d9e9e 100644 --- a/src/core/features/course/directives/directives.module.ts +++ b/src/core/features/course/directives/directives.module.ts @@ -20,7 +20,6 @@ import { CoreCourseDownloadModuleMainFileDirective } from './download-module-mai declarations: [ CoreCourseDownloadModuleMainFileDirective, ], - imports: [], exports: [ CoreCourseDownloadModuleMainFileDirective, ], diff --git a/src/core/features/course/format/formats.module.ts b/src/core/features/course/format/formats.module.ts index 7da1f733a..3519b6cb0 100644 --- a/src/core/features/course/format/formats.module.ts +++ b/src/core/features/course/format/formats.module.ts @@ -20,14 +20,11 @@ import { CoreCourseFormatTopicsModule } from './topics/topics.module'; import { CoreCourseFormatWeeksModule } from './weeks/weeks.module'; @NgModule({ - declarations: [], imports: [ CoreCourseFormatSingleActivityModule, CoreCourseFormatSocialModule, CoreCourseFormatTopicsModule, CoreCourseFormatWeeksModule, ], - providers: [], - exports: [], }) export class CoreCourseFormatModule { } diff --git a/src/core/features/course/format/social/social.module.ts b/src/core/features/course/format/social/social.module.ts index 918fd0a25..16f1379c6 100644 --- a/src/core/features/course/format/social/social.module.ts +++ b/src/core/features/course/format/social/social.module.ts @@ -18,8 +18,6 @@ import { CoreCourseFormatDelegate } from '@features/course/services/format-deleg import { CoreCourseFormatSocialHandler } from './services/handlers/social-format'; @NgModule({ - declarations: [], - imports: [], providers: [ { provide: APP_INITIALIZER, diff --git a/src/core/features/course/format/topics/topics.module.ts b/src/core/features/course/format/topics/topics.module.ts index 3760c31c0..3a7594aa7 100644 --- a/src/core/features/course/format/topics/topics.module.ts +++ b/src/core/features/course/format/topics/topics.module.ts @@ -18,8 +18,6 @@ import { CoreCourseFormatDelegate } from '@features/course/services/format-deleg import { CoreCourseFormatTopicsHandler } from './services/handlers/topics-format'; @NgModule({ - declarations: [], - imports: [], providers: [ { provide: APP_INITIALIZER, diff --git a/src/core/features/course/format/weeks/weeks.module.ts b/src/core/features/course/format/weeks/weeks.module.ts index bf7c5e685..491c122e4 100644 --- a/src/core/features/course/format/weeks/weeks.module.ts +++ b/src/core/features/course/format/weeks/weeks.module.ts @@ -18,8 +18,6 @@ import { CoreCourseFormatDelegate } from '@features/course/services/format-deleg import { CoreCourseFormatWeeksHandler } from './services/handlers/weeks-format'; @NgModule({ - declarations: [], - imports: [], providers: [ { provide: APP_INITIALIZER, diff --git a/src/core/features/editor/components/rich-text-editor/rich-text-editor.ts b/src/core/features/editor/components/rich-text-editor/rich-text-editor.ts index 65aacd83c..3605d5c53 100644 --- a/src/core/features/editor/components/rich-text-editor/rich-text-editor.ts +++ b/src/core/features/editor/components/rich-text-editor/rich-text-editor.ts @@ -44,7 +44,7 @@ import { CoreEditorOffline } from '../../services/editor-offline'; * If enabled, this component will show a rich text editor. Otherwise it'll show a regular textarea. * * Example: - * + * */ @Component({ selector: 'core-rich-text-editor', diff --git a/src/core/features/features.module.ts b/src/core/features/features.module.ts index cdece3e09..a8d883e7e 100644 --- a/src/core/features/features.module.ts +++ b/src/core/features/features.module.ts @@ -14,51 +14,59 @@ import { NgModule } from '@angular/core'; +import { CoreBlockModule } from './block/block.module'; +import { CoreCommentsModule } from './comments/comments.module'; +import { CoreContentLinksModule } from './contentlinks/contentlinks.module'; import { CoreCourseModule } from './course/course.module'; import { CoreCoursesModule } from './courses/courses.module'; +import { CoreEditorModule } from './editor/editor.module'; import { CoreEmulatorModule } from './emulator/emulator.module'; import { CoreFileUploaderModule } from './fileuploader/fileuploader.module'; +import { CoreFilterModule } from './filter/filter.module'; import { CoreGradesModule } from './grades/grades.module'; import { CoreH5PModule } from './h5p/h5p.module'; import { CoreLoginModule } from './login/login.module'; import { CoreMainMenuModule } from './mainmenu/mainmenu.module'; +import { CorePushNotificationsModule } from './pushnotifications/pushnotifications.module'; +import { CoreQuestionModule } from './question/question.module'; +import { CoreRatingModule } from './rating/rating.module'; +import { CoreSearchModule } from './search/search.module'; import { CoreSettingsModule } from './settings/settings.module'; +import { CoreSharedFilesModule } from './sharedfiles/sharedfiles.module'; import { CoreSiteHomeModule } from './sitehome/sitehome.module'; +import { CoreSitePluginsModule } from './siteplugins/siteplugins.module'; import { CoreTagModule } from './tag/tag.module'; import { CoreUserModule } from './user/user.module'; -import { CorePushNotificationsModule } from './pushnotifications/pushnotifications.module'; -import { CoreXAPIModule } from './xapi/xapi.module'; import { CoreViewerModule } from './viewer/viewer.module'; -import { CoreSearchModule } from './search/search.module'; -import { CoreCommentsModule } from './comments/comments.module'; -import { CoreSitePluginsModule } from './siteplugins/siteplugins.module'; -import { CoreRatingModule } from './rating/rating.module'; -import { CoreSharedFilesModule } from './sharedfiles/sharedfiles.module'; -import { CoreQuestionModule } from './question/question.module'; +import { CoreXAPIModule } from './xapi/xapi.module'; @NgModule({ imports: [ + CoreBlockModule, + CoreCommentsModule, + CoreContentLinksModule, CoreCourseModule, CoreCoursesModule, + CoreEditorModule, CoreEmulatorModule, CoreFileUploaderModule, + CoreFilterModule, CoreGradesModule, + CoreH5PModule, CoreLoginModule, CoreMainMenuModule, + CorePushNotificationsModule, + CoreQuestionModule, + CoreRatingModule, + CoreSearchModule, CoreSettingsModule, + CoreSharedFilesModule, CoreSiteHomeModule, + CoreSitePluginsModule, CoreTagModule, CoreUserModule, - CorePushNotificationsModule, - CoreSearchModule, - CoreXAPIModule, - CoreH5PModule, CoreViewerModule, - CoreCommentsModule, - CoreSitePluginsModule, - CoreRatingModule, - CoreSharedFilesModule, - CoreQuestionModule, + CoreXAPIModule, ], }) export class CoreFeaturesModule {} diff --git a/src/core/features/fileuploader/fileuploader.module.ts b/src/core/features/fileuploader/fileuploader.module.ts index 52b8d91da..8ca214fe7 100644 --- a/src/core/features/fileuploader/fileuploader.module.ts +++ b/src/core/features/fileuploader/fileuploader.module.ts @@ -30,8 +30,6 @@ export const CORE_FILEUPLOADER_SERVICES: Type[] = [ ]; @NgModule({ - imports: [], - declarations: [], providers: [ { provide: APP_INITIALIZER, diff --git a/src/core/features/filter/filter.module.ts b/src/core/features/filter/filter.module.ts index 23ddcd933..771565277 100644 --- a/src/core/features/filter/filter.module.ts +++ b/src/core/features/filter/filter.module.ts @@ -23,9 +23,5 @@ export const CORE_FILTER_SERVICES: Type[] = [ CoreFilterHelperProvider, ]; -@NgModule({ - declarations: [], - imports: [], - providers: [], -}) +@NgModule({}) export class CoreFilterModule { } diff --git a/src/core/features/grades/services/grades-helper.ts b/src/core/features/grades/services/grades-helper.ts index 15bf71d7e..c04a9ca78 100644 --- a/src/core/features/grades/services/grades-helper.ts +++ b/src/core/features/grades/services/grades-helper.ts @@ -306,20 +306,16 @@ export class CoreGradesHelperProvider { * @param selectedGrade Selected grade value. * @return Selected grade label. */ - getGradeLabelFromValue(grades: CoreGradesMenuItem[], selectedGrade: number): string { + getGradeLabelFromValue(grades: CoreGradesMenuItem[], selectedGrade?: number): string { selectedGrade = Number(selectedGrade); if (!grades || !selectedGrade || selectedGrade <= 0) { return ''; } - for (const x in grades) { - if (grades[x].value == selectedGrade) { - return grades[x].label; - } - } + const grade = grades.find((grade) => grade.value == selectedGrade); - return ''; + return grade ? grade.label : ''; } /** @@ -633,31 +629,35 @@ export class CoreGradesHelperProvider { * @param scale Scale csv list String. If not provided, it will take it from the module grade info. * @return Array with objects with value and label to create a propper HTML select. */ - makeGradesMenu( - gradingType: number, + async makeGradesMenu( + gradingType?: number, moduleId?: number, defaultLabel: string = '', defaultValue: string | number = '', scale?: string, ): Promise { + if (typeof gradingType == 'undefined') { + return []; + } + if (gradingType < 0) { if (scale) { - return Promise.resolve(CoreUtils.makeMenuFromList(scale, defaultLabel, undefined, defaultValue)); - } else if (moduleId) { - return CoreCourse.getModuleBasicGradeInfo(moduleId).then((gradeInfo) => { - if (gradeInfo && gradeInfo.scale) { - return CoreUtils.makeMenuFromList(gradeInfo.scale, defaultLabel, undefined, defaultValue); - } - - return []; - }); - } else { - return Promise.resolve([]); + return CoreUtils.makeMenuFromList(scale, defaultLabel, undefined, defaultValue); } + + if (moduleId) { + const gradeInfo = await CoreCourse.getModuleBasicGradeInfo(moduleId); + if (gradeInfo && gradeInfo.scale) { + return CoreUtils.makeMenuFromList(gradeInfo.scale, defaultLabel, undefined, defaultValue); + } + } + + return []; } if (gradingType > 0) { const grades: CoreGradesMenuItem[] = []; + if (defaultLabel) { // Key as string to avoid resorting of the object. grades.push({ @@ -665,6 +665,7 @@ export class CoreGradesHelperProvider { value: defaultValue, }); } + for (let i = gradingType; i >= 0; i--) { grades.push({ label: i + ' / ' + gradingType, @@ -672,10 +673,10 @@ export class CoreGradesHelperProvider { }); } - return Promise.resolve(grades); + return grades; } - return Promise.resolve([]); + return []; } /** diff --git a/src/core/features/settings/pages/general/general.html b/src/core/features/settings/pages/general/general.html index 5cd2ad929..0bc345250 100644 --- a/src/core/features/settings/pages/general/general.html +++ b/src/core/features/settings/pages/general/general.html @@ -21,7 +21,7 @@

{{ 'core.settings.fontsize' | translate }}

- + diff --git a/src/core/features/siteplugins/classes/handlers/workshop-assessment-strategy-handler.ts b/src/core/features/siteplugins/classes/handlers/workshop-assessment-strategy-handler.ts new file mode 100644 index 000000000..f78b2f60b --- /dev/null +++ b/src/core/features/siteplugins/classes/handlers/workshop-assessment-strategy-handler.ts @@ -0,0 +1,70 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { AddonWorkshopAssessmentStrategyHandler } from '@addons/mod/workshop/services/assessment-strategy-delegate'; +import { AddonModWorkshopGetAssessmentFormFieldsParsedData } from '@addons/mod/workshop/services/workshop'; +import { Type } from '@angular/core'; +import { + CoreSitePluginsWorkshopAssessmentStrategyComponent, +} from '@features/siteplugins/components/workshop-assessment-strategy/workshop-assessment-strategy'; +import { CoreFormFields } from '@singletons/form'; +import { CoreSitePluginsBaseHandler } from './base-handler'; + +/** + * Handler to display a workshop assessment strategy site plugin. + */ +export class CoreSitePluginsWorkshopAssessmentStrategyHandler + extends CoreSitePluginsBaseHandler + implements AddonWorkshopAssessmentStrategyHandler { + + constructor(public name: string, public strategyName: string) { + super(name); + } + + /** + * @inheritdoc + */ + getComponent(): Type { + return CoreSitePluginsWorkshopAssessmentStrategyComponent; + } + + /** + * @inheritdoc + */ + async getOriginalValues(): Promise { + return []; + } + + /** + * @inheritdoc + */ + hasDataChanged(): boolean { + return false; + } + + /** + * @inheritdoc + */ + async isEnabled(): Promise { + return true; + } + + /** + * @inheritdoc + */ + async prepareAssessmentData(): Promise { + return {}; + } + +} diff --git a/src/core/features/siteplugins/components/components.module.ts b/src/core/features/siteplugins/components/components.module.ts index 977cceb44..2969000e5 100644 --- a/src/core/features/siteplugins/components/components.module.ts +++ b/src/core/features/siteplugins/components/components.module.ts @@ -25,8 +25,7 @@ import { CoreSitePluginsQuestionBehaviourComponent } from './question-behaviour/ import { CoreSitePluginsQuizAccessRuleComponent } from './quiz-access-rule/quiz-access-rule'; import { CoreSitePluginsAssignFeedbackComponent } from './assign-feedback/assign-feedback'; import { CoreSitePluginsAssignSubmissionComponent } from './assign-submission/assign-submission'; -// @todo -// import { CoreSitePluginsWorkshopAssessmentStrategyComponent } from './workshop-assessment-strategy/workshop-assessment-strategy'; +import { CoreSitePluginsWorkshopAssessmentStrategyComponent } from './workshop-assessment-strategy/workshop-assessment-strategy'; import { CoreSitePluginsBlockComponent } from './block/block'; import { CoreSitePluginsOnlyTitleBlockComponent } from './only-title-block/only-title-block'; @@ -43,13 +42,12 @@ import { CoreSitePluginsOnlyTitleBlockComponent } from './only-title-block/only- CoreSitePluginsQuizAccessRuleComponent, CoreSitePluginsAssignFeedbackComponent, CoreSitePluginsAssignSubmissionComponent, - // @todo CoreSitePluginsWorkshopAssessmentStrategyComponent, + CoreSitePluginsWorkshopAssessmentStrategyComponent, ], imports: [ CoreSharedModule, CoreCompileHtmlComponentModule, ], - providers: [], exports: [ CoreSitePluginsPluginContentComponent, CoreSitePluginsModuleIndexComponent, @@ -62,7 +60,7 @@ import { CoreSitePluginsOnlyTitleBlockComponent } from './only-title-block/only- CoreSitePluginsQuizAccessRuleComponent, CoreSitePluginsAssignFeedbackComponent, CoreSitePluginsAssignSubmissionComponent, - // @todo CoreSitePluginsWorkshopAssessmentStrategyComponent, + CoreSitePluginsWorkshopAssessmentStrategyComponent, ], }) export class CoreSitePluginsComponentsModule {} diff --git a/src/core/features/siteplugins/components/workshop-assessment-strategy/core-siteplugins-workshop-assessment-strategy.html b/src/core/features/siteplugins/components/workshop-assessment-strategy/core-siteplugins-workshop-assessment-strategy.html new file mode 100644 index 000000000..f58bcd913 --- /dev/null +++ b/src/core/features/siteplugins/components/workshop-assessment-strategy/core-siteplugins-workshop-assessment-strategy.html @@ -0,0 +1 @@ + diff --git a/src/core/features/siteplugins/components/workshop-assessment-strategy/workshop-assessment-strategy.ts b/src/core/features/siteplugins/components/workshop-assessment-strategy/workshop-assessment-strategy.ts new file mode 100644 index 000000000..ea9e56c18 --- /dev/null +++ b/src/core/features/siteplugins/components/workshop-assessment-strategy/workshop-assessment-strategy.ts @@ -0,0 +1,54 @@ +// (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 { AddonWorkshopAssessmentStrategyDelegate } from '@addons/mod/workshop/services/assessment-strategy-delegate'; +import { AddonModWorkshopGetAssessmentFormFieldsParsedData } from '@addons/mod/workshop/services/workshop'; +import { AddonModWorkshopSubmissionAssessmentWithFormData } from '@addons/mod/workshop/services/workshop-helper'; +import { Component, OnInit, Input } from '@angular/core'; +import { CoreSitePluginsCompileInitComponent } from '@features/siteplugins/classes/compile-init-component'; + +/** + * Component that displays a workshop assessment strategy plugin created using a site plugin. + */ +@Component({ + selector: 'core-siteplugins-workshop-assessment-strategy', + templateUrl: 'core-siteplugins-workshop-assessment-strategy.html', +}) +export class CoreSitePluginsWorkshopAssessmentStrategyComponent extends CoreSitePluginsCompileInitComponent implements OnInit { + + @Input() workshopId!: number; + @Input() assessment!: AddonModWorkshopSubmissionAssessmentWithFormData; + @Input() edit!: boolean; + @Input() selectedValues!: AddonModWorkshopGetAssessmentFormFieldsParsedData[]; + @Input() fieldErrors!: Record; + @Input() strategy!: string; + @Input() moduleId!: number; + @Input() courseId?: number; + + /** + * @inheritdoc + */ + ngOnInit(): void { + // Pass the input and output data to the component. + this.jsData.workshopId = this.workshopId; + this.jsData.assessment = this.assessment; + this.jsData.edit = this.edit; + this.jsData.selectedValues = this.selectedValues; + this.jsData.fieldErrors = this.fieldErrors; + this.jsData.strategy = this.strategy; + + this.getHandlerData(AddonWorkshopAssessmentStrategyDelegate.getHandlerName(this.strategy)); + } + +} diff --git a/src/core/features/siteplugins/directives/directives.module.ts b/src/core/features/siteplugins/directives/directives.module.ts index d35d8e0fe..c3fd02a5d 100644 --- a/src/core/features/siteplugins/directives/directives.module.ts +++ b/src/core/features/siteplugins/directives/directives.module.ts @@ -26,7 +26,6 @@ import { CoreSitePluginsNewContentDirective } from './new-content'; CoreSitePluginsCallWSOnLoadDirective, CoreSitePluginsNewContentDirective, ], - imports: [], exports: [ CoreSitePluginsCallWSDirective, CoreSitePluginsCallWSNewContentDirective, diff --git a/src/core/features/siteplugins/services/siteplugins-helper.ts b/src/core/features/siteplugins/services/siteplugins-helper.ts index fd637602c..9acacb577 100644 --- a/src/core/features/siteplugins/services/siteplugins-helper.ts +++ b/src/core/features/siteplugins/services/siteplugins-helper.ts @@ -79,6 +79,8 @@ import { import { makeSingleton } from '@singletons'; import { CoreMainMenuHomeDelegate } from '@features/mainmenu/services/home-delegate'; import { CoreSitePluginsMainMenuHomeHandler } from '../classes/handlers/main-menu-home-handler'; +import { AddonWorkshopAssessmentStrategyDelegate } from '@addons/mod/workshop/services/assessment-strategy-delegate'; +import { CoreSitePluginsWorkshopAssessmentStrategyHandler } from '../classes/handlers/workshop-assessment-strategy-handler'; const HANDLER_DISABLED = 'core_site_plugins_helper_handler_disabled'; @@ -1094,27 +1096,23 @@ export class CoreSitePluginsHelperProvider { * @param handlerName Name of the handler in the plugin. * @param handlerSchema Data about the handler. * @return Promise resolved with a string to identify the handler. - * @todo */ protected registerWorkshopAssessmentStrategyHandler( - plugin: CoreSitePluginsPlugin, // eslint-disable-line @typescript-eslint/no-unused-vars - handlerName: string, // eslint-disable-line @typescript-eslint/no-unused-vars - handlerSchema: CoreSitePluginsHandlerCommonData, // eslint-disable-line @typescript-eslint/no-unused-vars + plugin: CoreSitePluginsPlugin, + handlerName: string, + handlerSchema: CoreSitePluginsHandlerCommonData, ): Promise { - // @todo - return Promise.resolve(''); + return this.registerComponentInitHandler( + plugin, + handlerName, + handlerSchema, + AddonWorkshopAssessmentStrategyDelegate.instance, + (uniqueName) => { + const strategyName = (handlerSchema.moodlecomponent || plugin.component).replace('workshopform_', ''); - // return this.registerComponentInitHandler( - // plugin, - // handlerName, - // handlerSchema, - // this.workshopAssessmentStrategyDelegate, - // (uniqueName, result) => { - // const strategyName = (handlerSchema.moodlecomponent || plugin.component).replace('workshopform_', ''); - - // return new CoreSitePluginsWorkshopAssessmentStrategyHandler(uniqueName, strategyName); - // }, - // ); + return new CoreSitePluginsWorkshopAssessmentStrategyHandler(uniqueName, strategyName); + }, + ); } /** diff --git a/src/core/features/xapi/xapi.module.ts b/src/core/features/xapi/xapi.module.ts index 6ee1d9f8a..6f8e34f6a 100644 --- a/src/core/features/xapi/xapi.module.ts +++ b/src/core/features/xapi/xapi.module.ts @@ -25,7 +25,6 @@ export const CORE_XAPI_SERVICES: Type[] = [ ]; @NgModule({ - imports: [], providers: [ { provide: CORE_SITE_SCHEMAS, diff --git a/src/core/pipes/pipes.module.ts b/src/core/pipes/pipes.module.ts index 36e2e09c7..a45223af7 100644 --- a/src/core/pipes/pipes.module.ts +++ b/src/core/pipes/pipes.module.ts @@ -13,38 +13,38 @@ // limitations under the License. import { NgModule } from '@angular/core'; + +import { CoreBytesToSizePipe } from './bytes-to-size'; import { CoreCreateLinksPipe } from './create-links'; +import { CoreDateDayOrTimePipe } from './date-day-or-time'; +import { CoreDurationPipe } from './duration'; import { CoreFormatDatePipe } from './format-date'; import { CoreNoTagsPipe } from './no-tags'; import { CoreSecondsToHMSPipe } from './seconds-to-hms'; import { CoreTimeAgoPipe } from './time-ago'; -import { CoreBytesToSizePipe } from './bytes-to-size'; -import { CoreDurationPipe } from './duration'; -import { CoreDateDayOrTimePipe } from './date-day-or-time'; import { CoreToLocaleStringPipe } from './to-locale-string'; @NgModule({ declarations: [ - CoreCreateLinksPipe, - CoreNoTagsPipe, - CoreTimeAgoPipe, - CoreFormatDatePipe, CoreBytesToSizePipe, - CoreSecondsToHMSPipe, - CoreDurationPipe, + CoreCreateLinksPipe, CoreDateDayOrTimePipe, + CoreDurationPipe, + CoreFormatDatePipe, + CoreNoTagsPipe, + CoreSecondsToHMSPipe, + CoreTimeAgoPipe, CoreToLocaleStringPipe, ], - imports: [], exports: [ - CoreCreateLinksPipe, - CoreNoTagsPipe, - CoreTimeAgoPipe, - CoreFormatDatePipe, CoreBytesToSizePipe, - CoreSecondsToHMSPipe, - CoreDurationPipe, + CoreCreateLinksPipe, CoreDateDayOrTimePipe, + CoreDurationPipe, + CoreFormatDatePipe, + CoreNoTagsPipe, + CoreSecondsToHMSPipe, + CoreTimeAgoPipe, CoreToLocaleStringPipe, ], }) diff --git a/src/core/services/file-helper.ts b/src/core/services/file-helper.ts index aa1745388..566155087 100644 --- a/src/core/services/file-helper.ts +++ b/src/core/services/file-helper.ts @@ -417,6 +417,22 @@ export class CoreFileHelperProvider { return !!fileTypeExcludeList.match(regEx); } + /** + * Extract filename from the path. + * + * @param file The file. + * @return The file name. + */ + getFilenameFromPath(file: CoreFileEntry): string | undefined { + const path = CoreUtils.isFileEntry(file) ? file.fullPath : file.filepath; + + if (typeof path == 'undefined' || path.length == 0) { + return; + } + + return path.split('\\').pop()?.split('/').pop(); + } + } export const CoreFileHelper = makeSingleton(CoreFileHelperProvider); diff --git a/src/core/services/utils/dom.ts b/src/core/services/utils/dom.ts index 606fb9df5..fe1872f05 100644 --- a/src/core/services/utils/dom.ts +++ b/src/core/services/utils/dom.ts @@ -1390,7 +1390,7 @@ export class CoreDomUtilsProvider { async showErrorModalDefault( error: CoreAnyError, defaultError: string, - needsTranslate?: boolean, + needsTranslate = false, autocloseTime?: number, ): Promise { if (this.isCanceledError(error)) { diff --git a/src/core/services/utils/text.ts b/src/core/services/utils/text.ts index a7f8013ca..aba818639 100644 --- a/src/core/services/utils/text.ts +++ b/src/core/services/utils/text.ts @@ -1097,6 +1097,7 @@ export class CoreTextUtilsProvider { } } +export const CoreTextUtils = makeSingleton(CoreTextUtilsProvider); /** * Options for viewText. @@ -1113,4 +1114,15 @@ export type CoreTextUtilsViewTextOptions = { modalOptions?: Partial; // Modal options. }; -export const CoreTextUtils = makeSingleton(CoreTextUtilsProvider); +/** + * Define text formatting types. + */ +export enum CoreTextFormat { + FORMAT_MOODLE = 0, // Does all sorts of transformations and filtering. + FORMAT_HTML = 1, // Plain HTML (with some tags stripped). Use it by default. + FORMAT_PLAIN = 2, // Plain text (even tags are printed in full). + // FORMAT_WIKI is deprecated since 2005... + FORMAT_MARKDOWN = 4, // Markdown-formatted text http://daringfireball.net/projects/markdown/ +} + +export const defaultTextFormat = CoreTextFormat.FORMAT_HTML; 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; } diff --git a/src/core/services/ws.ts b/src/core/services/ws.ts index 13992fb2a..34543069e 100644 --- a/src/core/services/ws.ts +++ b/src/core/services/ws.ts @@ -232,6 +232,22 @@ export class CoreWSProvider { return new CoreError(message); } + /** + * It will check if response has failed and throw the propper error. + * + * @param response WS response. + * @param defaultMessage Message to be used in case warnings is empty. + */ + throwOnFailedStatus(response: CoreStatusWithWarningsWSResponse, defaultMessage: string): void { + if (!response.status) { + if (response.warnings && response.warnings.length) { + throw new CoreWSError(response.warnings[0]); + } + + throw new CoreError(defaultMessage); + } + } + /** * Downloads a file from Moodle using Cordova File API. * diff --git a/src/core/singletons/form.ts b/src/core/singletons/form.ts index b28d861f5..f3f3d8775 100644 --- a/src/core/singletons/form.ts +++ b/src/core/singletons/form.ts @@ -63,7 +63,7 @@ export class CoreForms { * @param form Form element. * @param siteId The site affected. If not provided, no site affected. */ - static triggerFormCancelledEvent(formRef: ElementRef | HTMLFormElement | undefined, siteId?: string): void { + static triggerFormCancelledEvent(formRef?: ElementRef | HTMLFormElement | undefined, siteId?: string): void { if (!formRef) { return; } @@ -81,7 +81,7 @@ export class CoreForms { * @param online Whether the action was done in offline or not. * @param siteId The site affected. If not provided, no site affected. */ - static triggerFormSubmittedEvent(formRef: ElementRef | HTMLFormElement | undefined, online?: boolean, siteId?: string): void { + static triggerFormSubmittedEvent(formRef?: ElementRef | HTMLFormElement | undefined, online?: boolean, siteId?: string): void { if (!formRef) { return; } diff --git a/src/theme/theme.base.scss b/src/theme/theme.base.scss index 49955b3bf..17809b66b 100644 --- a/src/theme/theme.base.scss +++ b/src/theme/theme.base.scss @@ -275,6 +275,11 @@ ion-toolbar { color: $base; } } + + ion-icon.ion-color-#{$color-name} { + color: $base; + --ion-color-base: #{$base}; + } } // Avatar