From a4225b8c02dd676a7114825467100d0d964ee3b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Wed, 10 Feb 2021 11:36:54 +0100 Subject: [PATCH 01/17] MOBILE-3636 sync: Small improvements on previous sync services --- .../pages/edit-event/edit-event.module.ts | 2 ++ src/addons/calendar/services/calendar-sync.ts | 33 +++++++++---------- src/addons/mod/book/book-lazy.module.ts | 2 +- .../index/addon-mod-lesson-index.html | 2 +- src/addons/mod/lesson/lesson-lazy.module.ts | 2 +- .../mod/lesson/services/handlers/prefetch.ts | 2 +- src/addons/mod/lesson/services/lesson-sync.ts | 22 ++++++------- src/addons/mod/page/page-lazy.module.ts | 2 +- src/core/classes/base-sync.ts | 4 +-- .../features/course/classes/activity-sync.ts | 2 +- src/core/services/sync.ts | 9 +++-- src/core/services/utils/utils.ts | 2 +- 12 files changed, 44 insertions(+), 40 deletions(-) diff --git a/src/addons/calendar/pages/edit-event/edit-event.module.ts b/src/addons/calendar/pages/edit-event/edit-event.module.ts index c9c865932..0acf07633 100644 --- a/src/addons/calendar/pages/edit-event/edit-event.module.ts +++ b/src/addons/calendar/pages/edit-event/edit-event.module.ts @@ -19,11 +19,13 @@ import { CoreSharedModule } from '@/core/shared.module'; import { CoreEditorComponentsModule } from '@features/editor/components/components.module'; import { AddonCalendarEditEventPage } from './edit-event.page'; +import { CanLeaveGuard } from '@guards/can-leave'; const routes: Routes = [ { path: '', component: AddonCalendarEditEventPage, + canDeactivate: [CanLeaveGuard], }, ]; diff --git a/src/addons/calendar/services/calendar-sync.ts b/src/addons/calendar/services/calendar-sync.ts index d897a3d19..eee65e310 100644 --- a/src/addons/calendar/services/calendar-sync.ts +++ b/src/addons/calendar/services/calendar-sync.ts @@ -13,7 +13,7 @@ // limitations under the License. import { Injectable } from '@angular/core'; -import { CoreSyncBaseProvider } from '@classes/base-sync'; +import { CoreSyncBaseProvider, CoreSyncBlockedError } from '@classes/base-sync'; import { CoreApp } from '@services/app'; import { CoreEvents } from '@singletons/events'; import { CoreSites } from '@services/sites'; @@ -27,9 +27,9 @@ import { import { AddonCalendarOffline } from './calendar-offline'; import { AddonCalendarHelper } from './calendar-helper'; import { makeSingleton, Translate } from '@singletons'; -import { CoreError } from '@classes/errors/error'; import { CoreSync } from '@services/sync'; import { CoreTextUtils } from '@services/utils/text'; +import { CoreNetworkError } from '@classes/errors/network-error'; /** * Service to sync calendar. @@ -52,21 +52,23 @@ export class AddonCalendarSyncProvider extends CoreSyncBaseProvider { + async syncAllEvents(siteId?: string, force = false): Promise { await this.syncOnSites('all calendar events', this.syncAllEventsFunc.bind(this, [force]), siteId); } /** * Sync all events on a site. * - * @param siteId Site ID to sync. * @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 syncAllEventsFunc(siteId: string, force?: boolean): Promise { - const result = await (force ? this.syncEvents(siteId) : this.syncEventsIfNeeded(siteId)); + protected async syncAllEventsFunc(force = false, siteId?: string): Promise { + const result = force + ? await this.syncEvents(siteId) + : await this.syncEventsIfNeeded(siteId); - if (result && result.updated) { + if (result?.updated) { // Sync successful, send event. CoreEvents.trigger(AddonCalendarSyncProvider.AUTO_SYNCED, result, siteId); } @@ -78,13 +80,13 @@ export class AddonCalendarSyncProvider extends CoreSyncBaseProvider { + async syncEventsIfNeeded(siteId?: string): Promise { siteId = siteId || CoreSites.instance.getCurrentSiteId(); const needed = await this.isSyncNeeded(AddonCalendarSyncProvider.SYNC_ID, siteId); if (needed) { - await this.syncEvents(siteId); + return this.syncEvents(siteId); } } @@ -125,17 +127,12 @@ export class AddonCalendarSyncProvider extends CoreSyncBaseProvider 0) { if (!CoreApp.instance.isOnline()) { // Cannot sync in offline. - throw new CoreError('Cannot sync while offline'); + throw new CoreNetworkError(); } const promises = eventIds.map((eventId) => this.syncOfflineEvent(eventId, result, siteId)); @@ -175,10 +172,10 @@ export class AddonCalendarSyncProvider extends CoreSyncBaseProvider import('./pages/index/index.module').then( m => m.AddonModBookIndexPageModule), }, ]; diff --git a/src/addons/mod/lesson/components/index/addon-mod-lesson-index.html b/src/addons/mod/lesson/components/index/addon-mod-lesson-index.html index ab0d8373c..4f4f7b741 100644 --- a/src/addons/mod/lesson/components/index/addon-mod-lesson-index.html +++ b/src/addons/mod/lesson/components/index/addon-mod-lesson-index.html @@ -8,7 +8,7 @@ (action)="expandDescription()" iconAction="fas-arrow-right"> + iconAction="far-newspaper" (action)="gotoBlog()"> diff --git a/src/addons/mod/lesson/lesson-lazy.module.ts b/src/addons/mod/lesson/lesson-lazy.module.ts index 41c85cdd7..9670c4132 100644 --- a/src/addons/mod/lesson/lesson-lazy.module.ts +++ b/src/addons/mod/lesson/lesson-lazy.module.ts @@ -17,7 +17,7 @@ import { RouterModule, Routes } from '@angular/router'; const routes: Routes = [ { - path: ':courseId/:cmdId', + path: ':courseId/:cmId', loadChildren: () => import('./pages/index/index.module').then( m => m.AddonModLessonIndexPageModule), }, { diff --git a/src/addons/mod/lesson/services/handlers/prefetch.ts b/src/addons/mod/lesson/services/handlers/prefetch.ts index f81535a66..943a3ab32 100644 --- a/src/addons/mod/lesson/services/handlers/prefetch.ts +++ b/src/addons/mod/lesson/services/handlers/prefetch.ts @@ -257,7 +257,7 @@ export class AddonModLessonPrefetchHandlerService extends CoreCourseActivityPref */ protected async prefetchLesson(module: CoreCourseAnyModuleData, courseId?: number, single?: boolean): Promise { const siteId = CoreSites.instance.getCurrentSiteId(); - courseId = courseId || module.course || 1; + courseId = courseId || module.course || CoreSites.instance.getCurrentSiteHomeId(); const commonOptions = { readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, diff --git a/src/addons/mod/lesson/services/lesson-sync.ts b/src/addons/mod/lesson/services/lesson-sync.ts index 48034d781..ea7c0a544 100644 --- a/src/addons/mod/lesson/services/lesson-sync.ts +++ b/src/addons/mod/lesson/services/lesson-sync.ts @@ -14,7 +14,7 @@ import { Injectable } from '@angular/core'; -import { CoreError } from '@classes/errors/error'; +import { CoreSyncBlockedError } from '@classes/base-sync'; import { CoreNetworkError } from '@classes/errors/network-error'; import { CoreCourseActivitySyncBaseProvider } from '@features/course/classes/activity-sync'; import { CoreCourse } from '@features/course/services/course'; @@ -122,7 +122,7 @@ export class AddonModLessonSyncProvider extends CoreCourseActivitySyncBaseProvid * @param force Wether to force sync not depending on last execution. * @return Promise resolved if sync is successful, rejected if sync fails. */ - syncAllLessons(siteId?: string, force?: boolean): Promise { + syncAllLessons(siteId?: string, force = false): Promise { return this.syncOnSites('all lessons', this.syncAllLessonsFunc.bind(this, !!force), siteId); } @@ -163,7 +163,7 @@ export class AddonModLessonSyncProvider extends CoreCourseActivitySyncBaseProvid */ async syncLessonIfNeeded( lessonId: number, - askPassword?: boolean, + askPassword = false, siteId?: string, ): Promise { const needed = await this.isSyncNeeded(lessonId, siteId); @@ -184,8 +184,8 @@ export class AddonModLessonSyncProvider extends CoreCourseActivitySyncBaseProvid */ async syncLesson( lessonId: number, - askPassword?: boolean, - ignoreBlock?: boolean, + askPassword = false, + ignoreBlock = false, siteId?: string, ): Promise { siteId = siteId || CoreSites.instance.getCurrentSiteId(); @@ -201,7 +201,7 @@ export class AddonModLessonSyncProvider extends CoreCourseActivitySyncBaseProvid if (!ignoreBlock && CoreSync.instance.isBlocked(AddonModLessonProvider.COMPONENT, lessonId, siteId)) { this.logger.debug('Cannot sync lesson ' + lessonId + ' because it is blocked.'); - throw new CoreError(Translate.instance.instant('core.errorsyncblocked', { $a: this.componentTranslate })); + throw new CoreSyncBlockedError(Translate.instance.instant('core.errorsyncblocked', { $a: this.componentTranslate })); } this.logger.debug('Try to sync lesson ' + lessonId + ' in site ' + siteId); @@ -222,8 +222,8 @@ export class AddonModLessonSyncProvider extends CoreCourseActivitySyncBaseProvid */ protected async performSyncLesson( lessonId: number, - askPassword?: boolean, - ignoreBlock?: boolean, + askPassword = false, + ignoreBlock = false, siteId?: string, ): Promise { // Sync offline logs. @@ -270,7 +270,7 @@ export class AddonModLessonSyncProvider extends CoreCourseActivitySyncBaseProvid protected async syncAttempts( lessonId: number, result: AddonModLessonSyncResult, - askPassword?: boolean, + askPassword = false, siteId?: string, ): Promise { let attempts = await AddonModLessonOffline.instance.getLessonAttempts(lessonId, siteId); @@ -408,8 +408,8 @@ export class AddonModLessonSyncProvider extends CoreCourseActivitySyncBaseProvid lessonId: number, result: AddonModLessonSyncResult, passwordData?: AddonModLessonGetPasswordResult, - askPassword?: boolean, - ignoreBlock?: boolean, + askPassword = false, + ignoreBlock = false, siteId?: string, ): Promise { // Attempts sent or there was none. If there is a finished retake, send it. diff --git a/src/addons/mod/page/page-lazy.module.ts b/src/addons/mod/page/page-lazy.module.ts index 759035838..a52103771 100644 --- a/src/addons/mod/page/page-lazy.module.ts +++ b/src/addons/mod/page/page-lazy.module.ts @@ -17,7 +17,7 @@ import { RouterModule, Routes } from '@angular/router'; const routes: Routes = [ { - path: ':courseId/:cmdId', + path: ':courseId/:cmId', loadChildren: () => import('./pages/index/index.module').then( m => m.AddonModPageIndexPageModule), }, ]; diff --git a/src/core/classes/base-sync.ts b/src/core/classes/base-sync.ts index 4f7c1b63f..1eef32e85 100644 --- a/src/core/classes/base-sync.ts +++ b/src/core/classes/base-sync.ts @@ -231,7 +231,7 @@ export class CoreSyncBaseProvider { * @param time Time to set. If not defined, current time. * @return Promise resolved when the time is set. */ - async setSyncTime(id: string, siteId?: string, time?: number): Promise { + async setSyncTime(id: string | number, siteId?: string, time?: number): Promise { time = typeof time != 'undefined' ? time : Date.now(); await CoreSync.instance.insertOrUpdateSyncRecord(this.component, id, { time: time }, siteId); @@ -245,7 +245,7 @@ export class CoreSyncBaseProvider { * @param siteId Site ID. If not defined, current site. * @return Promise resolved when done. */ - async setSyncWarnings(id: string, warnings: string[], siteId?: string): Promise { + async setSyncWarnings(id: string | number, warnings: string[], siteId?: string): Promise { const warningsText = JSON.stringify(warnings || []); await CoreSync.instance.insertOrUpdateSyncRecord(this.component, id, { warnings: warningsText }, siteId); diff --git a/src/core/features/course/classes/activity-sync.ts b/src/core/features/course/classes/activity-sync.ts index f7e97c136..ee5bb8983 100644 --- a/src/core/features/course/classes/activity-sync.ts +++ b/src/core/features/course/classes/activity-sync.ts @@ -20,7 +20,7 @@ import { CoreCourseModulePrefetchHandlerBase } from './module-prefetch-handler'; /** * Base class to create activity sync providers. It provides some common functions. */ -export class CoreCourseActivitySyncBaseProvider extends CoreSyncBaseProvider { +export class CoreCourseActivitySyncBaseProvider extends CoreSyncBaseProvider { /** * Conveniece function to prefetch data after an update. diff --git a/src/core/services/sync.ts b/src/core/services/sync.ts index 84a22e8ef..92b32718b 100644 --- a/src/core/services/sync.ts +++ b/src/core/services/sync.ts @@ -112,11 +112,16 @@ export class CoreSyncProvider { * @param siteId Site ID. If not defined, current site. * @return Promise resolved with done. */ - async insertOrUpdateSyncRecord(component: string, id: string, data: Partial, siteId?: string): Promise { + async insertOrUpdateSyncRecord( + component: string, + id: string | number, + data: Partial, + siteId?: string, + ): Promise { const db = await CoreSites.instance.getSiteDb(siteId); data.component = component; - data.id = id; + data.id = id + ''; await db.insertRecord(SYNC_TABLE_NAME, data); } diff --git a/src/core/services/utils/utils.ts b/src/core/services/utils/utils.ts index d4ccd5df4..bb8ea56a1 100644 --- a/src/core/services/utils/utils.ts +++ b/src/core/services/utils/utils.ts @@ -1607,7 +1607,7 @@ export class CoreUtilsProvider { const result = await promise; return result; - } catch (error) { + } catch { // Ignore errors. return fallback; } From 22395607ae25df9c940aad21db7508be8052d255 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Wed, 10 Feb 2021 11:37:12 +0100 Subject: [PATCH 02/17] MOBILE-3636 grades: Add grade types --- src/assets/img/grades/agg_mean.png | Bin 0 -> 341 bytes src/assets/img/grades/agg_sum.png | Bin 0 -> 318 bytes src/core/features/course/services/course.ts | 42 +++-- .../features/grades/services/grades-helper.ts | 154 +++++++++++------- src/core/features/grades/services/grades.ts | 6 +- 5 files changed, 132 insertions(+), 70 deletions(-) create mode 100644 src/assets/img/grades/agg_mean.png create mode 100644 src/assets/img/grades/agg_sum.png diff --git a/src/assets/img/grades/agg_mean.png b/src/assets/img/grades/agg_mean.png new file mode 100644 index 0000000000000000000000000000000000000000..78c8046dfe8fa156481cf5a070e7d5e5ca2a93b8 GIT binary patch literal 341 zcmV-b0jmCqP)ZJ#p+^L_rUG(O3;)72rT;{I|pm#r01B z$#fMxTKrPl%HrNZ$BKAXnL^24DLPMm(3>#>aPI7L{=_!*L(cyKGD(nj`r%-PmY5m{way%t545g_5-Nd+lb8=OxtU>+Y$Yh5A}ux6I~z@vKOhCz%YQzDOp zukeC@^$k~9kL+NQe8AXum+snFooliNQbYnhE}7*nm$&~0EShO46AMm+CEvB^^CcY;Y7g>@d+l<4p*B@ z { + async getModuleBasicGradeInfo(moduleId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + if (!site || !site.isVersionGreaterEqualThan('3.2')) { + // On 3.1 won't get grading info and will return undefined. See check bellow. + return; + } + const info = await this.getModuleBasicInfo(moduleId, siteId); const grade: CoreCourseModuleGradeInfo = { @@ -539,10 +546,11 @@ export class CoreCourseProvider { typeof grade.advancedgrading != 'undefined' || typeof grade.outcomes != 'undefined' ) { + // On 3.1 won't get grading info and will return undefined. return grade; } - return false; + } /** @@ -1461,22 +1469,32 @@ export type CoreCourseModuleContentFile = { }; /** - * Course module basic info type. + * Course module basic info type. 3.2 onwards. */ export type CoreCourseModuleGradeInfo = { grade?: number; // Grade (max value or scale id). scale?: string; // Scale items (if used). gradepass?: string; // Grade to pass (float). gradecat?: number; // Grade category. - advancedgrading?: { // Advanced grading settings. - area: string; // Gradable area name. - method: string; // Grading method. - }[]; - outcomes?: { // Outcomes information. - id: string; // Outcome id. - name: string; // Outcome full name. - scale: string; // Scale items. - }[]; + advancedgrading?: CoreCourseModuleAdvancedGradingSetting[]; // Advanced grading settings. + outcomes?: CoreCourseModuleGradeOutcome[]; +}; + +/** + * Advanced grading settings. + */ +export type CoreCourseModuleAdvancedGradingSetting = { + area: string; // Gradable area name. + method: string; // Grading method. +}; + +/** + * Grade outcome information. + */ +export type CoreCourseModuleGradeOutcome = { + id: string; // Outcome id. + name: string; // Outcome full name. + scale: string; // Scale items. }; /** diff --git a/src/core/features/grades/services/grades-helper.ts b/src/core/features/grades/services/grades-helper.ts index 8a27e791a..6958f064c 100644 --- a/src/core/features/grades/services/grades-helper.ts +++ b/src/core/features/grades/services/grades-helper.ts @@ -31,6 +31,7 @@ import { CoreMenuItem, CoreUtils } from '@services/utils/utils'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreNavigator } from '@services/navigator'; import { makeSingleton } from '@singletons'; +import { CoreError } from '@classes/errors/error'; /** * Service that provides some features regarding grades information. @@ -51,16 +52,18 @@ export class CoreGradesHelperProvider { * @return Formatted row object. */ protected formatGradeRow(tableRow: CoreGradesTableRow): CoreGradesFormattedRow { - const row = {}; + const row: CoreGradesFormattedRow = { + rowclass: '', + }; for (const name in tableRow) { if (typeof tableRow[name].content != 'undefined' && tableRow[name].content !== null) { let content = String(tableRow[name].content); if (name == 'itemname') { this.setRowIcon(row, content); - row['link'] = this.getModuleLink(content); - row['rowclass'] += tableRow[name]!.class.indexOf('hidden') >= 0 ? ' hidden' : ''; - row['rowclass'] += tableRow[name]!.class.indexOf('dimmed_text') >= 0 ? ' dimmed_text' : ''; + row.link = this.getModuleLink(content); + row.rowclass += tableRow[name]!.class.indexOf('hidden') >= 0 ? ' hidden' : ''; + row.rowclass += tableRow[name]!.class.indexOf('dimmed_text') >= 0 ? ' dimmed_text' : ''; content = content.replace(/<\/span>/gi, '\n'); content = CoreTextUtils.instance.cleanTags(content); @@ -86,20 +89,20 @@ export class CoreGradesHelperProvider { * @return Formatted row object. */ protected formatGradeRowForTable(tableRow: CoreGradesTableRow): CoreGradesFormattedRowForTable { - const row = {}; + const row: CoreGradesFormattedRowForTable = {}; for (let name in tableRow) { if (typeof tableRow[name].content != 'undefined' && tableRow[name].content !== null) { let content = String(tableRow[name].content); if (name == 'itemname') { - row['id'] = parseInt(tableRow[name]!.id.split('_')[1], 10); - row['colspan'] = tableRow[name]!.colspan; - row['rowspan'] = (tableRow['leader'] && tableRow['leader'].rowspan) || 1; + row.id = parseInt(tableRow[name]!.id.split('_')[1], 10); + row.colspan = tableRow[name]!.colspan; + row.rowspan = (tableRow.leader && tableRow.leader.rowspan) || 1; this.setRowIcon(row, content); - row['rowclass'] = tableRow[name]!.class.indexOf('leveleven') < 0 ? 'odd' : 'even'; - row['rowclass'] += tableRow[name]!.class.indexOf('hidden') >= 0 ? ' hidden' : ''; - row['rowclass'] += tableRow[name]!.class.indexOf('dimmed_text') >= 0 ? ' dimmed_text' : ''; + row.rowclass = tableRow[name]!.class.indexOf('leveleven') < 0 ? 'odd' : 'even'; + row.rowclass += tableRow[name]!.class.indexOf('hidden') >= 0 ? ' hidden' : ''; + row.rowclass += tableRow[name]!.class.indexOf('dimmed_text') >= 0 ? ' dimmed_text' : ''; content = content.replace(/<\/span>/gi, '\n'); content = CoreTextUtils.instance.cleanTags(content); @@ -202,14 +205,14 @@ export class CoreGradesHelperProvider { */ async getGradesCourseData(grades: CoreGradesGradeOverview[]): Promise { // Obtain courses from cache to prevent network requests. - let coursesWereMissing; + let coursesWereMissing = false; try { const courses = await CoreCourses.instance.getUserCourses(undefined, undefined, CoreSitesReadingStrategy.OnlyCache); const coursesMap = CoreUtils.instance.arrayToObject(courses, 'id'); coursesWereMissing = this.addCourseData(grades, coursesMap); - } catch (error) { + } catch { coursesWereMissing = true; } @@ -278,7 +281,7 @@ export class CoreGradesHelperProvider { const grades = await CoreGrades.instance.getCourseGradesTable(courseId, userId, siteId, ignoreCache); if (!grades) { - throw new Error('Couldn\'t get grade item'); + throw new CoreError('Couldn\'t get grade item'); } return this.getGradesTableRow(grades, gradeId); @@ -325,15 +328,15 @@ export class CoreGradesHelperProvider { groupId?: number, siteId?: string, ignoreCache: boolean = false, - ): Promise { + ): Promise { const grades = await CoreGrades.instance.getGradeItems(courseId, userId, groupId, siteId, ignoreCache); if (!grades) { - throw new Error('Couldn\'t get grade module items'); + throw new CoreError('Couldn\'t get grade module items'); } if ('tabledata' in grades) { - // Table format. + // 3.1 Table format. return this.getModuleGradesTableRows(grades, moduleId); } @@ -347,18 +350,16 @@ export class CoreGradesHelperProvider { * @param selectedGrade Selected grade label. * @return Selected grade value. */ - getGradeValueFromLabel(grades: CoreMenuItem[], selectedGrade: string): number { + getGradeValueFromLabel(grades: CoreMenuItem[], selectedGrade?: string): number { if (!grades || !selectedGrade) { return 0; } - for (const x in grades) { - if (grades[x].label == selectedGrade) { - return grades[x].value < 0 ? 0 : grades[x].value; - } - } + const grade = grades.find((grade) => grade.label == selectedGrade); - return 0; + return !grade || grade.value < 0 + ? 0 + : grade.value; } /** @@ -457,15 +458,15 @@ export class CoreGradesHelperProvider { siteId = site.id; currentUserId = site.getUserId(); - if (moduleId) { - // Try to open the module grade directly. Check if it's possible. - const grades = await CoreGrades.instance.isGradeItemsAvalaible(siteId); + if (!moduleId) { + throw new CoreError('Invalid moduleId'); + } - if (!grades) { - throw new Error(); - } - } else { - throw new Error(); + // Try to open the module grade directly. Check if it's possible. + const grades = await CoreGrades.instance.isGradeItemsAvalaible(siteId); + + if (!grades) { + throw new CoreError('No grades found.'); } try { @@ -476,7 +477,7 @@ export class CoreGradesHelperProvider { const item = Array.isArray(items) && items.find((item) => moduleId == item.cmid); if (!item) { - throw new Error(); + throw new CoreError('Grade item not found.'); } // Open the item directly. @@ -560,46 +561,49 @@ export class CoreGradesHelperProvider { * @param text HTML where the image will be rendered. * @return Row object with the image. */ - protected setRowIcon(row: CoreGradesFormattedRowForTable, text: string): CoreGradesFormattedRowForTable { + protected setRowIcon( + row: CoreGradesFormattedRowForTable | CoreGradesFormattedRow, + text: string, + ): CoreGradesFormattedRowForTable { text = text.replace('%2F', '/').replace('%2f', '/'); if (text.indexOf('/agg_mean') > -1) { - row['itemtype'] = 'agg_mean'; - row['image'] = 'assets/img/grades/agg_mean.png'; + row.itemtype = 'agg_mean'; + row.image = 'assets/img/grades/agg_mean.png'; } else if (text.indexOf('/agg_sum') > -1) { - row['itemtype'] = 'agg_sum'; - row['image'] = 'assets/img/grades/agg_sum.png'; + row.itemtype = 'agg_sum'; + row.image = 'assets/img/grades/agg_sum.png'; } else if (text.indexOf('/outcomes') > -1 || text.indexOf('fa-tasks') > -1) { - row['itemtype'] = 'outcome'; - row['icon'] = 'fa-tasks'; + row.itemtype = 'outcome'; + row.icon = 'fas-chart-pie'; } else if (text.indexOf('i/folder') > -1 || text.indexOf('fa-folder') > -1) { - row['itemtype'] = 'category'; - row['icon'] = 'fa-folder'; + row.itemtype = 'category'; + row.icon = 'fas-cubes'; } else if (text.indexOf('/manual_item') > -1 || text.indexOf('fa-square-o') > -1) { - row['itemtype'] = 'manual'; - row['icon'] = 'fa-square-o'; + row.itemtype = 'manual'; + row.icon = 'far-square'; } else if (text.indexOf('/mod/') > -1) { const module = text.match(/mod\/([^/]*)\//); if (typeof module?.[1] != 'undefined') { - row['itemtype'] = 'mod'; - row['itemmodule'] = module[1]; - row['image'] = CoreCourse.instance.getModuleIconSrc( + row.itemtype = 'mod'; + row.itemmodule = module[1]; + row.image = CoreCourse.instance.getModuleIconSrc( module[1], CoreDomUtils.instance.convertToElement(text).querySelector('img')?.getAttribute('src') ?? undefined, ); } } else { - if (row['rowspan'] && row['rowspan'] > 1) { - row['itemtype'] = 'category'; - row['icon'] = 'fa-folder'; + if (row.rowspan && row.rowspan > 1) { + row.itemtype = 'category'; + row.icon = 'fas-cubes'; } else if (text.indexOf('src=') > -1) { - row['itemtype'] = 'unknown'; + row.itemtype = 'unknown'; const src = text.match(/src="([^"]*)"/); - row['image'] = src?.[1]; + row.image = src?.[1]; } else if (text.indexOf(' -1) { - row['itemtype'] = 'unknown'; + row.itemtype = 'unknown'; const src = text.match(/ { - return CoreSites.instance.getSite(siteId).then((site) => site.wsAvailable('gradereport_user_get_grade_items')); + async isGradeItemsAvalaible(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return site.wsAvailable('gradereport_user_get_grade_items'); } /** From b0cf681ab667a00f2d488f781a52278f0f1d630e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Fri, 19 Feb 2021 12:41:22 +0100 Subject: [PATCH 03/17] MOBILE-3636 core: Check route parent params on navigator --- src/core/services/navigator.ts | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/src/core/services/navigator.ts b/src/core/services/navigator.ts index 2e795d519..683a366a9 100644 --- a/src/core/services/navigator.ts +++ b/src/core/services/navigator.ts @@ -243,12 +243,34 @@ export class CoreNavigatorService { return CoreUrlUtils.instance.removeUrlParams(this.previousPath || ''); } + /** + * Iterately get the params checking parent routes. + * + * @param route Current route. + * @param name Name of the parameter. + * @return Value of the parameter, undefined if not found. + */ + protected getRouteSnapshotParam(name: string, route?: ActivatedRoute): T | undefined { + if (!route?.snapshot) { + return; + } + + const value = route.snapshot.queryParams[name] ?? route.snapshot.params[name]; + + if (typeof value != 'undefined') { + return value; + } + + return this.getRouteSnapshotParam(name, route.parent || undefined); + } + /** * Get a parameter for the current route. * Please notice that objects can only be retrieved once. You must call this function only once per page and parameter, * unless there's a new navigation to the page. * * @param name Name of the parameter. + * @param params Optional params to get the value from. If missing, it will autodetect. * @return Value of the parameter, undefined if not found. */ getRouteParam(name: string, params?: Params): T | undefined { @@ -256,15 +278,16 @@ export class CoreNavigatorService { if (!params) { const route = this.getCurrentRoute(); - if (!route.snapshot) { - return; - } - value = route.snapshot.queryParams[name] ?? route.snapshot.params[name]; + value = this.getRouteSnapshotParam(name, route); } else { value = params[name]; } + if (typeof value == 'undefined') { + return; + } + let storedParam = this.storedParams[value]; // Remove the parameter from our map if it's in there. @@ -286,6 +309,7 @@ export class CoreNavigatorService { * Angular router automatically converts numbers to string, this function automatically converts it back to number. * * @param name Name of the parameter. + * @param params Optional params to get the value from. If missing, it will autodetect. * @return Value of the parameter, undefined if not found. */ getRouteNumberParam(name: string, params?: Params): number | undefined { @@ -299,6 +323,7 @@ export class CoreNavigatorService { * Angular router automatically converts booleans to string, this function automatically converts it back to boolean. * * @param name Name of the parameter. + * @param params Optional params to get the value from. If missing, it will autodetect. * @return Value of the parameter, undefined if not found. */ getRouteBooleanParam(name: string, params?: Params): boolean | undefined { From a4eefeb25a67b59f539a3460b17d30069ff57eb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Tue, 9 Feb 2021 15:06:40 +0100 Subject: [PATCH 04/17] MOBILE-3636 assign: Implement assignment base --- src/addons/mod/assign/assign-lazy.module.ts | 42 + src/addons/mod/assign/assign.module.ts | 66 + .../assign/components/components.module.ts | 47 + .../index/addon-mod-assign-index.html | 142 ++ .../mod/assign/components/index/index.ts | 414 ++++ src/addons/mod/assign/lang.json | 104 + src/addons/mod/assign/pages/index/index.html | 22 + .../mod/assign/pages/index/index.page.ts | 68 + .../mod/assign/services/assign-helper.ts | 727 +++++++ .../mod/assign/services/assign-offline.ts | 459 ++++ src/addons/mod/assign/services/assign-sync.ts | 572 +++++ src/addons/mod/assign/services/assign.ts | 1855 +++++++++++++++++ .../mod/assign/services/database/assign.ts | 150 ++ .../mod/assign/services/feedback-delegate.ts | 374 ++++ .../services/handlers/default-feedback.ts | 146 ++ .../services/handlers/default-submission.ts | 210 ++ .../assign/services/handlers/index-link.ts | 32 + .../mod/assign/services/handlers/list-link.ts | 32 + .../mod/assign/services/handlers/module.ts | 94 + .../mod/assign/services/handlers/prefetch.ts | 531 +++++ .../assign/services/handlers/push-click.ts | 66 + .../mod/assign/services/handlers/sync-cron.ts | 50 + .../assign/services/submission-delegate.ts | 565 +++++ src/addons/mod/mod.module.ts | 2 + src/core/services/groups.ts | 2 +- src/core/services/navigator.ts | 7 +- 26 files changed, 6777 insertions(+), 2 deletions(-) create mode 100644 src/addons/mod/assign/assign-lazy.module.ts create mode 100644 src/addons/mod/assign/assign.module.ts create mode 100644 src/addons/mod/assign/components/components.module.ts create mode 100644 src/addons/mod/assign/components/index/addon-mod-assign-index.html create mode 100644 src/addons/mod/assign/components/index/index.ts create mode 100644 src/addons/mod/assign/lang.json create mode 100644 src/addons/mod/assign/pages/index/index.html create mode 100644 src/addons/mod/assign/pages/index/index.page.ts create mode 100644 src/addons/mod/assign/services/assign-helper.ts create mode 100644 src/addons/mod/assign/services/assign-offline.ts create mode 100644 src/addons/mod/assign/services/assign-sync.ts create mode 100644 src/addons/mod/assign/services/assign.ts create mode 100644 src/addons/mod/assign/services/database/assign.ts create mode 100644 src/addons/mod/assign/services/feedback-delegate.ts create mode 100644 src/addons/mod/assign/services/handlers/default-feedback.ts create mode 100644 src/addons/mod/assign/services/handlers/default-submission.ts create mode 100644 src/addons/mod/assign/services/handlers/index-link.ts create mode 100644 src/addons/mod/assign/services/handlers/list-link.ts create mode 100644 src/addons/mod/assign/services/handlers/module.ts create mode 100644 src/addons/mod/assign/services/handlers/prefetch.ts create mode 100644 src/addons/mod/assign/services/handlers/push-click.ts create mode 100644 src/addons/mod/assign/services/handlers/sync-cron.ts create mode 100644 src/addons/mod/assign/services/submission-delegate.ts diff --git a/src/addons/mod/assign/assign-lazy.module.ts b/src/addons/mod/assign/assign-lazy.module.ts new file mode 100644 index 000000000..1107997d6 --- /dev/null +++ b/src/addons/mod/assign/assign-lazy.module.ts @@ -0,0 +1,42 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreSharedModule } from '@/core/shared.module'; +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { AddonModAssignComponentsModule } from './components/components.module'; +import { AddonModAssignIndexPage } from './pages/index/index.page'; + +const routes: Routes = [ + { + path: ':courseId/:cmId', + component: AddonModAssignIndexPage, + }, + { + path: ':courseId/:cmId/submission-list', + component: AddonModAssignSubmissionListPage, + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CoreSharedModule, + AddonModAssignComponentsModule, + ], + declarations: [ + AddonModAssignIndexPage, + ], +}) +export class AddonModAssignLazyModule {} diff --git a/src/addons/mod/assign/assign.module.ts b/src/addons/mod/assign/assign.module.ts new file mode 100644 index 000000000..1a7565342 --- /dev/null +++ b/src/addons/mod/assign/assign.module.ts @@ -0,0 +1,66 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { APP_INITIALIZER, NgModule } from '@angular/core'; +import { Routes } from '@angular/router'; +import { CoreContentLinksDelegate } from '@features/contentlinks/services/contentlinks-delegate'; +import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate'; +import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate'; +import { CoreMainMenuTabRoutingModule } from '@features/mainmenu/mainmenu-tab-routing.module'; +import { CorePushNotificationsDelegate } from '@features/pushnotifications/services/push-delegate'; +import { CoreCronDelegate } from '@services/cron'; +import { CORE_SITE_SCHEMAS } from '@services/sites'; +import { AddonModAssignComponentsModule } from './components/components.module'; +import { OFFLINE_SITE_SCHEMA } from './services/database/assign'; +import { AddonModAssignIndexLinkHandler } from './services/handlers/index-link'; +import { AddonModAssignListLinkHandler } from './services/handlers/list-link'; +import { AddonModAssignModuleHandler, AddonModAssignModuleHandlerService } from './services/handlers/module'; +import { AddonModAssignPrefetchHandler } from './services/handlers/prefetch'; +import { AddonModAssignPushClickHandler } from './services/handlers/push-click'; +import { AddonModAssignSyncCronHandler } from './services/handlers/sync-cron'; + +const routes: Routes = [ + { + path: AddonModAssignModuleHandlerService.PAGE_NAME, + loadChildren: () => import('./assign-lazy.module').then(m => m.AddonModAssignLazyModule), + }, +]; + +@NgModule({ + imports: [ + CoreMainMenuTabRoutingModule.forChild(routes), + AddonModAssignComponentsModule, + ], + providers: [ + { + provide: CORE_SITE_SCHEMAS, + useValue: [OFFLINE_SITE_SCHEMA], + multi: true, + }, + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + CoreCourseModuleDelegate.instance.registerHandler(AddonModAssignModuleHandler.instance); + CoreContentLinksDelegate.instance.registerHandler(AddonModAssignIndexLinkHandler.instance); + CoreContentLinksDelegate.instance.registerHandler(AddonModAssignListLinkHandler.instance); + CoreCourseModulePrefetchDelegate.instance.registerHandler(AddonModAssignPrefetchHandler.instance); + CoreCronDelegate.instance.register(AddonModAssignSyncCronHandler.instance); + CorePushNotificationsDelegate.instance.registerClickHandler(AddonModAssignPushClickHandler.instance); + }, + }, + ], +}) +export class AddonModAssignModule {} diff --git a/src/addons/mod/assign/components/components.module.ts b/src/addons/mod/assign/components/components.module.ts new file mode 100644 index 000000000..bdc46ebfb --- /dev/null +++ b/src/addons/mod/assign/components/components.module.ts @@ -0,0 +1,47 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CoreSharedModule } from '@/core/shared.module'; +import { CoreCourseComponentsModule } from '@features/course/components/components.module'; +import { AddonModAssignIndexComponent } from './index/index'; + +@NgModule({ + declarations: [ + AddonModAssignIndexComponent, + /* AddonModAssignSubmissionComponent, + AddonModAssignSubmissionPluginComponent, + AddonModAssignFeedbackPluginComponent*/ + ], + imports: [ + CommonModule, + IonicModule, + TranslateModule.forChild(), + FormsModule, + CoreSharedModule, + CoreCourseComponentsModule, + ], + exports: [ + AddonModAssignIndexComponent, + /* AddonModAssignSubmissionComponent, + AddonModAssignSubmissionPluginComponent, + AddonModAssignFeedbackPluginComponent */ + ], +}) +export class AddonModAssignComponentsModule {} diff --git a/src/addons/mod/assign/components/index/addon-mod-assign-index.html b/src/addons/mod/assign/components/index/addon-mod-assign-index.html new file mode 100644 index 000000000..67c2169e9 --- /dev/null +++ b/src/addons/mod/assign/components/index/addon-mod-assign-index.html @@ -0,0 +1,142 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ 'core.hasdatatosync' | translate: {$a: moduleName} }} + + + + + + + + + {{'core.groupsseparate' | translate }} + {{'core.groupsvisible' | translate }} + + + + {{groupOpt.name}} + + + + + + +

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

+

{{ timeRemaining }}

+
+
+ + +

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

+

{{ lateSubmissions }}

+
+
+ + + + +

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

+

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

+
+ + {{ summary.participantcount }} + +
+ + + +

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

+ + {{ summary.submissiondraftscount }} + +
+ + + +

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

+ + {{ summary.submissionssubmittedcount }} + +
+ + + +

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

+ + {{ summary.submissionsneedgradingcount }} + +
+
+ + + + + + {{ 'addon.mod_assign.'+summary.warnofungroupedusers | translate }} + + +
+ + + + +
diff --git a/src/addons/mod/assign/components/index/index.ts b/src/addons/mod/assign/components/index/index.ts new file mode 100644 index 000000000..d62267888 --- /dev/null +++ b/src/addons/mod/assign/components/index/index.ts @@ -0,0 +1,414 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Optional, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { Params } from '@angular/router'; +import { CoreSite } from '@classes/site'; +import { CoreCourseModuleMainActivityComponent } from '@features/course/classes/main-activity-component'; +import { CoreCourseContentsPage } from '@features/course/pages/contents/contents'; +import { CoreCourse } from '@features/course/services/course'; +import { IonContent } from '@ionic/angular'; +import { CoreGroupInfo, CoreGroups } from '@services/groups'; +import { CoreNavigator } from '@services/navigator'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreUtils } from '@services/utils/utils'; +import { Translate } from '@singletons'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { + AddonModAssign, + AddonModAssignAssign, + AddonModAssignGradedEventData, + AddonModAssignProvider, + AddonModAssignSubmissionGradingSummary, +} from '../../services/assign'; +import { AddonModAssignOffline } from '../../services/assign-offline'; +import { + AddonModAssignAutoSyncData, + AddonModAssignSync, + AddonModAssignSyncProvider, + AddonModAssignSyncResult, +} from '../../services/assign-sync'; + +/** + * Component that displays an assignment. + */ +@Component({ + selector: 'addon-mod-assign-index', + templateUrl: 'addon-mod-assign-index.html', +}) +export class AddonModAssignIndexComponent extends CoreCourseModuleMainActivityComponent implements OnInit, OnDestroy { + + // @todo @ViewChild(AddonModAssignSubmissionComponent) submissionComponent?: AddonModAssignSubmissionComponent; + submissionComponent?: any; + + component = AddonModAssignProvider.COMPONENT; + moduleName = 'assign'; + + assign?: AddonModAssignAssign; // The assign object. + canViewAllSubmissions = false; // Whether the user can view all submissions. + canViewOwnSubmission = false; // Whether the user can view their own submission. + timeRemaining?: string; // Message about time remaining to submit. + lateSubmissions?: string; // Message about late submissions. + showNumbers = true; // Whether to show number of submissions with each status. + summary?: AddonModAssignSubmissionGradingSummary; // The grading summary. + needsGradingAvalaible = false; // Whether we can see the submissions that need grading. + + groupInfo: CoreGroupInfo = { + groups: [], + separateGroups: false, + visibleGroups: false, + defaultGroupId: 0, + }; + + // Status. + submissionStatusSubmitted = AddonModAssignProvider.SUBMISSION_STATUS_SUBMITTED; + submissionStatusDraft = AddonModAssignProvider.SUBMISSION_STATUS_DRAFT; + needGrading = AddonModAssignProvider.NEED_GRADING; + + protected currentUserId?: number; // Current user ID. + protected currentSite?: CoreSite; // Current user ID. + protected syncEventName = AddonModAssignSyncProvider.AUTO_SYNCED; + + // Observers. + protected savedObserver?: CoreEventObserver; + protected submittedObserver?: CoreEventObserver; + protected gradedObserver?: CoreEventObserver; + + constructor( + protected content?: IonContent, + @Optional() courseContentsPage?: CoreCourseContentsPage, + ) { + super('AddonModLessonIndexComponent', content, courseContentsPage); + } + + /** + * Component being initialized. + */ + async ngOnInit(): Promise { + super.ngOnInit(); + + this.currentUserId = CoreSites.instance.getCurrentSiteUserId(); + this.currentSite = CoreSites.instance.getCurrentSite(); + + // Listen to events. + this.savedObserver = CoreEvents.on(AddonModAssignProvider.SUBMISSION_SAVED_EVENT, (data) => { + if (this.assign && data.assignmentId == this.assign.id && data.userId == this.currentUserId) { + // Assignment submission saved, refresh data. + this.showLoadingAndRefresh(true, false); + } + }, this.siteId); + + this.submittedObserver = CoreEvents.on(AddonModAssignProvider.SUBMITTED_FOR_GRADING_EVENT, (data) => { + if (this.assign && data.assignmentId == this.assign.id && data.userId == this.currentUserId) { + // Assignment submitted, check completion. + CoreCourse.instance.checkModuleCompletion(this.courseId!, this.module!.completiondata); + + // Reload data since it can have offline data now. + this.showLoadingAndRefresh(true, false); + } + }, this.siteId); + + this.gradedObserver = CoreEvents.on(AddonModAssignProvider.GRADED_EVENT, (data) => { + if (this.assign && data.assignmentId == this.assign.id && data.userId == this.currentUserId) { + // Assignment graded, refresh data. + this.showLoadingAndRefresh(true, false); + } + }, this.siteId); + + await this.loadContent(false, true); + + try { + await AddonModAssign.instance.logView(this.assign!.id, this.assign!.name); + CoreCourse.instance.checkModuleCompletion(this.courseId!, this.module!.completiondata); + } catch { + // Ignore errors. Just don't check Module completion. + } + + if (this.canViewAllSubmissions) { + // User can see all submissions, log grading view. + CoreUtils.instance.ignoreErrors(AddonModAssign.instance.logGradingView(this.assign!.id, this.assign!.name)); + } else if (this.canViewOwnSubmission) { + // User can only see their own submission, log view the user submission. + CoreUtils.instance.ignoreErrors(AddonModAssign.instance.logSubmissionView(this.assign!.id, this.assign!.name)); + } + } + + /** + * Expand the description. + */ + expandDescription(ev?: Event): void { + ev?.preventDefault(); + ev?.stopPropagation(); + + if (this.assign && (this.description || this.assign.introattachments)) { + CoreTextUtils.instance.viewText(Translate.instance.instant('core.description'), this.description || '', { + component: this.component, + componentId: this.module!.id, + files: this.assign.introattachments, + filter: true, + contextLevel: 'module', + instanceId: this.module!.id, + courseId: this.courseId, + }); + } + } + + /** + * Get assignment data. + * + * @param refresh If it's refreshing content. + * @param sync If it should try to sync. + * @param showErrors If show errors to the user of hide them. + * @return Promise resolved when done. + */ + protected async fetchContent(refresh = false, sync = false, showErrors = false): Promise { + + // Get assignment data. + try { + this.assign = await AddonModAssign.instance.getAssignment(this.courseId!, this.module!.id); + + this.dataRetrieved.emit(this.assign); + this.description = this.assign.intro; + + if (sync) { + // Try to synchronize the assign. + await CoreUtils.instance.ignoreErrors(this.syncActivity(showErrors)); + } + + // Check if there's any offline data for this assign. + this.hasOffline = await AddonModAssignOffline.instance.hasAssignOfflineData(this.assign.id); + + // Get assignment submissions. + const submissions = await AddonModAssign.instance.getSubmissions(this.assign.id, { cmId: this.module!.id }); + const time = CoreTimeUtils.instance.timestamp(); + + this.canViewAllSubmissions = submissions.canviewsubmissions; + + if (submissions.canviewsubmissions) { + + // Calculate the messages to display about time remaining and late submissions. + if (this.assign.duedate > 0) { + if (this.assign.duedate - time <= 0) { + this.timeRemaining = Translate.instance.instant('addon.mod_assign.assignmentisdue'); + } else { + this.timeRemaining = CoreTimeUtils.instance.formatDuration(this.assign.duedate - time, 3); + + if (this.assign.cutoffdate) { + if (this.assign.cutoffdate > time) { + this.lateSubmissions = Translate.instance.instant( + 'addon.mod_assign.latesubmissionsaccepted', + { $a: CoreTimeUtils.instance.userDate(this.assign.cutoffdate * 1000) }, + ); + } else { + this.lateSubmissions = Translate.instance.instant('addon.mod_assign.nomoresubmissionsaccepted'); + } + } else { + this.lateSubmissions = ''; + } + } + } else { + this.timeRemaining = ''; + this.lateSubmissions = ''; + } + + // Check if groupmode is enabled to avoid showing wrong numbers. + this.groupInfo = await CoreGroups.instance.getActivityGroupInfo(this.assign.cmid, false); + this.showNumbers = (this.groupInfo.groups && this.groupInfo.groups.length == 0) || + this.currentSite!.isVersionGreaterEqualThan('3.5'); + + await this.setGroup(CoreGroups.instance.validateGroupId(this.group, this.groupInfo)); + + return; + } + + try { + // Check if the user can view their own submission. + await AddonModAssign.instance.getSubmissionStatus(this.assign.id, { cmId: this.module!.id }); + this.canViewOwnSubmission = true; + } catch (error) { + this.canViewOwnSubmission = false; + + if (error.errorcode !== 'nopermission') { + throw error; + } + } + } finally { + this.fillContextMenu(refresh); + } + } + + /** + * Set group to see the summary. + * + * @param groupId Group ID. + * @return Resolved when done. + */ + async setGroup(groupId: number): Promise { + this.group = groupId; + + const submissionStatus = await AddonModAssign.instance.getSubmissionStatus(this.assign!.id, { + groupId: this.group, + cmId: this.module!.id, + }); + + + this.summary = submissionStatus.gradingsummary; + if (!this.summary) { + this.needsGradingAvalaible = false; + + return; + } + + if (this.summary?.warnofungroupedusers === true) { + this.summary.warnofungroupedusers = 'ungroupedusers'; + } else { + switch (this.summary?.warnofungroupedusers) { + case AddonModAssignProvider.WARN_GROUPS_REQUIRED: + this.summary.warnofungroupedusers = 'ungroupedusers'; + break; + case AddonModAssignProvider.WARN_GROUPS_OPTIONAL: + this.summary.warnofungroupedusers = 'ungroupedusersoptional'; + break; + default: + this.summary.warnofungroupedusers = ''; + break; + } + } + + this.needsGradingAvalaible = + (submissionStatus.gradingsummary?.submissionsneedgradingcount || 0) > 0 && + this.currentSite!.isVersionGreaterEqualThan('3.2'); + } + + /** + * Go to view a list of submissions. + * + * @param status Status to see. + * @param count Number of submissions with the status. + */ + goToSubmissionList(status: string, count: number): void { + if (typeof status != 'undefined' && !count && this.showNumbers) { + return; + } + + const params: Params = { + groupId: this.group || 0, + moduleName: this.moduleName, + }; + if (typeof status != 'undefined') { + params.status = status; + } + CoreNavigator.instance.navigate('submission-list', { + params, + }); + } + + /** + * Checks if sync has succeed from result sync data. + * + * @param result Data returned by the sync function. + * @return If succeed or not. + */ + protected hasSyncSucceed(result: AddonModAssignSyncResult): boolean { + if (result.updated) { + this.submissionComponent?.invalidateAndRefresh(false); + } + + return result.updated; + } + + /** + * Perform the invalidate content function. + * + * @return Resolved when done. + */ + protected async invalidateContent(): Promise { + const promises: Promise[] = []; + + promises.push(AddonModAssign.instance.invalidateAssignmentData(this.courseId!)); + + if (this.assign) { + promises.push(AddonModAssign.instance.invalidateAllSubmissionData(this.assign.id)); + + if (this.canViewAllSubmissions) { + promises.push(AddonModAssign.instance.invalidateSubmissionStatusData(this.assign.id, undefined, this.group)); + } + } + + await Promise.all(promises).finally(() => { + this.submissionComponent?.invalidateAndRefresh(true); + }); + } + + /** + * User entered the page that contains the component. + */ + ionViewDidEnter(): void { + super.ionViewDidEnter(); + + this.submissionComponent?.ionViewDidEnter(); + } + + /** + * User left the page that contains the component. + */ + ionViewDidLeave(): void { + super.ionViewDidLeave(); + + this.submissionComponent?.ionViewDidLeave(); + } + + /** + * Compares sync event data with current data to check if refresh content is needed. + * + * @param syncEventData Data receiven on sync observer. + * @return True if refresh is needed, false otherwise. + */ + protected isRefreshSyncNeeded(syncEventData: AddonModAssignAutoSyncData): boolean { + if (this.assign && syncEventData.assignId == this.assign.id) { + if (syncEventData.warnings && syncEventData.warnings.length) { + // Show warnings. + CoreDomUtils.instance.showErrorModal(syncEventData.warnings[0]); + } + + return true; + } + + return false; + } + + /** + * Performs the sync of the activity. + * + * @return Promise resolved when done. + */ + protected async sync(): Promise { + await AddonModAssignSync.instance.syncAssign(this.assign!.id); + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + super.ngOnDestroy(); + + this.savedObserver?.off(); + this.submittedObserver?.off(); + this.gradedObserver?.off(); + } + +} diff --git a/src/addons/mod/assign/lang.json b/src/addons/mod/assign/lang.json new file mode 100644 index 000000000..5311cdf8a --- /dev/null +++ b/src/addons/mod/assign/lang.json @@ -0,0 +1,104 @@ +{ + "acceptsubmissionstatement": "Please accept the submission statement.", + "addattempt": "Allow another attempt", + "addnewattempt": "Add a new attempt", + "addnewattemptfromprevious": "Add a new attempt based on previous submission", + "addsubmission": "Add submission", + "allowsubmissionsfromdate": "Allow submissions from", + "allowsubmissionsfromdatesummary": "This assignment will accept submissions from {{$a}}", + "allowsubmissionsanddescriptionfromdatesummary": "The assignment details and submission form will be available from {{$a}}", + "applytoteam": "Apply grades and feedback to entire group", + "assignmentisdue": "Assignment is due", + "attemptnumber": "Attempt number", + "attemptreopenmethod": "Attempts reopened", + "attemptreopenmethod_manual": "Manually", + "attemptreopenmethod_untilpass": "Automatically until pass", + "attemptsettings": "Attempt settings", + "cannotgradefromapp": "Certain grading methods are not yet supported by the app and cannot be modified.", + "cannoteditduetostatementsubmission": "You can't add or edit a submission in the app because the submission statement could not be retrieved from the site.", + "cannotsubmitduetostatementsubmission": "You can't make a submission in the app because the submission statement could not be retrieved from the site.", + "confirmsubmission": "Are you sure you want to submit your work for grading? You will not be able to make any more changes.", + "currentgrade": "Current grade in gradebook", + "cutoffdate": "Cut-off date", + "currentattempt": "This is attempt {{$a}}.", + "currentattemptof": "This is attempt {{$a.attemptnumber}} ( {{$a.maxattempts}} attempts allowed ).", + "defaultteam": "Default group", + "duedate": "Due date", + "duedateno": "No due date", + "duedatereached": "The due date for this assignment has now passed", + "editingstatus": "Editing status", + "editsubmission": "Edit submission", + "erroreditpluginsnotsupported": "You can't add or edit a submission in the app because certain plugins are not yet supported for editing.", + "errorshowinginformation": "Submission information cannot be displayed.", + "extensionduedate": "Extension due date", + "feedbacknotsupported": "This feedback is not supported by the app and may not contain all the information.", + "grade": "Grade", + "graded": "Graded", + "gradedby": "Graded by", + "gradedfollowupsubmit": "Graded - follow up submission received", + "gradenotsynced": "Grade not synced", + "gradedon": "Graded on", + "gradelocked": "This grade is locked or overridden in the gradebook.", + "gradeoutof": "Grade out of {{$a}}", + "gradingstatus": "Grading status", + "groupsubmissionsettings": "Group submission settings", + "hiddenuser": "Participant", + "latesubmissions": "Late submissions", + "latesubmissionsaccepted": "Allowed until {{$a}}", + "markingworkflowstate": "Marking workflow state", + "markingworkflowstateinmarking": "In marking", + "markingworkflowstateinreview": "In review", + "markingworkflowstatenotmarked": "Not marked", + "markingworkflowstatereadyforreview": "Marking completed", + "markingworkflowstatereadyforrelease": "Ready for release", + "markingworkflowstatereleased": "Released", + "modulenameplural": "Assignments", + "multipleteams": "Member of more than one group", + "multipleteams_desc": "The assignment requires submission in groups. You are a member of more than one group. To be able to submit you must be a member of only one group. Please contact your teacher to change your group membership.", + "noattempt": "No attempt", + "nomoresubmissionsaccepted": "Only allowed for participants who have been granted an extension", + "noonlinesubmissions": "This assignment does not require you to submit anything online", + "nosubmission": "Nothing has been submitted for this assignment", + "notallparticipantsareshown": "Participants who have not made a submission are not shown.", + "noteam": "Not a member of any group", + "noteam_desc": "This assignment requires submission in groups. You are not a member of any group, so you cannot create a submission. Please contact your teacher to be added to a group.", + "notgraded": "Not graded", + "numberofdraftsubmissions": "Drafts", + "numberofparticipants": "Participants", + "numberofsubmittedassignments": "Submitted", + "numberofsubmissionsneedgrading": "Needs grading", + "numberofteams": "Groups", + "numwords": "{{$a}} words", + "outof": "{{$a.current}} out of {{$a.total}}", + "overdue": "Assignment is overdue by: {{$a}}", + "submissioneditable": "Student can edit this submission", + "submissionnoteditable": "Student cannot edit this submission", + "submissionnotsupported": "This submission is not supported by the app and may not contain all the information.", + "submission": "Submission", + "submissionslocked": "This assignment is not accepting submissions", + "submissionstatus_draft": "Draft (not submitted)", + "submissionstatusheading": "Submission status", + "submissionstatus_marked": "Graded", + "submissionstatus_new": "No submission", + "submissionstatus_reopened": "Reopened", + "submissionstatus_submitted": "Submitted for grading", + "submissionstatus_": "No submission", + "submissionstatus": "Submission status", + "submissionteam": "Group", + "submitassignment_help": "Once this assignment is submitted you will not be able to make any more changes.", + "submitassignment": "Submit assignment", + "submittedearly": "Assignment was submitted {{$a}} early", + "submittedlate": "Assignment was submitted {{$a}} late", + "syncblockedusercomponent": "user grade", + "timemodified": "Last modified", + "timeremaining": "Time remaining", + "ungroupedusers": "The setting 'Require group to make submission' is enabled and some users are either not a member of any group, or are a member of more than one group, so are unable to make submissions.", + "ungroupedusersoptional": "The setting 'Students submit in groups' is enabled and some users are either not a member of any group, or are a member of more than one group. Please be aware that these students will submit as members of the 'Default group'.", + "unlimitedattempts": "Unlimited", + "userwithid": "User with ID {{id}}", + "userswhoneedtosubmit": "Users who need to submit: {{$a}}", + "viewsubmission": "View submission", + "warningsubmissionmodified": "The user submission was modified on the site.", + "warningsubmissiongrademodified": "The submission grade was modified on the site.", + "wordlimit": "Word limit" +} \ No newline at end of file diff --git a/src/addons/mod/assign/pages/index/index.html b/src/addons/mod/assign/pages/index/index.html new file mode 100644 index 000000000..4a3c8499d --- /dev/null +++ b/src/addons/mod/assign/pages/index/index.html @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/addons/mod/assign/pages/index/index.page.ts b/src/addons/mod/assign/pages/index/index.page.ts new file mode 100644 index 000000000..3a7635582 --- /dev/null +++ b/src/addons/mod/assign/pages/index/index.page.ts @@ -0,0 +1,68 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, OnInit, ViewChild } from '@angular/core'; +import { CoreCourseWSModule } from '@features/course/services/course'; +import { CoreNavigator } from '@services/navigator'; +import { AddonModAssignIndexComponent } from '../../components/index/index'; +import { AddonModAssignAssign } from '../../services/assign'; + +/** + * Page that displays an assign. + */ +@Component({ + selector: 'page-addon-mod-assign-index', + templateUrl: 'index.html', +}) +export class AddonModAssignIndexPage implements OnInit { + + @ViewChild(AddonModAssignIndexComponent) assignComponent?: AddonModAssignIndexComponent; + + title?: string; + module?: CoreCourseWSModule; + courseId?: number; + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.module = CoreNavigator.instance.getRouteParam('module'); + this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId'); + this.title = this.module?.name; + } + + /** + * Update some data based on the assign instance. + * + * @param assign Assign instance. + */ + updateData(assign: AddonModAssignAssign): void { + this.title = assign.name || this.title; + } + + /** + * User entered the page. + */ + ionViewDidEnter(): void { + this.assignComponent?.ionViewDidEnter(); + } + + /** + * User left the page. + */ + ionViewDidLeave(): void { + this.assignComponent?.ionViewDidLeave(); + } + +} diff --git a/src/addons/mod/assign/services/assign-helper.ts b/src/addons/mod/assign/services/assign-helper.ts new file mode 100644 index 000000000..08cc4c45f --- /dev/null +++ b/src/addons/mod/assign/services/assign-helper.ts @@ -0,0 +1,727 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreFileUploader, CoreFileUploaderStoreFilesResult } from '@features/fileuploader/services/fileuploader'; +import { CoreSites, CoreSitesCommonWSOptions } from '@services/sites'; +import { CoreWSExternalFile } from '@services/ws'; +import { FileEntry } from '@ionic-native/file/ngx'; +import { + AddonModAssignProvider, + AddonModAssignAssign, + AddonModAssignSubmission, + AddonModAssignParticipant, + AddonModAssignSubmissionFeedback, + AddonModAssign, +} from './assign'; +import { AddonModAssignOffline } from './assign-offline'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreFile } from '@services/file'; +import { CoreCourseCommonModWSOptions } from '@features/course/services/course'; +import { CoreGroups } from '@services/groups'; +import { AddonModAssignSubmissionDelegate } from './submission-delegate'; +import { AddonModAssignFeedbackDelegate } from './feedback-delegate'; +import { makeSingleton } from '@singletons'; + +/** + * Service that provides some helper functions for assign. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModAssignHelperProvider { + + /** + * Check if a submission can be edited in offline. + * + * @param assign Assignment. + * @param submission Submission. + * @return Whether it can be edited offline. + */ + async canEditSubmissionOffline(assign: AddonModAssignAssign, submission: AddonModAssignSubmission): Promise { + if (!submission) { + return false; + } + + if (submission.status == AddonModAssignProvider.SUBMISSION_STATUS_NEW || + submission.status == AddonModAssignProvider.SUBMISSION_STATUS_REOPENED) { + // It's a new submission, allow creating it in offline. + return true; + } + + let canEdit = true; + + const promises = submission.plugins + ? submission.plugins.map((plugin) => + AddonModAssignSubmissionDelegate.instance.canPluginEditOffline(assign, submission, plugin).then((canEditPlugin) => { + if (!canEditPlugin) { + canEdit = false; + } + + return; + })) + : []; + + await Promise.all(promises); + + return canEdit; + } + + /** + * Clear plugins temporary data because a submission was cancelled. + * + * @param assign Assignment. + * @param submission Submission to clear the data for. + * @param inputData Data entered in the submission form. + */ + clearSubmissionPluginTmpData(assign: AddonModAssignAssign, submission: AddonModAssignSubmission, inputData: any): void { + submission.plugins?.forEach((plugin) => { + AddonModAssignSubmissionDelegate.instance.clearTmpData(assign, submission, plugin, inputData); + }); + } + + /** + * Copy the data from last submitted attempt to the current submission. + * Since we don't have any WS for that we'll have to re-submit everything manually. + * + * @param assign Assignment. + * @param previousSubmission Submission to copy. + * @return Promise resolved when done. + */ + async copyPreviousAttempt(assign: AddonModAssignAssign, previousSubmission: AddonModAssignSubmission): Promise { + const pluginData: any = {}; + const promises = previousSubmission.plugins + ? previousSubmission.plugins.map((plugin) => + AddonModAssignSubmissionDelegate.instance.copyPluginSubmissionData(assign, plugin, pluginData)) + : []; + + await Promise.all(promises); + + // We got the plugin data. Now we need to submit it. + if (Object.keys(pluginData).length) { + // There's something to save. + return AddonModAssign.instance.saveSubmissionOnline(assign.id, pluginData); + } + } + + /** + * Create an empty feedback object. + * + * @return Feedback. + */ + createEmptyFeedback(): AddonModAssignSubmissionFeedback { + return { + grade: undefined, + gradefordisplay: undefined, + gradeddate: undefined, + }; + } + + /** + * Create an empty submission object. + * + * @return Submission. + */ + createEmptySubmission(): AddonModAssignSubmissionFormatted { + return { + id: undefined, + userid: undefined, + attemptnumber: undefined, + timecreated: undefined, + timemodified: undefined, + status: undefined, + groupid: undefined, + }; + } + + /** + * Delete stored submission files for a plugin. See storeSubmissionFiles. + * + * @param assignId Assignment ID. + * @param folderName Name of the plugin folder. Must be unique (both in submission and feedback plugins). + * @param userId User ID. If not defined, site's current user. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async deleteStoredSubmissionFiles(assignId: number, folderName: string, userId?: number, siteId?: string): Promise { + const folderPath = await AddonModAssignOffline.instance.getSubmissionPluginFolder(assignId, folderName, userId, siteId); + + await CoreFile.instance.removeDir(folderPath); + } + + /** + * Delete all drafts of the feedback plugin data. + * + * @param assignId Assignment Id. + * @param userId User Id. + * @param feedback Feedback data. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async discardFeedbackPluginData( + assignId: number, + userId: number, + feedback: AddonModAssignSubmissionFeedback, + siteId?: string, + ): Promise { + + const promises = feedback.plugins + ? feedback.plugins.map((plugin) => + AddonModAssignFeedbackDelegate.instance.discardPluginFeedbackData(assignId, userId, plugin, siteId)) + : []; + + await Promise.all(promises); + } + + /** + * Check if a submission has no content. + * + * @param assign Assignment object. + * @param submission Submission to inspect. + * @return Whether the submission is empty. + */ + isSubmissionEmpty(assign: AddonModAssignAssign, submission?: AddonModAssignSubmission): boolean { + if (!submission) { + return true; + } + + const anyNotEmpty = submission.plugins?.some((plugin) => + !AddonModAssignSubmissionDelegate.instance.isPluginEmpty(assign, plugin)); + + // If any plugin is not empty, we consider that the submission is not empty either. + if (anyNotEmpty) { + return false; + } + + + // If all the plugins were empty (or there were no plugins), we consider the submission to be empty. + return true; + } + + /** + * List the participants for a single assignment, with some summary info about their submissions. + * + * @param assign Assignment object. + * @param groupId Group Id. + * @param options Other options. + * @return Promise resolved with the list of participants and summary of submissions. + */ + async getParticipants( + assign: AddonModAssignAssign, + groupId?: number, + options: CoreSitesCommonWSOptions = {}, + ): Promise { + + groupId = groupId || 0; + options.siteId = options.siteId || CoreSites.instance.getCurrentSiteId(); + + // Create new options including all existing ones. + const modOptions: CoreCourseCommonModWSOptions = { cmId: assign.cmid, ...options }; + + const participants = await AddonModAssign.instance.listParticipants(assign.id, groupId, modOptions); + + if (groupId || participants && participants.length > 0) { + return participants; + } + + // If no participants returned and all groups specified, get participants by groups. + const groupsInfo = await CoreGroups.instance.getActivityGroupInfo(assign.cmid, false, undefined, modOptions.siteId); + []; + + const participantsIndexed: {[id: number]: AddonModAssignParticipant} = {}; + + const promises = groupsInfo.groups + ? groupsInfo.groups.map((userGroup) => + AddonModAssign.instance.listParticipants(assign.id, userGroup.id, modOptions).then((participantsFromList) => { + // Do not get repeated users. + participantsFromList.forEach((participant) => { + participantsIndexed[participant.id] = participant; + }); + + return; + })) + :[]; + + await Promise.all(promises); + + return CoreUtils.instance.objectToArray(participantsIndexed); + } + + /** + * Get plugin config from assignment config. + * + * @param assign Assignment object including all config. + * @param subtype Subtype name (assignsubmission or assignfeedback) + * @param type Name of the subplugin. + * @return Object containing all configurations of the subplugin selected. + */ + getPluginConfig(assign: AddonModAssignAssign, subtype: string, type: string): AddonModAssignPluginConfig { + const configs: AddonModAssignPluginConfig = {}; + + assign.configs.forEach((config) => { + if (config.subtype == subtype && config.plugin == type) { + configs[config.name] = config.value; + } + }); + + return configs; + } + + /** + * Get enabled subplugins. + * + * @param assign Assignment object including all config. + * @param subtype Subtype name (assignsubmission or assignfeedback) + * @return List of enabled plugins for the assign. + */ + getPluginsEnabled(assign: AddonModAssignAssign, subtype: string): AddonModAssignPluginsEnabled { + const enabled: AddonModAssignPluginsEnabled = []; + + assign.configs.forEach((config) => { + if (config.subtype == subtype && config.name == 'enabled' && parseInt(config.value, 10) === 1) { + // Format the plugin objects. + enabled.push({ + type: config.plugin, + }); + } + }); + + return enabled; + } + + /** + * Get a list of stored submission files. See storeSubmissionFiles. + * + * @param assignId Assignment ID. + * @param folderName Name of the plugin folder. Must be unique (both in submission and feedback plugins). + * @param userId User ID. If not defined, site's current user. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the files. + */ + async getStoredSubmissionFiles( + assignId: number, + folderName: string, + userId?: number, + siteId?: string, + ): Promise<(FileEntry | DirectoryEntry)[]> { + const folderPath = await AddonModAssignOffline.instance.getSubmissionPluginFolder(assignId, folderName, userId, siteId); + + return CoreFile.instance.getDirectoryContents(folderPath); + } + + /** + * Get the size that will be uploaded to perform an attempt copy. + * + * @param assign Assignment. + * @param previousSubmission Submission to copy. + * @return Promise resolved with the size. + */ + async getSubmissionSizeForCopy(assign: AddonModAssignAssign, previousSubmission: AddonModAssignSubmission): Promise { + let totalSize = 0; + + const promises = previousSubmission.plugins + ? previousSubmission.plugins.map((plugin) => + AddonModAssignSubmissionDelegate.instance.getPluginSizeForCopy(assign, plugin).then((size) => { + totalSize += (size || 0); + + return; + })) + : []; + + await Promise.all(promises); + + return totalSize; + } + + /** + * Get the size that will be uploaded to save a submission. + * + * @param assign Assignment. + * @param submission Submission to check data. + * @param inputData Data entered in the submission form. + * @return Promise resolved with the size. + */ + async getSubmissionSizeForEdit( + assign: AddonModAssignAssign, + submission: AddonModAssignSubmission, + inputData: any, + ): Promise { + + let totalSize = 0; + + const promises = submission.plugins + ? submission.plugins.map((plugin) => + AddonModAssignSubmissionDelegate.instance.getPluginSizeForEdit(assign, submission, plugin, inputData) + .then((size) => { + totalSize += (size || 0); + + return; + })) + : []; + + await Promise.all(promises); + + return totalSize; + } + + /** + * Get user data for submissions since they only have userid. + * + * @param assign Assignment object. + * @param submissions Submissions to get the data for. + * @param groupId Group Id. + * @param options Other options. + * @return Promise always resolved. Resolve param is the formatted submissions. + */ + async getSubmissionsUserData( + assign: AddonModAssignAssign, + submissions: AddonModAssignSubmissionFormatted[] = [], + groupId?: number, + options: CoreSitesCommonWSOptions = {}, + ): Promise { + // Create new options including all existing ones. + const modOptions: CoreCourseCommonModWSOptions = { cmId: assign.cmid, ...options }; + + const parts = await this.getParticipants(assign, groupId, options); + + const blind = assign.blindmarking && !assign.revealidentities; + const promises: Promise[] = []; + const result: AddonModAssignSubmissionFormatted[] = []; + const participants: {[id: number]: AddonModAssignParticipant} = CoreUtils.instance.arrayToObject(parts, 'id'); + + submissions.forEach((submission) => { + submission.submitid = submission.userid && submission.userid > 0 ? submission.userid : submission.blindid; + if (typeof submission.submitid == 'undefined' || submission.submitid <= 0) { + return; + } + + const participant = participants[submission.submitid]; + if (!participant) { + // Avoid permission denied error. Participant not found on list. + return; + } + + delete participants[submission.submitid]; + + if (!blind) { + submission.userfullname = participant.fullname; + submission.userprofileimageurl = participant.profileimageurl; + } + + submission.manyGroups = !!participant.groups && participant.groups.length > 1; + submission.noGroups = !!participant.groups && participant.groups.length == 0; + if (participant.groupname) { + submission.groupid = participant.groupid!; + submission.groupname = participant.groupname; + } + + let promise = Promise.resolve(); + if (submission.userid && submission.userid > 0 && blind) { + // Blind but not blinded! (Moodle < 3.1.1, 3.2). + delete submission.userid; + + promise = AddonModAssign.instance.getAssignmentUserMappings(assign.id, submission.submitid, modOptions) + .then((blindId) => { + submission.blindid = blindId; + + return; + }); + } + + promises.push(promise.then(() => { + // Add to the list. + if (submission.userfullname || submission.blindid) { + result.push(submission); + } + + return; + })); + }); + + await Promise.all(promises); + + // Create a submission for each participant left in the list (the participants already treated were removed). + CoreUtils.instance.objectToArray(participants).forEach((participant: AddonModAssignParticipant) => { + const submission = this.createEmptySubmission(); + + submission.submitid = participant.id; + + if (!blind) { + submission.userid = participant.id; + submission.userfullname = participant.fullname; + submission.userprofileimageurl = participant.profileimageurl; + } else { + submission.blindid = participant.id; + } + + submission.manyGroups = !!participant.groups && participant.groups.length > 1; + submission.noGroups = !!participant.groups && participant.groups.length == 0; + if (participant.groupname) { + submission.groupid = participant.groupid!; + submission.groupname = participant.groupname; + } + submission.status = participant.submitted ? AddonModAssignProvider.SUBMISSION_STATUS_SUBMITTED : + AddonModAssignProvider.SUBMISSION_STATUS_NEW; + + result.push(submission); + }); + + return result; + } + + /** + * Check if the feedback data has changed for a certain submission and assign. + * + * @param assign Assignment. + * @param submission The submission. + * @param feedback Feedback data. + * @param userId The user ID. + * @return Promise resolved with true if data has changed, resolved with false otherwise. + */ + async hasFeedbackDataChanged( + assign: AddonModAssignAssign, + submission: AddonModAssignSubmission, + feedback: AddonModAssignSubmissionFeedback, + userId: number, + ): Promise { + + let hasChanged = false; + + const promises = feedback.plugins + ? feedback.plugins.map((plugin) => + this.prepareFeedbackPluginData(assign.id, userId, feedback).then(async (inputData) => { + const changed = await CoreUtils.instance.ignoreErrors( + AddonModAssignFeedbackDelegate.instance.hasPluginDataChanged(assign, submission, plugin, inputData, userId), + false, + ); + if (changed) { + hasChanged = true; + } + + return; + })) + : []; + + await CoreUtils.instance.allPromises(promises); + + return hasChanged; + } + + /** + * Check if the submission data has changed for a certain submission and assign. + * + * @param assign Assignment. + * @param submission Submission to check data. + * @param inputData Data entered in the submission form. + * @return Promise resolved with true if data has changed, resolved with false otherwise. + */ + async hasSubmissionDataChanged( + assign: AddonModAssignAssign, + submission: AddonModAssignSubmission, + inputData: any, + ): Promise { + let hasChanged = false; + + const promises = submission.plugins + ? submission.plugins.map((plugin) => + AddonModAssignSubmissionDelegate.instance.hasPluginDataChanged(assign, submission, plugin, inputData) + .then((changed) => { + if (changed) { + hasChanged = true; + } + + return; + }).catch(() => { + // Ignore errors. + })) + : []; + + await CoreUtils.instance.allPromises(promises); + + return hasChanged; + } + + /** + * Prepare and return the plugin data to send for a certain feedback and assign. + * + * @param assignId Assignment Id. + * @param userId User Id. + * @param feedback Feedback data. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with plugin data to send to server. + */ + async prepareFeedbackPluginData( + assignId: number, + userId: number, + feedback: AddonModAssignSubmissionFeedback, + siteId?: string, + ): Promise { + + const pluginData = {}; + const promises = feedback.plugins + ? feedback.plugins.map((plugin) => + AddonModAssignFeedbackDelegate.instance.preparePluginFeedbackData(assignId, userId, plugin, pluginData, siteId)) + : []; + + await Promise.all(promises); + + return pluginData; + } + + /** + * Prepare and return the plugin data to send for a certain submission and assign. + * + * @param assign Assignment. + * @param submission Submission to check data. + * @param inputData Data entered in the submission form. + * @param offline True to prepare the data for an offline submission, false otherwise. + * @return Promise resolved with plugin data to send to server. + */ + async prepareSubmissionPluginData( + assign: AddonModAssignAssign, + submission: AddonModAssignSubmission, + inputData: any, + offline = false, + ): Promise { + + const pluginData = {}; + const promises = submission.plugins + ? submission.plugins.map((plugin) => + AddonModAssignSubmissionDelegate.instance.preparePluginSubmissionData( + assign, + submission, + plugin, + inputData, + pluginData, + offline, + )) + : []; + + await Promise.all(promises); + + return pluginData; + } + + /** + * Given a list of files (either online files or local files), store the local files in a local folder + * to be submitted later. + * + * @param assignId Assignment ID. + * @param folderName Name of the plugin folder. Must be unique (both in submission and feedback plugins). + * @param files List of files. + * @param userId User ID. If not defined, site's current user. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if success, rejected otherwise. + */ + async storeSubmissionFiles( + assignId: number, + folderName: string, + files: (CoreWSExternalFile | FileEntry)[], + userId?: number, + siteId?: string, + ): Promise { + // Get the folder where to store the files. + const folderPath = await AddonModAssignOffline.instance.getSubmissionPluginFolder(assignId, folderName, userId, siteId); + + return CoreFileUploader.instance.storeFilesToUpload(folderPath, files); + } + + /** + * Upload a file to a draft area. If the file is an online file it will be downloaded and then re-uploaded. + * + * @param assignId Assignment ID. + * @param file Online file or local FileEntry. + * @param itemId Draft ID to use. Undefined or 0 to create a new draft ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the itemId. + */ + uploadFile(assignId: number, file: CoreWSExternalFile | FileEntry, itemId?: number, siteId?: string): Promise { + return CoreFileUploader.instance.uploadOrReuploadFile(file, itemId, AddonModAssignProvider.COMPONENT, assignId, siteId); + } + + /** + * Given a list of files (either online files or local files), upload them to a draft area and return the draft ID. + * Online files will be downloaded and then re-uploaded. + * If there are no files to upload it will return a fake draft ID (1). + * + * @param assignId Assignment ID. + * @param files List of files. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the itemId. + */ + uploadFiles(assignId: number, files: (CoreWSExternalFile | FileEntry)[], siteId?: string): Promise { + return CoreFileUploader.instance.uploadOrReuploadFiles(files, AddonModAssignProvider.COMPONENT, assignId, siteId); + } + + /** + * Upload or store some files, depending if the user is offline or not. + * + * @param assignId Assignment ID. + * @param folderName Name of the plugin folder. Must be unique (both in submission and feedback plugins). + * @param files List of files. + * @param offline True if files sould be stored for offline, false to upload them. + * @param userId User ID. If not defined, site's current user. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + uploadOrStoreFiles( + assignId: number, + folderName: string, + files: (CoreWSExternalFile | FileEntry)[], + offline = false, + userId?: number, + siteId?: string, + ): Promise { + + if (offline) { + return this.storeSubmissionFiles(assignId, folderName, files, userId, siteId); + } + + return this.uploadFiles(assignId, files, siteId); + } + +} +export const AddonModAssignHelper = makeSingleton(AddonModAssignHelperProvider); + + +/** + * Assign submission with some calculated data. + */ +export type AddonModAssignSubmissionFormatted = + Omit & { + id?: number; // Submission id. + userid?: number; // Student id. + attemptnumber?: number; // Attempt number. + timecreated?: number; // Submission creation time. + timemodified?: number; // Submission last modified time. + status?: string; // Submission status. + groupid?: number; // Group id. + blindid?: number; // Calculated in the app. Blindid of the user that did the submission. + submitid?: number; // Calculated in the app. Userid or blindid of the user that did the submission. + userfullname?: string; // Calculated in the app. Full name of the user that did the submission. + userprofileimageurl?: string; // Calculated in the app. Avatar of the user that did the submission. + manyGroups?: boolean; // Calculated in the app. Whether the user belongs to more than 1 group. + noGroups?: boolean; // Calculated in the app. Whether the user doesn't belong to any group. + groupname?: string; // Calculated in the app. Name of the group the submission belongs to. + }; + +/** + * Assingment subplugins type enabled. + */ +export type AddonModAssignPluginsEnabled = { + type: string; // Plugin type. +}[]; + +/** + * Assingment plugin config. + */ +export type AddonModAssignPluginConfig = {[name: string]: string}; diff --git a/src/addons/mod/assign/services/assign-offline.ts b/src/addons/mod/assign/services/assign-offline.ts new file mode 100644 index 000000000..78da7092e --- /dev/null +++ b/src/addons/mod/assign/services/assign-offline.ts @@ -0,0 +1,459 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreError } from '@classes/errors/error'; +import { SQLiteDBRecordValues } from '@classes/sqlitedb'; +import { CoreFile } from '@services/file'; +import { CoreSites } from '@services/sites'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreTimeUtils } from '@services/utils/time'; +import { makeSingleton } from '@singletons'; +import { AddonModAssignOutcomes, AddonModAssignSavePluginData } from './assign'; +import { + AddonModAssignSubmissionsDBRecord, + AddonModAssignSubmissionsGradingDBRecord, + SUBMISSIONS_GRADES_TABLE, + SUBMISSIONS_TABLE, +} from './database/assign'; + +/** + * Service to handle offline assign. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModAssignOfflineProvider { + + /** + * Delete a submission. + * + * @param assignId Assignment ID. + * @param userId User ID. If not defined, site's current user. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if deleted, rejected if failure. + */ + async deleteSubmission(assignId: number, userId?: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + userId = userId || site.getUserId(); + + await site.getDb().deleteRecords( + SUBMISSIONS_TABLE, + { assignid: assignId, userid: userId }, + ); + } + + /** + * Delete a submission grade. + * + * @param assignId Assignment ID. + * @param userId User ID. If not defined, site's current user. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if deleted, rejected if failure. + */ + async deleteSubmissionGrade(assignId: number, userId?: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + userId = userId || site.getUserId(); + + await site.getDb().deleteRecords( + SUBMISSIONS_GRADES_TABLE, + { assignid: assignId, userid: userId }, + ); + } + + /** + * Get all the assignments ids that have something to be synced. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with assignments id that have something to be synced. + */ + async getAllAssigns(siteId?: string): Promise { + const promises: + Promise[] = []; + + promises.push(this.getAllSubmissions(siteId)); + promises.push(this.getAllSubmissionsGrade(siteId)); + + const results = await Promise.all(promises); + // Flatten array. + const flatten: (AddonModAssignSubmissionsDBRecord | AddonModAssignSubmissionsGradingDBRecord)[] = + [].concat.apply([], results); + + // Get assign id. + let assignIds: number[] = flatten.map((assign) => assign.assignid); + // Get unique values. + assignIds = assignIds.filter((id, pos) => assignIds.indexOf(id) == pos); + + return assignIds; + } + + /** + * Get all the stored submissions from all the assignments. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with submissions. + */ + protected async getAllSubmissions(siteId?: string): Promise { + return this.getAssignSubmissionsFormatted(undefined, siteId); + } + + /** + * Get all the stored submissions for a certain assignment. + * + * @param assignId Assignment ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with submissions. + */ + async getAssignSubmissions(assignId: number, siteId?: string): Promise { + return this.getAssignSubmissionsFormatted({ assingid: assignId }, siteId); + } + + /** + * Convenience helper function to get stored submissions formatted. + * + * @param conditions Query conditions. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with submissions. + */ + protected async getAssignSubmissionsFormatted( + conditions: SQLiteDBRecordValues = {}, + siteId?: string, + ): Promise { + const db = await CoreSites.instance.getSiteDb(siteId); + + const submissions: AddonModAssignSubmissionsDBRecord[] = await db.getRecords(SUBMISSIONS_TABLE, conditions); + + // Parse the plugin data. + return submissions.map((submission) => ({ + assignid: submission.assignid, + userid: submission.userid, + courseid: submission.courseid, + plugindata: CoreTextUtils.instance.parseJSON(submission.plugindata, {}), + onlinetimemodified: submission.onlinetimemodified, + timecreated: submission.timecreated, + timemodified: submission.timemodified, + submitted: submission.submitted, + submissionstatement: submission.submissionstatement, + })); + } + + /** + * Get all the stored submissions grades from all the assignments. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with submissions grades. + */ + protected async getAllSubmissionsGrade(siteId?: string): Promise { + return this.getAssignSubmissionsGradeFormatted(undefined, siteId); + } + + /** + * Get all the stored submissions grades for a certain assignment. + * + * @param assignId Assignment ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with submissions grades. + */ + async getAssignSubmissionsGrade( + assignId: number, + siteId?: string, + ): Promise { + return this.getAssignSubmissionsGradeFormatted({ assingid: assignId }, siteId); + } + + /** + * Convenience helper function to get stored submissions grading formatted. + * + * @param conditions Query conditions. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with submissions grades. + */ + protected async getAssignSubmissionsGradeFormatted( + conditions: SQLiteDBRecordValues = {}, + siteId?: string, + ): Promise { + const db = await CoreSites.instance.getSiteDb(siteId); + + const submissions: AddonModAssignSubmissionsGradingDBRecord[] = await db.getRecords(SUBMISSIONS_GRADES_TABLE, conditions); + + // Parse the plugin data and outcomes. + return submissions.map((submission) => ({ + assignid: submission.assignid, + userid: submission.userid, + courseid: submission.courseid, + grade: submission.grade, + attemptnumber: submission.attemptnumber, + addattempt: submission.addattempt, + workflowstate: submission.workflowstate, + applytoall: submission.applytoall, + outcomes: CoreTextUtils.instance.parseJSON(submission.outcomes, {}), + plugindata: CoreTextUtils.instance.parseJSON(submission.plugindata, {}), + timemodified: submission.timemodified, + })); + } + + /** + * Get a stored submission. + * + * @param assignId Assignment ID. + * @param userId User ID. If not defined, site's current user. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with submission. + */ + async getSubmission(assignId: number, userId?: number, siteId?: string): Promise { + userId = userId || CoreSites.instance.getCurrentSiteUserId(); + + const submissions = await this.getAssignSubmissionsFormatted({ assignid: assignId, userid: userId }, siteId); + + if (submissions.length) { + return submissions[0]; + } + + throw new CoreError('No records found.'); + } + + /** + * Get the path to the folder where to store files for an offline submission. + * + * @param assignId Assignment ID. + * @param userId User ID. If not defined, site's current user. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the path. + */ + async getSubmissionFolder(assignId: number, userId?: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + userId = userId || site.getUserId(); + const siteFolderPath = CoreFile.instance.getSiteFolder(site.getId()); + const submissionFolderPath = 'offlineassign/' + assignId + '/' + userId; + + return CoreTextUtils.instance.concatenatePaths(siteFolderPath, submissionFolderPath); + } + + /** + * Get a stored submission grade. + * Submission grades are not identified using attempt number so it can retrieve the feedback for a previous attempt. + * + * @param assignId Assignment ID. + * @param userId User ID. If not defined, site's current user. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with submission grade. + */ + async getSubmissionGrade( + assignId: number, + userId?: number, + siteId?: string, + ): Promise { + userId = userId || CoreSites.instance.getCurrentSiteUserId(); + + const submissions = await this.getAssignSubmissionsGradeFormatted({ assignid: assignId, userid: userId }, siteId); + + if (submissions.length) { + return submissions[0]; + } + + throw new CoreError('No records found.'); + } + + /** + * Get the path to the folder where to store files for a certain plugin in an offline submission. + * + * @param assignId Assignment ID. + * @param pluginName Name of the plugin. Must be unique (both in submission and feedback plugins). + * @param userId User ID. If not defined, site's current user. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the path. + */ + async getSubmissionPluginFolder(assignId: number, pluginName: string, userId?: number, siteId?: string): Promise { + const folderPath = await this.getSubmissionFolder(assignId, userId, siteId); + + return CoreTextUtils.instance.concatenatePaths(folderPath, pluginName); + } + + /** + * Check if the assignment has something to be synced. + * + * @param assignId Assignment ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: whether the assignment has something to be synced. + */ + async hasAssignOfflineData(assignId: number, siteId?: string): Promise { + const promises: + Promise[] = []; + + + promises.push(this.getAssignSubmissions(assignId, siteId)); + promises.push(this.getAssignSubmissionsGrade(assignId, siteId)); + + try { + const results = await Promise.all(promises); + + return results.some((result) => result.length); + } catch { + // No offline data found. + return false; + } + } + + /** + * Mark/Unmark a submission as being submitted. + * + * @param assignId Assignment ID. + * @param courseId Course ID the assign belongs to. + * @param submitted True to mark as submitted, false to mark as not submitted. + * @param acceptStatement True to accept the submission statement, false otherwise. + * @param timemodified The time the submission was last modified in online. + * @param userId User ID. If not defined, site's current user. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if marked, rejected if failure. + */ + async markSubmitted( + assignId: number, + courseId: number, + submitted: boolean, + acceptStatement: boolean, + timemodified: number, + userId?: number, + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + + userId = userId || site.getUserId(); + let submission: AddonModAssignSubmissionsDBRecord; + try { + const savedSubmission: AddonModAssignSubmissionsDBRecordFormatted = + await this.getSubmission(assignId, userId, site.getId()); + submission = Object.assign(savedSubmission, { + plugindata: savedSubmission.plugindata ? JSON.stringify(savedSubmission.plugindata) : '{}', + submitted: submitted ? 1 : 0, // Mark the submission. + submissionstatement: acceptStatement ? 1 : 0, // Mark the submission. + }); + } catch { + // No submission, create an empty one. + const now = CoreTimeUtils.instance.timestamp(); + submission = { + assignid: assignId, + courseid: courseId, + userid: userId, + onlinetimemodified: timemodified, + timecreated: now, + timemodified: now, + plugindata: '{}', + submitted: submitted ? 1 : 0, // Mark the submission. + submissionstatement: acceptStatement ? 1 : 0, // Mark the submission. + }; + } + + return await site.getDb().insertRecord(SUBMISSIONS_TABLE, submission); + } + + /** + * Save a submission to be sent later. + * + * @param assignId Assignment ID. + * @param courseId Course ID the assign belongs to. + * @param pluginData Data to save. + * @param timemodified The time the submission was last modified in online. + * @param submitted True if submission has been submitted, false otherwise. + * @param userId User ID. If not defined, site's current user. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if stored, rejected if failure. + */ + async saveSubmission( + assignId: number, + courseId: number, + pluginData: AddonModAssignSavePluginData, + timemodified: number, + submitted: boolean, + userId?: number, + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + + userId = userId || site.getUserId(); + + const now = CoreTimeUtils.instance.timestamp(); + const entry: AddonModAssignSubmissionsDBRecord = { + assignid: assignId, + courseid: courseId, + plugindata: pluginData ? JSON.stringify(pluginData) : '{}', + userid: userId, + submitted: submitted ? 1 : 0, + timecreated: now, + timemodified: now, + onlinetimemodified: timemodified, + }; + + return await site.getDb().insertRecord(SUBMISSIONS_TABLE, entry); + } + + /** + * Save a grading to be sent later. + * + * @param assignId Assign ID. + * @param userId User ID. + * @param courseId Course ID the assign belongs to. + * @param grade Grade to submit. + * @param attemptNumber Number of the attempt being graded. + * @param addAttempt Admit the user to attempt again. + * @param workflowState Next workflow State. + * @param applyToAll If it's a team submission, whether the grade applies to all group members. + * @param outcomes Object including all outcomes values. If empty, any of them will be sent. + * @param pluginData Plugin data to save. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if stored, rejected if failure. + */ + async submitGradingForm( + assignId: number, + userId: number, + courseId: number, + grade: number, + attemptNumber: number, + addAttempt: boolean, + workflowState: string, + applyToAll: boolean, + outcomes: AddonModAssignOutcomes, + pluginData: AddonModAssignSavePluginData, + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const now = CoreTimeUtils.instance.timestamp(); + const entry: AddonModAssignSubmissionsGradingDBRecord = { + assignid: assignId, + userid: userId, + courseid: courseId, + grade: grade, + attemptnumber: attemptNumber, + addattempt: addAttempt ? 1 : 0, + workflowstate: workflowState, + applytoall: applyToAll ? 1 : 0, + outcomes: outcomes ? JSON.stringify(outcomes) : '{}', + plugindata: pluginData ? JSON.stringify(pluginData) : '{}', + timemodified: now, + }; + + return await site.getDb().insertRecord(SUBMISSIONS_GRADES_TABLE, entry); + } + +} +export const AddonModAssignOffline = makeSingleton(AddonModAssignOfflineProvider); + +export type AddonModAssignSubmissionsDBRecordFormatted = Omit & { + plugindata: AddonModAssignSavePluginData; +}; + +export type AddonModAssignSubmissionsGradingDBRecordFormatted = + Omit & { + plugindata: AddonModAssignSavePluginData; + outcomes: AddonModAssignOutcomes; + }; diff --git a/src/addons/mod/assign/services/assign-sync.ts b/src/addons/mod/assign/services/assign-sync.ts new file mode 100644 index 000000000..5335cd234 --- /dev/null +++ b/src/addons/mod/assign/services/assign-sync.ts @@ -0,0 +1,572 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreEvents, CoreEventSiteData } from '@singletons/events'; +import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreSyncBlockedError } from '@classes/base-sync'; +import { + AddonModAssignProvider, + AddonModAssignAssign, + AddonModAssignSubmission, + AddonModAssign, + AddonModAssignGetSubmissionStatusWSResponse, + AddonModAssignSubmissionStatusOptions, +} from './assign'; +import { makeSingleton, Translate } from '@singletons'; +import { CoreCourse } from '@features/course/services/course'; +import { CoreCourseActivitySyncBaseProvider } from '@features/course/classes/activity-sync'; +import { + AddonModAssignOffline, + AddonModAssignSubmissionsDBRecordFormatted, + AddonModAssignSubmissionsGradingDBRecordFormatted, +} from './assign-offline'; +import { CoreSync } from '@services/sync'; +import { CoreCourseLogHelper } from '@features/course/services/log-helper'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreApp } from '@services/app'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreNetworkError } from '@classes/errors/network-error'; +import { CoreGradesFormattedItem, CoreGradesFormattedRow, CoreGradesHelper } from '@features/grades/services/grades-helper'; +import { AddonModAssignSubmissionDelegate } from './submission-delegate'; +import { AddonModAssignFeedbackDelegate } from './feedback-delegate'; + +/** + * Service to sync assigns. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModAssignSyncProvider extends CoreCourseActivitySyncBaseProvider { + + static readonly AUTO_SYNCED = 'addon_mod_assign_autom_synced'; + static readonly MANUAL_SYNCED = 'addon_mod_assign_manual_synced'; + + protected componentTranslate: string; + + constructor() { + super('AddonModLessonSyncProvider'); + this.componentTranslate = CoreCourse.instance.translateModuleName('assign'); + } + + /** + * Get the sync ID for a certain user grade. + * + * @param assignId Assign ID. + * @param userId User the grade belongs to. + * @return Sync ID. + */ + getGradeSyncId(assignId: number, userId: number): string { + return 'assignGrade#' + assignId + '#' + userId; + } + + /** + * Convenience function to get scale selected option. + * + * @param options Possible options. + * @param selected Selected option to search. + * @return Index of the selected option. + */ + protected getSelectedScaleId(options: string, selected: string): number { + let optionsList = options.split(','); + + optionsList = optionsList.map((value) => value.trim()); + + optionsList.unshift(''); + + const index = options.indexOf(selected) || 0; + if (index < 0) { + return 0; + } + + return index; + } + + /** + * Check if an assignment has data to synchronize. + * + * @param assignId Assign ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: whether it has data to sync. + */ + hasDataToSync(assignId: number, siteId?: string): Promise { + return AddonModAssignOffline.instance.hasAssignOfflineData(assignId, siteId); + } + + /** + * Try to synchronize all the assignments in a certain site or in all sites. + * + * @param siteId Site ID to sync. If not defined, sync all sites. + * @param force Wether to force sync not depending on last execution. + * @return Promise resolved if sync is successful, rejected if sync fails. + */ + syncAllAssignments(siteId?: string, force?: boolean): Promise { + return this.syncOnSites('all assignments', this.syncAllAssignmentsFunc.bind(this, !!force), siteId); + } + + /** + * Sync all assignments on a site. + * + * @param force Wether to force sync not depending on last execution. + * @param siteId Site ID to sync. If not defined, sync all sites. + * @param Promise resolved if sync is successful, rejected if sync fails. + */ + protected async syncAllAssignmentsFunc(force: boolean, siteId: string): Promise { + // Get all assignments that have offline data. + const assignIds = await AddonModAssignOffline.instance.getAllAssigns(siteId); + + // Try to sync all assignments. + await Promise.all(assignIds.map(async (assignId) => { + const result = force + ? await this.syncAssign(assignId, siteId) + : await this.syncAssignIfNeeded(assignId, siteId); + + if (result?.updated) { + CoreEvents.trigger(AddonModAssignSyncProvider.AUTO_SYNCED, { + assignId: assignId, + warnings: result.warnings, + gradesBlocked: result.gradesBlocked, + }, siteId); + } + })); + } + + /** + * Sync an assignment only if a certain time has passed since the last time. + * + * @param assignId Assign ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the assign is synced or it doesn't need to be synced. + */ + async syncAssignIfNeeded(assignId: number, siteId?: string): Promise { + const needed = await this.isSyncNeeded(assignId, siteId); + + if (needed) { + return this.syncAssign(assignId, siteId); + } + } + + /** + * Try to synchronize an assign. + * + * @param assignId Assign ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved in success. + */ + async syncAssign(assignId: number, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + this.componentTranslate = this.componentTranslate || CoreCourse.instance.translateModuleName('assign'); + + if (this.isSyncing(assignId, siteId)) { + // There's already a sync ongoing for this assign, return the promise. + return this.getOngoingSync(assignId, siteId)!; + } + + // Verify that assign isn't blocked. + if (CoreSync.instance.isBlocked(AddonModAssignProvider.COMPONENT, assignId, siteId)) { + this.logger.debug('Cannot sync assign ' + assignId + ' because it is blocked.'); + + throw new CoreSyncBlockedError(Translate.instance.instant('core.errorsyncblocked', { $a: this.componentTranslate })); + } + + + this.logger.debug('Try to sync assign ' + assignId + ' in site ' + siteId); + + const syncPromise = this.performSyncAssign(assignId, siteId); + + return this.addOngoingSync(assignId, syncPromise, siteId); + } + + /** + * Perform the assign submission. + * + * @param assignId Assign ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved in success. + */ + protected async performSyncAssign(assignId: number, siteId: string): Promise { + // Sync offline logs. + await CoreUtils.instance.ignoreErrors( + CoreCourseLogHelper.instance.syncActivity(AddonModAssignProvider.COMPONENT, assignId, siteId), + ); + + const result: AddonModAssignSyncResult = { + warnings: [], + updated: false, + gradesBlocked: [], + }; + + // Load offline data and sync offline logs. + const [submissions, grades] = await Promise.all([ + this.getOfflineSubmissions(assignId, siteId), + this.getOfflineGrades(assignId, siteId), + ]); + + if (!submissions.length && !grades.length) { + // Nothing to sync. + await CoreUtils.instance.ignoreErrors(this.setSyncTime(assignId, siteId)); + + return result; + } + + if (!CoreApp.instance.isOnline()) { + // Cannot sync in offline. + throw new CoreNetworkError(); + } + + const courseId = submissions.length > 0 ? submissions[0].courseid : grades[0].courseid; + + const assign = await AddonModAssign.instance.getAssignmentById(courseId, assignId, { siteId }); + + let promises: Promise[] = []; + + promises = promises.concat(submissions.map(async (submission) => { + await this.syncSubmission(assign, submission, result.warnings, siteId); + + result.updated = true; + + return; + })); + + promises = promises.concat(grades.map(async (grade) => { + try { + await this.syncSubmissionGrade(assign, grade, result.warnings, courseId, siteId); + + result.updated = true; + } catch (error) { + if (error instanceof CoreSyncBlockedError) { + // Grade blocked, but allow finish the sync. + result.gradesBlocked.push(grade.userid); + } else { + throw error; + } + } + })); + + await CoreUtils.instance.allPromises(promises); + + if (result.updated) { + // Data has been sent to server. Now invalidate the WS calls. + await CoreUtils.instance.ignoreErrors(AddonModAssign.instance.invalidateContent(assign.cmid, courseId, siteId)); + } + + // Sync finished, set sync time. + await CoreUtils.instance.ignoreErrors(this.setSyncTime(assignId, siteId)); + + // All done, return the result. + return result; + } + + /** + * Get offline grades to be sent. + * + * @param assignId Assign ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise with grades. + */ + protected async getOfflineGrades( + assignId: number, + siteId: string, + ): Promise { + // If no offline data found, return empty array. + return CoreUtils.instance.ignoreErrors(AddonModAssignOffline.instance.getAssignSubmissionsGrade(assignId, siteId), []); + } + + /** + * Get offline submissions to be sent. + * + * @param assignId Assign ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise with submissions. + */ + protected async getOfflineSubmissions( + assignId: number, + siteId: string, + ): Promise { + // If no offline data found, return empty array. + return CoreUtils.instance.ignoreErrors(AddonModAssignOffline.instance.getAssignSubmissions(assignId, siteId), []); + } + + /** + * Synchronize a submission. + * + * @param assign Assignment. + * @param offlineData Submission offline data. + * @param warnings List of warnings. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if success, rejected otherwise. + */ + protected async syncSubmission( + assign: AddonModAssignAssign, + offlineData: AddonModAssignSubmissionsDBRecordFormatted, + warnings: string[], + siteId: string, + ): Promise { + + const userId = offlineData.userid; + const pluginData = {}; + const options: AddonModAssignSubmissionStatusOptions = { + userId, + cmId: assign.cmid, + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + siteId, + }; + + const status = await AddonModAssign.instance.getSubmissionStatus(assign.id, options); + + const submission = AddonModAssign.instance.getSubmissionObjectFromAttempt(assign, status.lastattempt); + + if (submission && submission.timemodified != offlineData.onlinetimemodified) { + // The submission was modified in Moodle, discard the submission. + this.addOfflineDataDeletedWarning( + warnings, + this.componentTranslate, + assign.name, + Translate.instance.instant('addon.mod_assign.warningsubmissionmodified'), + ); + + return this.deleteSubmissionData(assign, offlineData, submission, siteId); + } + + try { + if (submission?.plugins) { + // Prepare plugins data. + await Promise.all(submission.plugins.map((plugin) => + AddonModAssignSubmissionDelegate.instance.preparePluginSyncData( + assign, + submission, + plugin, + offlineData, + pluginData, + siteId, + ))); + } + + // Now save the submission. + if (Object.keys(pluginData).length > 0) { + await AddonModAssign.instance.saveSubmissionOnline(assign.id, pluginData, siteId); + } + + if (assign.submissiondrafts && offlineData.submitted) { + // The user submitted the assign manually. Submit it for grading. + await AddonModAssign.instance.submitForGradingOnline(assign.id, !!offlineData.submissionstatement, siteId); + } + + // Submission data sent, update cached data. No need to block the user for this. + AddonModAssign.instance.getSubmissionStatus(assign.id, options); + } catch (error) { + if (!error || !CoreUtils.instance.isWebServiceError(error)) { + // Local error, reject. + throw error; + } + + // A WebService has thrown an error, this means it cannot be submitted. Discard the submission. + this.addOfflineDataDeletedWarning( + warnings, + this.componentTranslate, + assign.name, + CoreTextUtils.instance.getErrorMessageFromError(error) || '', + ); + } + + // Delete the offline data. + await this.deleteSubmissionData(assign, offlineData, submission, siteId); + } + + /** + * Delete the submission offline data (not grades). + * + * @param assign Assign. + * @param submission Submission. + * @param offlineData Offline data. + * @param siteId Site ID. + * @return Promise resolved when done. + */ + protected async deleteSubmissionData( + assign: AddonModAssignAssign, + offlineData: AddonModAssignSubmissionsDBRecordFormatted, + submission?: AddonModAssignSubmission, + siteId?: string, + ): Promise { + + // Delete the offline data. + await AddonModAssignOffline.instance.deleteSubmission(assign.id, offlineData.userid, siteId); + + if (submission?.plugins){ + // Delete plugins data. + await Promise.all(submission.plugins.map((plugin) => + AddonModAssignSubmissionDelegate.instance.deletePluginOfflineData( + assign, + submission, + plugin, + offlineData, + siteId, + ))); + } + } + + /** + * Synchronize a submission grade. + * + * @param assign Assignment. + * @param offlineData Submission grade offline data. + * @param warnings List of warnings. + * @param courseId Course Id. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if success, rejected otherwise. + */ + protected async syncSubmissionGrade( + assign: AddonModAssignAssign, + offlineData: AddonModAssignSubmissionsGradingDBRecordFormatted, + warnings: string[], + courseId: number, + siteId: string, + ): Promise { + + const userId = offlineData.userid; + const syncId = this.getGradeSyncId(assign.id, userId); + const options: AddonModAssignSubmissionStatusOptions = { + userId, + cmId: assign.cmid, + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + siteId, + }; + + // Check if this grade sync is blocked. + if (CoreSync.instance.isBlocked(AddonModAssignProvider.COMPONENT, syncId, siteId)) { + this.logger.error(`Cannot sync grade for assign ${assign.id} and user ${userId} because it is blocked.!!!!`); + + throw new CoreSyncBlockedError(Translate.instance.instant( + 'core.errorsyncblocked', + { $a: Translate.instance.instant('addon.mod_assign.syncblockedusercomponent') }, + )); + } + + const status = await AddonModAssign.instance.getSubmissionStatus(assign.id, options); + + const timemodified = (status.feedback && (status.feedback.gradeddate || status.feedback.grade.timemodified)) || 0; + + if (timemodified > offlineData.timemodified) { + // The submission grade was modified in Moodle, discard it. + this.addOfflineDataDeletedWarning( + warnings, + this.componentTranslate, + assign.name, + Translate.instance.instant('addon.mod_assign.warningsubmissiongrademodified'), + ); + + return AddonModAssignOffline.instance.deleteSubmissionGrade(assign.id, userId, siteId); + } + + // If grade has been modified from gradebook, do not use offline. + const grades: CoreGradesFormattedItem[] | CoreGradesFormattedRow[] = + await CoreGradesHelper.instance.getGradeModuleItems(courseId, assign.cmid, userId, undefined, siteId, true); + + const gradeInfo = await CoreCourse.instance.getModuleBasicGradeInfo(assign.cmid, siteId); + + // Override offline grade and outcomes based on the gradebook data. + grades.forEach((grade: CoreGradesFormattedItem | CoreGradesFormattedRow) => { + if ('gradedategraded' in grade && (grade.gradedategraded || 0) >= offlineData.timemodified) { + if (!grade.outcomeid && !grade.scaleid) { + if (gradeInfo && gradeInfo.scale) { + offlineData.grade = this.getSelectedScaleId(gradeInfo.scale, grade.grade || ''); + } else { + offlineData.grade = parseFloat(grade.grade || '') || undefined; + } + } else if (gradeInfo && grade.outcomeid && AddonModAssign.instance.isOutcomesEditEnabled() && gradeInfo.outcomes) { + gradeInfo.outcomes.forEach((outcome, index) => { + if (outcome.scale && grade.itemnumber == index) { + offlineData.outcomes[grade.itemnumber] = this.getSelectedScaleId( + outcome.scale, + grade.grade || '', + ); + } + }); + } + } + }); + + try { + // Now submit the grade. + await AddonModAssign.instance.submitGradingFormOnline( + assign.id, + userId, + offlineData.grade, + offlineData.attemptnumber, + !!offlineData.addattempt, + offlineData.workflowstate, + !!offlineData.applytoall, + offlineData.outcomes, + offlineData.plugindata, + siteId, + ); + + // Grades sent. Discard grades drafts. + let promises: Promise[] = []; + if (status.feedback && status.feedback.plugins) { + promises = status.feedback.plugins.map((plugin) => + AddonModAssignFeedbackDelegate.instance.discardPluginFeedbackData(assign.id, userId, plugin, siteId)); + } + + // Update cached data. + promises.push(AddonModAssign.instance.getSubmissionStatus(assign.id, options)); + + await CoreUtils.instance.allPromises(promises); + } catch (error) { + if (!error || !CoreUtils.instance.isWebServiceError(error)) { + // Local error, reject. + throw error; + } + + // A WebService has thrown an error, this means it cannot be submitted. Discard the submission. + this.addOfflineDataDeletedWarning( + warnings, + this.componentTranslate, + assign.name, + CoreTextUtils.instance.getErrorMessageFromError(error) || '', + ); + } + + // Delete the offline data. + await AddonModAssignOffline.instance.deleteSubmissionGrade(assign.id, userId, siteId); + } + +} +export const AddonModAssignSync = makeSingleton(AddonModAssignSyncProvider); + +/** + * Data returned by a assign sync. + */ +export type AddonModAssignSyncResult = { + warnings: string[]; // List of warnings. + updated: boolean; // Whether some data was sent to the server or offline data was updated. + courseId?: number; // Course the assign belongs to (if known). + gradesBlocked: number[]; // Whether some grade couldn't be synced because it was blocked. UserId fields of the blocked grade. +}; + + +/** + * Data passed to AUTO_SYNCED event. + */ +export type AddonModAssignAutoSyncData = CoreEventSiteData & { + assignId: number; + warnings: string[]; + gradesBlocked: number[]; // Whether some grade couldn't be synced because it was blocked. UserId fields of the blocked grade. +}; + +/** + * Data passed to MANUAL_SYNCED event. + */ +export type AddonModAssignManualSyncData = AddonModAssignAutoSyncData & { + context: string; + submitId?: number; +}; diff --git a/src/addons/mod/assign/services/assign.ts b/src/addons/mod/assign/services/assign.ts new file mode 100644 index 000000000..8ac694051 --- /dev/null +++ b/src/addons/mod/assign/services/assign.ts @@ -0,0 +1,1855 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreSites, CoreSitesCommonWSOptions, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; +import { CoreInterceptor } from '@classes/interceptor'; +import { CoreWSExternalWarning, CoreWSExternalFile } from '@services/ws'; +import { makeSingleton } from '@singletons'; +import { CoreCourseCommonModWSOptions } from '@features/course/services/course'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreGrades } from '@features/grades/services/grades'; +import { CoreFilepool } from '@services/filepool'; +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreCourseLogHelper } from '@features/course/services/log-helper'; +import { CoreError } from '@classes/errors/error'; +import { CoreApp } from '@services/app'; +import { CoreUtils } from '@services/utils/utils'; +import { AddonModAssignOffline } from './assign-offline'; +import { AddonModAssignSubmissionDelegate } from './submission-delegate'; + +const ROOT_CACHE_KEY = 'mmaModAssign:'; + +/** + * Service that provides some functions for assign. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModAssignProvider { + + static readonly COMPONENT = 'mmaModAssign'; + static readonly SUBMISSION_COMPONENT = 'mmaModAssignSubmission'; + static readonly UNLIMITED_ATTEMPTS = -1; + + // Submission status. + static readonly SUBMISSION_STATUS_NEW = 'new'; + static readonly SUBMISSION_STATUS_REOPENED = 'reopened'; + static readonly SUBMISSION_STATUS_DRAFT = 'draft'; + static readonly SUBMISSION_STATUS_SUBMITTED = 'submitted'; + + // "Re-open" methods (to retry the assign). + static readonly ATTEMPT_REOPEN_METHOD_NONE = 'none'; + static readonly ATTEMPT_REOPEN_METHOD_MANUAL = 'manual'; + + // Grading status. + static readonly GRADING_STATUS_GRADED = 'graded'; + static readonly GRADING_STATUS_NOT_GRADED = 'notgraded'; + static readonly MARKING_WORKFLOW_STATE_RELEASED = 'released'; + static readonly NEED_GRADING = 'needgrading'; + static readonly GRADED_FOLLOWUP_SUBMIT = 'gradedfollowupsubmit'; + + // Group submissions warnings. + static readonly WARN_GROUPS_REQUIRED = 'warnrequired'; + static readonly WARN_GROUPS_OPTIONAL = 'warnoptional'; + + // Events. + static readonly SUBMISSION_SAVED_EVENT = 'addon_mod_assign_submission_saved'; + static readonly SUBMITTED_FOR_GRADING_EVENT = 'addon_mod_assign_submitted_for_grading'; + static readonly GRADED_EVENT = 'addon_mod_assign_graded'; + + protected gradingOfflineEnabled: {[siteId: string]: boolean} = {}; + + /** + * Check if the user can submit in offline. This should only be used if submissionStatus.lastattempt.cansubmit cannot + * be used (offline usage). + * This function doesn't check if the submission is empty, it should be checked before calling this function. + * + * @param assign Assignment instance. + * @param submissionStatus Submission status returned by getSubmissionStatus. + * @return Whether it can submit. + */ + canSubmitOffline(assign: AddonModAssignAssign, submissionStatus: AddonModAssignGetSubmissionStatusWSResponse): boolean { + if (!this.isSubmissionOpen(assign, submissionStatus)) { + return false; + } + + const userSubmission = submissionStatus.lastattempt?.submission; + const teamSubmission = submissionStatus.lastattempt?.teamsubmission; + + if (teamSubmission) { + if (teamSubmission.status === AddonModAssignProvider.SUBMISSION_STATUS_SUBMITTED) { + // The assignment submission has been completed. + return false; + } else if (userSubmission && userSubmission.status === AddonModAssignProvider.SUBMISSION_STATUS_SUBMITTED) { + // The user has already clicked the submit button on the team submission. + return false; + } else if (assign.preventsubmissionnotingroup && !submissionStatus.lastattempt?.submissiongroup) { + return false; + } + } else if (userSubmission) { + if (userSubmission.status === AddonModAssignProvider.SUBMISSION_STATUS_SUBMITTED) { + // The assignment submission has been completed. + return false; + } + } else { + // No valid submission or team submission. + return false; + } + + // Last check is that this instance allows drafts. + return !!assign.submissiondrafts; + } + + /** + * Fix some submission status params. + * + * @param site Site to use. + * @param userId User Id (empty for current user). + * @param groupId Group Id (empty for all participants). + * @param isBlind If blind marking is enabled or not. + * @return Object with fixed params. + */ + protected fixSubmissionStatusParams( + site: CoreSite, + userId?: number, + groupId?: number, + isBlind = false, + ): AddonModAssignFixedSubmissionParams { + + return { + isBlind: !userId ? false : !!isBlind, + groupId: site.isVersionGreaterEqualThan('3.5') ? groupId || 0 : 0, + userId: userId || site.getUserId(), + }; + } + + /** + * Get an assignment by course module ID. + * + * @param courseId Course ID the assignment belongs to. + * @param cmId Assignment module ID. + * @param options Other options. + * @return Promise resolved with the assignment. + */ + getAssignment(courseId: number, cmId: number, options: CoreSitesCommonWSOptions = {}): Promise { + return this.getAssignmentByField(courseId, 'cmid', cmId, options); + } + + /** + * Get an assigment with key=value. If more than one is found, only the first will be returned. + * + * @param courseId Course ID. + * @param key Name of the property to check. + * @param value Value to search. + * @param options Other options. + * @return Promise resolved when the assignment is retrieved. + */ + protected async getAssignmentByField( + courseId: number, + key: string, + value: number, + options: CoreSitesCommonWSOptions = {}, + ): Promise { + const site = await CoreSites.instance.getSite(options.siteId); + + const params: AddonModAssignGetAssignmentsWSParams = { + courseids: [courseId], + includenotenrolledcourses: true, + }; + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getAssignmentCacheKey(courseId), + updateFrequency: CoreSite.FREQUENCY_RARELY, + component: AddonModAssignProvider.COMPONENT, + ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + let response: AddonModAssignGetAssignmentsWSResponse; + + try { + response = await site.read('mod_assign_get_assignments', params, preSets); + } catch { + // In 3.6 we added a new parameter includenotenrolledcourses that could cause offline data not to be found. + // Retry again without the param to check if the request is already cached. + delete params.includenotenrolledcourses; + + response = await site.read('mod_assign_get_assignments', params, preSets); + } + + // Search the assignment to return. + if (response.courses.length) { + const assignment = response.courses[0].assignments.find((assignment) => assignment[key] == value); + + if (assignment) { + return assignment; + } + } + + throw new CoreError('Assignment not found'); + } + + /** + * Get an assignment by instance ID. + * + * @param courseId Course ID the assignment belongs to. + * @param id Assignment instance ID. + * @param options Other options. + * @return Promise resolved with the assignment. + */ + getAssignmentById(courseId: number, id: number, options: CoreSitesCommonWSOptions = {}): Promise { + return this.getAssignmentByField(courseId, 'id', id, options); + } + + /** + * Get cache key for assignment data WS calls. + * + * @param courseId Course ID. + * @return Cache key. + */ + protected getAssignmentCacheKey(courseId: number): string { + return ROOT_CACHE_KEY + 'assignment:' + courseId; + } + + /** + * Get an assignment user mapping for blind marking. + * + * @param assignId Assignment Id. + * @param userId User Id to be blinded. + * @param options Other options. + * @return Promise resolved with the user blind id. + */ + async getAssignmentUserMappings(assignId: number, userId: number, options: CoreCourseCommonModWSOptions = {}): Promise { + if (!userId || userId < 0) { + // User not valid, stop. + return -1; + } + + const site = await CoreSites.instance.getSite(options.siteId); + + const params: AddonModAssignGetUserMappingsWSParams = { + assignmentids: [assignId], + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getAssignmentUserMappingsCacheKey(assignId), + updateFrequency: CoreSite.FREQUENCY_OFTEN, + component: AddonModAssignProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), + }; + + const response = await site.read('mod_assign_get_user_mappings', params, preSets); + + // Search the user. + if (response.assignments.length && response.assignments[0].assignmentid == assignId) { + const mapping = response.assignments[0].mappings.find((mapping) => mapping.userid == userId); + + if (mapping) { + return mapping.id; + } + } else if (response.warnings && response.warnings.length) { + throw response.warnings[0]; + } + + throw new CoreError('Assignment user mappings not found'); + } + + /** + * Get cache key for assignment user mappings data WS calls. + * + * @param assignId Assignment ID. + * @return Cache key. + */ + protected getAssignmentUserMappingsCacheKey(assignId: number): string { + return ROOT_CACHE_KEY + 'usermappings:' + assignId; + } + + /** + * Returns grade information from assign_grades for the requested assignment id + * + * @param assignId Assignment Id. + * @param options Other options. + * @return Resolved with requested info when done. + */ + async getAssignmentGrades(assignId: number, options: CoreCourseCommonModWSOptions = {}): Promise { + const site = await CoreSites.instance.getSite(options.siteId); + + const params: AddonModAssignGetGradesWSParams = { + assignmentids: [assignId], + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getAssignmentGradesCacheKey(assignId), + component: AddonModAssignProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), + }; + + const response = await site.read('mod_assign_get_grades', params, preSets); + + // Search the assignment. + if (response.assignments.length && response.assignments[0].assignmentid == assignId) { + return response.assignments[0].grades; + } else if (response.warnings && response.warnings.length) { + if (response.warnings[0].warningcode == '3') { + // No grades found. + return []; + } + + throw response.warnings[0]; + } + + throw new CoreError('Assignment grades not found.'); + } + + /** + * Get cache key for assignment grades data WS calls. + * + * @param assignId Assignment ID. + * @return Cache key. + */ + protected getAssignmentGradesCacheKey(assignId: number): string { + return ROOT_CACHE_KEY + 'assigngrades:' + assignId; + } + + /** + * Returns the color name for a given grading status name. + * + * @param status Grading status name + * @return The color name. + */ + getSubmissionGradingStatusColor(status: string): string { + if (!status) { + return ''; + } + + if (status == AddonModAssignProvider.GRADING_STATUS_GRADED || + status == AddonModAssignProvider.MARKING_WORKFLOW_STATE_RELEASED) { + return 'success'; + } + + return 'danger'; + } + + /** + * Returns the translation id for a given grading status name. + * + * @param status Grading Status name + * @return The status translation identifier. + */ + getSubmissionGradingStatusTranslationId(status?: string): string | undefined { + if (!status) { + return; + } + + if (status == AddonModAssignProvider.GRADING_STATUS_GRADED || status == AddonModAssignProvider.GRADING_STATUS_NOT_GRADED + || status == AddonModAssignProvider.GRADED_FOLLOWUP_SUBMIT) { + return 'addon.mod_assign.' + status; + } + + return 'addon.mod_assign.markingworkflowstate' + status; + } + + /** + * Get the submission object from an attempt. + * + * @param assign Assign. + * @param attempt Attempt. + * @return Submission object or null. + */ + getSubmissionObjectFromAttempt( + assign: AddonModAssignAssign, + attempt: AddonModAssignSubmissionAttempt | undefined, + ): AddonModAssignSubmission | undefined { + if (!attempt) { + return; + } + + return assign.teamsubmission ? attempt.teamsubmission : attempt.submission; + } + + /** + * Get attachments of a submission plugin. + * + * @param submissionPlugin Submission plugin. + * @return Submission plugin attachments. + */ + getSubmissionPluginAttachments(submissionPlugin: AddonModAssignPlugin): CoreWSExternalFile[] { + if (!submissionPlugin.fileareas) { + return []; + } + + const files: CoreWSExternalFile[] = []; + + submissionPlugin.fileareas.forEach((filearea) => { + if (!filearea || !filearea.files) { + // No files to get. + return; + } + + filearea.files.forEach((file) => { + if (!file.filename) { + // We don't have filename, extract it from the path. + file.filename = file.filepath?.charAt(0) == '/' ? file.filepath.substr(1) : file.filepath; + } + + files.push(file); + }); + }); + + return files; + } + + /** + * Get text of a submission plugin. + * + * @param submissionPlugin Submission plugin. + * @param keepUrls True if it should keep original URLs, false if they should be replaced. + * @return Submission text. + */ + getSubmissionPluginText(submissionPlugin: AddonModAssignPlugin, keepUrls = false): string { + if (!submissionPlugin.editorfields) { + return ''; + } + let text = ''; + + submissionPlugin.editorfields.forEach((field) => { + text += field.text; + }); + + if (!keepUrls && submissionPlugin.fileareas && submissionPlugin.fileareas[0]) { + text = CoreTextUtils.instance.replacePluginfileUrls(text, submissionPlugin.fileareas[0].files || []); + } + + return text; + } + + /** + * Get an assignment submissions. + * + * @param assignId Assignment id. + * @param options Other options. + * @return Promise resolved when done. + */ + async getSubmissions( + assignId: number, + options: CoreCourseCommonModWSOptions = {}, + ): Promise<{ canviewsubmissions: boolean; submissions?: AddonModAssignSubmission[] }> { + const site = await CoreSites.instance.getSite(options.siteId); + + const params: ModAssignGetSubmissionsWSParams = { + assignmentids: [assignId], + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getSubmissionsCacheKey(assignId), + updateFrequency: CoreSite.FREQUENCY_OFTEN, + component: AddonModAssignProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), + }; + const response = await site.read('mod_assign_get_submissions', params, preSets); + + // Check if we can view submissions, with enough permissions. + if (response.warnings?.length && response.warnings[0].warningcode == '1') { + return { canviewsubmissions: false }; + } + + if (response.assignments && response.assignments.length) { + return { + canviewsubmissions: true, + submissions: response.assignments[0].submissions, + }; + } + + throw new CoreError('Assignment submissions not found'); + } + + /** + * Get cache key for assignment submissions data WS calls. + * + * @param assignId Assignment id. + * @return Cache key. + */ + protected getSubmissionsCacheKey(assignId: number): string { + return ROOT_CACHE_KEY + 'submissions:' + assignId; + } + + /** + * Get information about an assignment submission status for a given user. + * + * @param assignId Assignment instance id. + * @param options Other options. + * @return Promise always resolved with the user submission status. + */ + async getSubmissionStatus( + assignId: number, + options: AddonModAssignSubmissionStatusOptions = {}, + ): Promise { + const site = await CoreSites.instance.getSite(options.siteId); + + options = { + filter: true, + ...options, + }; + + const fixedParams = this.fixSubmissionStatusParams(site, options.userId, options.groupId, options.isBlind); + const params: AddonModAssignGetSubmissionStatusWSParams = { + assignid: assignId, + userid: fixedParams.userId, + }; + if (fixedParams.groupId) { + params.groupid = fixedParams.groupId; + } + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getSubmissionStatusCacheKey( + assignId, + fixedParams.userId, + fixedParams.groupId, + fixedParams.isBlind, + ), + getCacheUsingCacheKey: true, + filter: options.filter, + rewriteurls: options.filter, + component: AddonModAssignProvider.COMPONENT, + componentId: options.cmId, + // Don't cache when getting text without filters. + // @todo Change this to support offline editing. + saveToCache: options.filter, + ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), + }; + + return site.read('mod_assign_get_submission_status', params, preSets); + } + + /** + * Get information about an assignment submission status for a given user. + * If the data doesn't include the user submission, retry ignoring cache. + * + * @param assign Assignment. + * @param options Other options. + * @return Promise always resolved with the user submission status. + */ + async getSubmissionStatusWithRetry( + assign: AddonModAssignAssign, + options: AddonModAssignSubmissionStatusOptions = {}, + ): Promise { + options.cmId = options.cmId || assign.cmid; + + const response = await this.getSubmissionStatus(assign.id, options); + + const userSubmission = this.getSubmissionObjectFromAttempt(assign, response.lastattempt); + if (userSubmission) { + return response; + } + // Try again, ignoring cache. + const newOptions = { + ...options, + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + }; + + try { + return this.getSubmissionStatus(assign.id, newOptions); + } catch { + // Error, return the first result even if it doesn't have the user submission. + return response; + } + } + + /** + * Get cache key for get submission status data WS calls. + * + * @param assignId Assignment instance id. + * @param userId User id (empty for current user). + * @param groupId Group Id (empty for all participants). + * @param isBlind If blind marking is enabled or not. + * @return Cache key. + */ + protected getSubmissionStatusCacheKey(assignId: number, userId: number, groupId?: number, isBlind = false): string { + return this.getSubmissionsCacheKey(assignId) + ':' + userId + ':' + (isBlind ? 1 : 0) + ':' + groupId; + } + + /** + * Returns the color name for a given status name. + * + * @param status Status name + * @return The color name. + */ + getSubmissionStatusColor(status: string): string { + switch (status) { + case 'submitted': + return 'success'; + case 'draft': + return 'info'; + case 'new': + case 'noattempt': + case 'noonlinesubmissions': + case 'nosubmission': + case 'gradedfollowupsubmit': + return 'danger'; + default: + return 'light'; + } + } + + /** + * Given a list of plugins, returns the plugin names that aren't supported for editing. + * + * @param plugins Plugins to check. + * @return Promise resolved with unsupported plugin names. + */ + async getUnsupportedEditPlugins(plugins: AddonModAssignPlugin[]): Promise { + const notSupported: string[] = []; + const promises = plugins.map((plugin) => + AddonModAssignSubmissionDelegate.instance.isPluginSupportedForEdit(plugin.type).then((enabled) => { + if (!enabled) { + notSupported.push(plugin.name); + } + + return; + })); + + await Promise.all(promises); + + return notSupported; + } + + /** + * List the participants for a single assignment, with some summary info about their submissions. + * + * @param assignId Assignment id. + * @param groupId Group id. If not defined, 0. + * @param options Other options. + * @return Promise resolved with the list of participants and summary of submissions. + */ + async listParticipants( + assignId: number, + groupId?: number, + options: CoreCourseCommonModWSOptions = {}, + ): Promise { + + groupId = groupId || 0; + + const site = await CoreSites.instance.getSite(options.siteId); + if (!site.wsAvailable('mod_assign_list_participants')) { + // Silently fail if is not available. (needs Moodle version >= 3.2) + throw new CoreError('mod_assign_list_participants WS is only available 3.2 onwards'); + } + + const params: AddonModAssignListParticipantsWSParams = { + assignid: assignId, + groupid: groupId, + filter: '', + }; + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.listParticipantsCacheKey(assignId, groupId), + updateFrequency: CoreSite.FREQUENCY_OFTEN, + component: AddonModAssignProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), + }; + + return site.read('mod_assign_list_participants', params, preSets); + } + + /** + * Get cache key for assignment list participants data WS calls. + * + * @param assignId Assignment id. + * @param groupId Group id. + * @return Cache key. + */ + protected listParticipantsCacheKey(assignId: number, groupId: number): string { + return this.listParticipantsPrefixCacheKey(assignId) + ':' + groupId; + } + + /** + * Get prefix cache key for assignment list participants data WS calls. + * + * @param assignId Assignment id. + * @return Cache key. + */ + protected listParticipantsPrefixCacheKey(assignId: number): string { + return ROOT_CACHE_KEY + 'participants:' + assignId; + } + + /** + * Invalidates all submission status data. + * + * @param assignId Assignment instance id. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateAllSubmissionData(assignId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getSubmissionsCacheKey(assignId)); + } + + /** + * Invalidates assignment data WS calls. + * + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateAssignmentData(courseId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getAssignmentCacheKey(courseId)); + } + + /** + * Invalidates assignment user mappings data WS calls. + * + * @param assignId Assignment ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateAssignmentUserMappingsData(assignId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getAssignmentUserMappingsCacheKey(assignId)); + } + + /** + * Invalidates assignment grades data WS calls. + * + * @param assignId Assignment ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateAssignmentGradesData(assignId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getAssignmentGradesCacheKey(assignId)); + } + + /** + * Invalidate the prefetched content except files. + * To invalidate files, use AddonModAssignProvider.invalidateFiles. + * + * @param moduleId The module ID. + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateContent(moduleId: number, courseId: number, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const assign = await this.getAssignment(courseId, moduleId, { siteId }); + const promises: Promise[] = []; + // Do not invalidate assignment data before getting assignment info, we need it! + promises.push(this.invalidateAllSubmissionData(assign.id, siteId)); + promises.push(this.invalidateAssignmentUserMappingsData(assign.id, siteId)); + promises.push(this.invalidateAssignmentGradesData(assign.id, siteId)); + promises.push(this.invalidateListParticipantsData(assign.id, siteId)); + // @todo promises.push(CoreComments.instance.invalidateCommentsByInstance('module', assign.id, siteId)); + promises.push(this.invalidateAssignmentData(courseId, siteId)); + promises.push(CoreGrades.instance.invalidateAllCourseGradesData(courseId)); + + + await Promise.all(promises); + } + + /** + * Invalidate the prefetched files. + * + * @param moduleId The module ID. + * @return Promise resolved when the files are invalidated. + */ + async invalidateFiles(moduleId: number): Promise { + await CoreFilepool.instance.invalidateFilesByComponent( + CoreSites.instance.getCurrentSiteId(), + AddonModAssignProvider.COMPONENT, + moduleId, + ); + } + + /** + * Invalidates assignment submissions data WS calls. + * + * @param assignId Assignment instance id. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateSubmissionData(assignId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getSubmissionsCacheKey(assignId)); + } + + /** + * Invalidates submission status data. + * + * @param assignId Assignment instance id. + * @param userId User id (empty for current user). + * @param groupId Group Id (empty for all participants). + * @param isBlind Whether blind marking is enabled or not. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateSubmissionStatusData( + assignId: number, + userId?: number, + groupId?: number, + isBlind = false, + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + const fixedParams = this.fixSubmissionStatusParams(site, userId, groupId, isBlind); + + await site.invalidateWsCacheForKey(this.getSubmissionStatusCacheKey( + assignId, + fixedParams.userId, + fixedParams.groupId, + fixedParams.isBlind, + )); + } + + /** + * Invalidates assignment participants data. + * + * @param assignId Assignment instance id. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateListParticipantsData(assignId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.listParticipantsPrefixCacheKey(assignId)); + } + + /** + * Convenience function to check if grading offline is enabled. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: whether grading offline is enabled. + */ + protected async isGradingOfflineEnabled(siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + if (typeof this.gradingOfflineEnabled[siteId] != 'undefined') { + return this.gradingOfflineEnabled[siteId]; + } + + this.gradingOfflineEnabled[siteId] = await CoreGrades.instance.isGradeItemsAvalaible(siteId); + + return this.gradingOfflineEnabled[siteId]; + } + + /** + * Outcomes only can be edited if mod_assign_submit_grading_form is avalaible. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with true if outcomes edit is enabled, rejected or resolved with false otherwise. + * @since 3.2 + */ + async isOutcomesEditEnabled(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return site.wsAvailable('mod_assign_submit_grading_form'); + } + + /** + * Check if assignments plugin is enabled in a certain site. + * + * @param siteId Site ID. If not defined, current site. + * @return Whether the plugin is enabled. + */ + isPluginEnabled(): boolean { + return true; + } + + /** + * Check if a submission is open. This function is based on Moodle's submissions_open. + * + * @param assign Assignment instance. + * @param submissionStatus Submission status returned by getSubmissionStatus. + * @return Whether submission is open. + */ + isSubmissionOpen(assign: AddonModAssignAssign, submissionStatus?: AddonModAssignGetSubmissionStatusWSResponse): boolean { + if (!assign || !submissionStatus) { + return false; + } + + const time = CoreTimeUtils.instance.timestamp(); + const lastAttempt = submissionStatus.lastattempt; + const submission = this.getSubmissionObjectFromAttempt(assign, lastAttempt); + + let dateOpen = true; + let finalDate: number | undefined; + + if (assign.cutoffdate) { + finalDate = assign.cutoffdate; + } + + if (lastAttempt && lastAttempt.locked) { + return false; + } + + // User extensions. + if (finalDate) { + if (lastAttempt && lastAttempt.extensionduedate) { + // Extension can be before cut off date. + if (lastAttempt.extensionduedate > finalDate) { + finalDate = lastAttempt.extensionduedate; + } + } + } + + if (finalDate) { + dateOpen = assign.allowsubmissionsfromdate <= time && time <= finalDate; + } else { + dateOpen = assign.allowsubmissionsfromdate <= time; + } + + if (!dateOpen) { + return false; + } + + if (submission) { + if (assign.submissiondrafts && submission.status == AddonModAssignProvider.SUBMISSION_STATUS_SUBMITTED) { + // Drafts are tracked and the student has submitted the assignment. + return false; + } + } + + return true; + } + + /** + * Report an assignment submission as being viewed. + * + * @param assignid Assignment ID. + * @param name Name of the assign. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the WS call is successful. + */ + async logSubmissionView(assignid: number, name?: string, siteId?: string): Promise { + const params: AddonModAssignViewSubmissionStatusWSParams = { + assignid, + }; + + await CoreCourseLogHelper.instance.logSingle( + 'mod_assign_view_submission_status', + params, + AddonModAssignProvider.COMPONENT, + assignid, + name, + 'assign', + {}, + siteId, + ); + } + + /** + * Report an assignment grading table is being viewed. + * + * @param assignid Assignment ID. + * @param name Name of the assign. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the WS call is successful. + */ + async logGradingView(assignid: number, name?: string, siteId?: string): Promise { + const params: AddonModAssignViewGradingTableWSParams = { + assignid, + }; + + await CoreCourseLogHelper.instance.logSingle( + 'mod_assign_view_grading_table', + params, + AddonModAssignProvider.COMPONENT, + assignid, + name, + 'assign', + {}, + siteId, + ); + } + + /** + * Report an assign as being viewed. + * + * @param assignid Assignment ID. + * @param name Name of the assign. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the WS call is successful. + */ + async logView(assignid: number, name?: string, siteId?: string): Promise { + const params: AddonModAssignViewAssignWSParams = { + assignid, + }; + + await CoreCourseLogHelper.instance.logSingle( + 'mod_assign_view_assign', + params, + AddonModAssignProvider.COMPONENT, + assignid, + name, + 'assign', + {}, + siteId, + ); + } + + /** + * Returns if a submissions needs to be graded. + * + * @param submission Submission. + * @param assignId Assignment ID. + * @return Promise resolved with boolean: whether it needs to be graded or not. + */ + async needsSubmissionToBeGraded(submission: any, assignId: number): Promise { + if (!submission.gradingstatus) { + // This should not happen, but it's better to show rather than not showing any of the submissions. + return true; + } + + if (submission.gradingstatus != AddonModAssignProvider.GRADING_STATUS_GRADED && + submission.gradingstatus != AddonModAssignProvider.MARKING_WORKFLOW_STATE_RELEASED) { + // Not graded. + return true; + } + + // We need more data to decide that. + const response = await this.getSubmissionStatus(assignId, { + userId: submission.submitid, + isBlind: !!submission.blindid, + }); + + if (!response.feedback || !response.feedback.gradeddate) { + // Not graded. + return true; + } + + return response.feedback.gradeddate < submission.timemodified; + } + + /** + * Save current user submission for a certain assignment. + * + * @param assignId Assign ID. + * @param courseId Course ID the assign belongs to. + * @param pluginData Data to save. + * @param allowOffline Whether to allow offline usage. + * @param timemodified The time the submission was last modified in online. + * @param allowsDrafts Whether the assignment allows submission drafts. + * @param userId User ID. If not defined, site's current user. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with true if sent to server, resolved with false if stored in offline. + */ + async saveSubmission( + assignId: number, + courseId: number, + pluginData: AddonModAssignSavePluginData, + allowOffline: boolean, + timemodified: number, + allowsDrafts = false, + userId?: number, + siteId?: string, + ): Promise { + + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + // Function to store the submission to be synchronized later. + const storeOffline = async (): Promise => { + await AddonModAssignOffline.instance.saveSubmission( + assignId, + courseId, + pluginData, + timemodified, + !allowsDrafts, + userId, + siteId, + ); + + return false; + }; + + if (allowOffline && !CoreApp.instance.isOnline()) { + // App is offline, store the action. + return storeOffline(); + } + + try { + // If there's already a submission to be sent to the server, discard it first. + await AddonModAssignOffline.instance.deleteSubmission(assignId, userId, siteId); + await this.saveSubmissionOnline(assignId, pluginData, siteId); + + return true; + } catch (error) { + if (allowOffline && error && !CoreUtils.instance.isWebServiceError(error)) { + // Couldn't connect to server, store in offline. + return storeOffline(); + } else { + // The WebService has thrown an error or offline not supported, reject. + throw error; + } + } + } + + /** + * Save current user submission for a certain assignment. It will fail if offline or cannot connect. + * + * @param assignId Assign ID. + * @param pluginData Data to save. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when saved, rejected otherwise. + */ + async saveSubmissionOnline(assignId: number, pluginData: AddonModAssignSavePluginData, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + const params: AddonModAssignSaveSubmissionWSParams = { + assignmentid: assignId, + plugindata: pluginData, + }; + const warnings = await site.write('mod_assign_save_submission', params); + + if (warnings.length) { + // The WebService returned warnings, reject. + throw warnings[0]; + } + } + + /** + * Submit the current user assignment for grading. + * + * @param assignId Assign ID. + * @param courseId Course ID the assign belongs to. + * @param acceptStatement True if submission statement is accepted, false otherwise. + * @param timemodified The time the submission was last modified in online. + * @param forceOffline True to always mark it in offline. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with true if sent to server, resolved with false if stored in offline. + */ + async submitForGrading( + assignId: number, + courseId: number, + acceptStatement: boolean, + timemodified: number, + forceOffline = false, + siteId?: string, + ): Promise { + + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + // Function to store the submission to be synchronized later. + const storeOffline = async (): Promise => { + await AddonModAssignOffline.instance.markSubmitted( + assignId, + courseId, + true, + acceptStatement, + timemodified, + undefined, + siteId, + ); + + return false; + }; + + if (forceOffline || !CoreApp.instance.isOnline()) { + // App is offline, store the action. + return storeOffline(); + } + + try { + // If there's already a submission to be sent to the server, discard it first. + await AddonModAssignOffline.instance.deleteSubmission(assignId, undefined, siteId); + await this.submitForGradingOnline(assignId, acceptStatement, siteId); + + return true; + } catch (error) { + if (error && !CoreUtils.instance.isWebServiceError(error)) { + // Couldn't connect to server, store in offline. + return storeOffline(); + } else { + // The WebService has thrown an error, reject. + throw error; + } + } + } + + /** + * Submit the current user assignment for grading. It will fail if offline or cannot connect. + * + * @param assignId Assign ID. + * @param acceptStatement True if submission statement is accepted, false otherwise. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when submitted, rejected otherwise. + */ + async submitForGradingOnline(assignId: number, acceptStatement: boolean, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const params: AddonModAssignSubmitForGradingWSParams = { + assignmentid: assignId, + acceptsubmissionstatement: acceptStatement, + }; + + const warnings = await site.write('mod_assign_submit_for_grading', params); + + if (warnings.length) { + // The WebService returned warnings, reject. + throw warnings[0]; + } + } + + /** + * Submit the grading for the current user and assignment. It will use old or new WS depending on availability. + * + * @param assignId Assign ID. + * @param userId User ID. + * @param courseId Course ID the assign belongs to. + * @param grade Grade to submit. + * @param attemptNumber Number of the attempt being graded. + * @param addAttempt Admit the user to attempt again. + * @param workflowState Next workflow State. + * @param applyToAll If it's a team submission, whether the grade applies to all group members. + * @param outcomes Object including all outcomes values. If empty, any of them will be sent. + * @param pluginData Feedback plugin data to save. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with true if sent to server, resolved with false if stored offline. + */ + async submitGradingForm( + assignId: number, + userId: number, + courseId: number, + grade: number, + attemptNumber: number, + addAttempt: boolean, + workflowState: string, + applyToAll: boolean, + outcomes: AddonModAssignOutcomes, + pluginData: AddonModAssignSavePluginData, + siteId?: string, + ): Promise { + + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + // Function to store the grading to be synchronized later. + const storeOffline = async (): Promise => { + await AddonModAssignOffline.instance.submitGradingForm( + assignId, + userId, + courseId, + grade, + attemptNumber, + addAttempt, + workflowState, + applyToAll, + outcomes, + pluginData, + siteId, + ); + + return false; + }; + + // Grading offline is only allowed if WS of grade items is enabled to avoid inconsistency. + const enabled = await this.isGradingOfflineEnabled(siteId); + if (!enabled) { + await this.submitGradingFormOnline( + assignId, + userId, + grade, + attemptNumber, + addAttempt, + workflowState, + applyToAll, + outcomes, + pluginData, + siteId, + ); + + return true; + } + + if (!CoreApp.instance.isOnline()) { + // App is offline, store the action. + return storeOffline(); + } + + try { + // If there's already a grade to be sent to the server, discard it first. + await AddonModAssignOffline.instance.deleteSubmissionGrade(assignId, userId, siteId); + await this.submitGradingFormOnline( + assignId, + userId, + grade, + attemptNumber, + addAttempt, + workflowState, + applyToAll, + outcomes, + pluginData, + siteId, + ); + + return true; + } catch (error) { + if (error && !CoreUtils.instance.isWebServiceError(error)) { + // Couldn't connect to server, store in offline. + return storeOffline(); + } else { + // The WebService has thrown an error, reject. + throw error; + } + } + } + + /** + * Submit the grading for the current user and assignment. It will use old or new WS depending on availability. + * It will fail if offline or cannot connect. + * + * @param assignId Assign ID. + * @param userId User ID. + * @param grade Grade to submit. + * @param attemptNumber Number of the attempt being graded. + * @param addAttempt Allow the user to attempt again. + * @param workflowState Next workflow State. + * @param applyToAll If it's a team submission, if the grade applies to all group members. + * @param outcomes Object including all outcomes values. If empty, any of them will be sent. + * @param pluginData Feedback plugin data to save. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when submitted, rejected otherwise. + */ + async submitGradingFormOnline( + assignId: number, + userId: number, + grade: number | undefined, + attemptNumber: number, + addAttempt: boolean, + workflowState: string, + applyToAll: boolean, + outcomes: AddonModAssignOutcomes, + pluginData: AddonModAssignSavePluginData, + siteId?: string, + ): Promise { + + const site = await CoreSites.instance.getSite(siteId); + userId = userId || site.getUserId(); + + if (site.wsAvailable('mod_assign_submit_grading_form')) { + // WS available @since 3.2. + + const jsonData = { + grade, + attemptnumber: attemptNumber, + addattempt: addAttempt ? 1 : 0, + workflowstate: workflowState, + applytoall: applyToAll ? 1 : 0, + }; + + for (const index in outcomes) { + jsonData['outcome_' + index + '[' + userId + ']'] = outcomes[index]; + } + + for (const index in pluginData) { + jsonData[index] = pluginData[index]; + } + + const serialized = CoreInterceptor.serialize(jsonData, true); + const params: AddonModAssignSubmitGradingFormWSParams = { + assignmentid: assignId, + userid: userId, + jsonformdata: JSON.stringify(serialized), + }; + + const warnings = await site.write('mod_assign_submit_grading_form', params); + + if (warnings.length) { + // The WebService returned warnings, reject. + throw warnings[0]; + } + } + + // WS not available, fallback to save_grade. + const params: AddonModAssignSaveGradeWSParams = { + assignmentid: assignId, + userid: userId, + grade: grade, + attemptnumber: attemptNumber, + addattempt: addAttempt, + workflowstate: workflowState, + applytoall: applyToAll, + plugindata: pluginData, + }; + const preSets: CoreSiteWSPreSets = { + responseExpected: false, + }; + + await site.write('mod_assign_save_grade', params, preSets); + } + +} +export const AddonModAssign = makeSingleton(AddonModAssignProvider); + +/** + * Options to pass to get submission status. + */ +export type AddonModAssignSubmissionStatusOptions = CoreCourseCommonModWSOptions & { + userId?: number; // User Id (empty for current user). + groupId?: number; // Group Id (empty for all participants). + isBlind?: boolean; // If blind marking is enabled or not. + filter?: boolean; // True to filter WS response and rewrite URLs, false otherwise. Defaults to true. +}; + +/** + * Assign data returned by mod_assign_get_assignments. + */ +export type AddonModAssignAssign = { + id: number; // Assignment id. + cmid: number; // Course module id. + course: number; // Course id. + name: string; // Assignment name. + nosubmissions: number; // No submissions. + submissiondrafts: number; // Submissions drafts. + sendnotifications: number; // Send notifications. + sendlatenotifications: number; // Send notifications. + sendstudentnotifications: number; // Send student notifications (default). + duedate: number; // Assignment due date. + allowsubmissionsfromdate: number; // Allow submissions from date. + grade: number; // Grade type. + timemodified: number; // Last time assignment was modified. + completionsubmit: number; // If enabled, set activity as complete following submission. + cutoffdate: number; // Date after which submission is not accepted without an extension. + gradingduedate?: number; // @since 3.3. The expected date for marking the submissions. + teamsubmission: number; // If enabled, students submit as a team. + requireallteammemberssubmit: number; // If enabled, all team members must submit. + teamsubmissiongroupingid: number; // The grouping id for the team submission groups. + blindmarking: number; // If enabled, hide identities until reveal identities actioned. + hidegrader?: number; // @since 3.7. If enabled, hide grader to student. + revealidentities: number; // Show identities for a blind marking assignment. + attemptreopenmethod: string; // Method used to control opening new attempts. + maxattempts: number; // Maximum number of attempts allowed. + markingworkflow: number; // Enable marking workflow. + markingallocation: number; // Enable marking allocation. + requiresubmissionstatement: number; // Student must accept submission statement. + preventsubmissionnotingroup?: number; // @since 3.2. Prevent submission not in group. + submissionstatement?: string; // @since 3.2. Submission statement formatted. + submissionstatementformat?: number; // @since 3.2. Submissionstatement format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + configs: AddonModAssignConfig[]; // Configuration settings. + intro?: string; // Assignment intro, not allways returned because it deppends on the activity configuration. + introformat?: number; // Intro format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + introfiles?: CoreWSExternalFile[]; // @since 3.2. + introattachments?: CoreWSExternalFile[]; +}; + +/** + * Config setting in an assign. + */ +export type AddonModAssignConfig = { + id?: number; // Assign_plugin_config id. + assignment?: number; // Assignment id. + plugin: string; // Plugin. + subtype: string; // Subtype. + name: string; // Name. + value: string; // Value. +}; + +/** + * Grade of an assign, returned by mod_assign_get_grades. + */ +export type AddonModAssignGrade = { + id: number; // Grade id. + assignment?: number; // Assignment id. + userid: number; // Student id. + attemptnumber: number; // Attempt number. + timecreated: number; // Grade creation time. + timemodified: number; // Grade last modified time. + grader: number; // Grader, -1 if grader is hidden. + grade: string; // Grade. + gradefordisplay?: string; // Grade rendered into a format suitable for display. +}; + +/** + * Assign submission returned by mod_assign_get_submissions. + */ +export type AddonModAssignSubmission = { + id: number; // Submission id. + userid: number; // Student id. + attemptnumber: number; // Attempt number. + timecreated: number; // Submission creation time. + timemodified: number; // Submission last modified time. + status: string; // Submission status. + groupid: number; // Group id. + assignment?: number; // Assignment id. + latest?: number; // Latest attempt. + plugins?: AddonModAssignPlugin[]; // Plugins. + gradingstatus?: string; // @since 3.2. Grading status. +}; + +/** + * Assign plugin. + */ +export type AddonModAssignPlugin = { + type: string; // Submission plugin type. + name: string; // Submission plugin name. + fileareas?: { // Fileareas. + area: string; // File area. + files?: CoreWSExternalFile[]; + }[]; + editorfields?: { // Editorfields. + name: string; // Field name. + description: string; // Field description. + text: string; // Field value. + format: number; // Text format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + }[]; +}; + +/** + * Grading summary of an assign submission. + */ +export type AddonModAssignSubmissionGradingSummary = { + participantcount: number; // Number of users who can submit. + submissiondraftscount: number; // Number of submissions in draft status. + submissionsenabled: boolean; // Whether submissions are enabled or not. + submissionssubmittedcount: number; // Number of submissions in submitted status. + submissionsneedgradingcount: number; // Number of submissions that need grading. + warnofungroupedusers: string | boolean; // Whether we need to warn people about groups. +}; + +/** + * Attempt of an assign submission. + */ +export type AddonModAssignSubmissionAttempt = { + submission?: AddonModAssignSubmission; // Submission info. + teamsubmission?: AddonModAssignSubmission; // Submission info. + submissiongroup?: number; // The submission group id (for group submissions only). + submissiongroupmemberswhoneedtosubmit?: number[]; // List of users who still need to submit (for group submissions only). + submissionsenabled: boolean; // Whether submissions are enabled or not. + locked: boolean; // Whether new submissions are locked. + graded: boolean; // Whether the submission is graded. + canedit: boolean; // Whether the user can edit the current submission. + caneditowner?: boolean; // @since 3.2. Whether the owner of the submission can edit it. + cansubmit: boolean; // Whether the user can submit. + extensionduedate: number; // Extension due date. + blindmarking: boolean; // Whether blind marking is enabled. + gradingstatus: string; // Grading status. + usergroups: number[]; // User groups in the course. +}; + +/** + * Previous attempt of an assign submission. + */ +export type AddonModAssignSubmissionPreviousAttempt = { + attemptnumber: number; // Attempt number. + submission?: AddonModAssignSubmission; // Submission info. + grade?: AddonModAssignGrade; // Grade information. + feedbackplugins?: AddonModAssignPlugin[]; // Feedback info. +}; + +/** + * Feedback of an assign submission. + */ +export type AddonModAssignSubmissionFeedback = { + grade: AddonModAssignGrade; // Grade information. + gradefordisplay: string; // Grade rendered into a format suitable for display. + gradeddate: number; // The date the user was graded. + plugins?: AddonModAssignPlugin[]; // Plugins info. +}; + + +/** + * Params of mod_assign_list_participants WS. + */ +type AddonModAssignListParticipantsWSParams = { + assignid: number; // Assign instance id. + groupid: number; // Group id. + filter: string; // Search string to filter the results. + skip?: number; // Number of records to skip. + limit?: number; // Maximum number of records to return. + onlyids?: boolean; // Do not return all user fields. + includeenrolments?: boolean; // Do return courses where the user is enrolled. + tablesort?: boolean; // Apply current user table sorting preferences. +}; + +/** + * Data returned by mod_assign_list_participants WS. + */ +type AddonModAssignListParticipantsWSResponse = AddonModAssignParticipant[]; + +/** + * Participant returned by mod_assign_list_participants. + */ +export type AddonModAssignParticipant = { + id: number; // ID of the user. + username?: string; // The username. + firstname?: string; // The first name(s) of the user. + lastname?: string; // The family name of the user. + fullname: string; // The fullname of the user. + email?: string; // Email address. + address?: string; // Postal address. + phone1?: string; // Phone 1. + phone2?: string; // Phone 2. + icq?: string; // Icq number. + skype?: string; // Skype id. + yahoo?: string; // Yahoo id. + aim?: string; // Aim id. + msn?: string; // Msn number. + department?: string; // Department. + institution?: string; // Institution. + idnumber?: string; // The idnumber of the user. + interests?: string; // User interests (separated by commas). + firstaccess?: number; // First access to the site (0 if never). + lastaccess?: number; // Last access to the site (0 if never). + suspended?: boolean; // @since 3.2. Suspend user account, either false to enable user login or true to disable it. + description?: string; // User profile description. + descriptionformat?: number; // Int format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + city?: string; // Home city of the user. + url?: string; // URL of the user. + country?: string; // Home country code of the user, such as AU or CZ. + profileimageurlsmall?: string; // User image profile URL - small version. + profileimageurl?: string; // User image profile URL - big version. + customfields?: { // User custom fields (also known as user profile fields). + type: string; // The type of the custom field - text field, checkbox... + value: string; // The value of the custom field. + name: string; // The name of the custom field. + shortname: string; // The shortname of the custom field - to be able to build the field class in the code. + }[]; + preferences?: { // Users preferences. + name: string; // The name of the preferences. + value: string; // The value of the preference. + }[]; + recordid?: number; // @since 3.7. Record id. + groups?: { // User groups. + id: number; // Group id. + name: string; // Group name. + description: string; // Group description. + }[]; + roles?: { // User roles. + roleid: number; // Role id. + name: string; // Role name. + shortname: string; // Role shortname. + sortorder: number; // Role sortorder. + }[]; + enrolledcourses?: { // Courses where the user is enrolled - limited by which courses the user is able to see. + id: number; // Id of the course. + fullname: string; // Fullname of the course. + shortname: string; // Shortname of the course. + }[]; + submitted: boolean; // Have they submitted their assignment. + requiregrading: boolean; // Is their submission waiting for grading. + grantedextension?: boolean; // @since 3.3. Have they been granted an extension. + groupid?: number; // For group assignments this is the group id. + groupname?: string; // For group assignments this is the group name. +}; + +/** + * Result of WS mod_assign_get_assignments. + */ +export type AddonModAssignGetAssignmentsWSResponse = { + courses: { // List of courses. + id: number; // Course id. + fullname: string; // Course full name. + shortname: string; // Course short name. + timemodified: number; // Last time modified. + assignments: AddonModAssignAssign[]; // Assignment info. + }[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_assign_get_submissions WS. + */ +type ModAssignGetSubmissionsWSParams = { + assignmentids: number[]; // 1 or more assignment ids. + status?: string; // Status. + since?: number; // Submitted since. + before?: number; // Submitted before. +}; + +/** + * Data returned by mod_assign_get_submissions WS. + */ +export type AddonModAssignGetSubmissionsWSResponse = { + assignments: { // Assignment submissions. + assignmentid: number; // Assignment id. + submissions: AddonModAssignSubmission[]; + }[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_assign_get_submission_status WS. + */ +type AddonModAssignGetSubmissionStatusWSParams = { + assignid: number; // Assignment instance id. + userid?: number; // User id (empty for current user). + groupid?: number; // Filter by users in group (used for generating the grading summary). Empty or 0 for all groups information. +}; + + +/** + * Result of WS mod_assign_get_submission_status. + */ +export type AddonModAssignGetSubmissionStatusWSResponse = { + gradingsummary?: AddonModAssignSubmissionGradingSummary; // Grading information. + lastattempt?: AddonModAssignSubmissionAttempt; // Last attempt information. + feedback?: AddonModAssignSubmissionFeedback; // Feedback for the last attempt. + previousattempts?: AddonModAssignSubmissionPreviousAttempt[]; // List all the previous attempts did by the user. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_assign_view_submission_status WS. + */ +type AddonModAssignViewSubmissionStatusWSParams = { + assignid: number; // Assign instance id. +}; + +/** + * Params of mod_assign_view_grading_table WS. + */ +type AddonModAssignViewGradingTableWSParams = { + assignid: number; // Assign instance id. +}; + +/** + * Params of mod_assign_view_assign WS. + */ +type AddonModAssignViewAssignWSParams = { + assignid: number; // Assign instance id. +}; + +type AddonModAssignFixedSubmissionParams = { + userId: number; + groupId: number; + isBlind: boolean; +}; + +/** + * Params of mod_assign_get_assignments WS. + */ +type AddonModAssignGetAssignmentsWSParams = { + courseids?: number[]; // 0 or more course ids. + capabilities?: string[]; // List of capabilities used to filter courses. + includenotenrolledcourses?: boolean; // Whether to return courses that the user can see even if is not enroled in. + // This requires the parameter courseids to not be empty. + +}; + +/** + * Params of mod_assign_get_user_mappings WS. + */ +type AddonModAssignGetUserMappingsWSParams = { + assignmentids: number[]; // 1 or more assignment ids. +}; + +/** + * Data returned by mod_assign_get_user_mappings WS. + */ +export type AddonModAssignGetUserMappingsWSResponse = { + assignments: { // List of assign user mapping data. + assignmentid: number; // Assignment id. + mappings: { + id: number; // User mapping id. + userid: number; // Student id. + }[]; + }[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_assign_get_grades WS. + */ +type AddonModAssignGetGradesWSParams = { + assignmentids: number[]; // 1 or more assignment ids. + since?: number; // Timestamp, only return records where timemodified >= since. +}; + +/** + * Data returned by mod_assign_get_grades WS. + */ +export type AddonModAssignGetGradesWSResponse = { + assignments: { // List of assignment grade information. + assignmentid: number; // Assignment id. + grades: AddonModAssignGrade[]; + }[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_assign_save_submission WS. + */ +type AddonModAssignSaveSubmissionWSParams = { + assignmentid: number; // The assignment id to operate on. + plugindata: AddonModAssignSavePluginData; +}; + +/** + * All subplugins will decide what to add here. + */ +export type AddonModAssignSavePluginData = Record; + +/** + * Params of mod_assign_submit_for_grading WS. + */ +type AddonModAssignSubmitForGradingWSParams = { + assignmentid: number; // The assignment id to operate on. + acceptsubmissionstatement: boolean; // Accept the assignment submission statement. +}; + +/** + * Params of mod_assign_submit_grading_form WS. + */ +type AddonModAssignSubmitGradingFormWSParams = { + assignmentid: number; // The assignment id to operate on. + userid: number; // The user id the submission belongs to. + jsonformdata: string; // The data from the grading form, encoded as a json array. +}; + + +/** + * Params of mod_assign_save_grade WS. + */ +type AddonModAssignSaveGradeWSParams = { + assignmentid: number; // The assignment id to operate on. + userid: number; // The student id to operate on. + grade: number; // The new grade for this user. Ignored if advanced grading used. + attemptnumber: number; // The attempt number (-1 means latest attempt). + addattempt: boolean; // Allow another attempt if the attempt reopen method is manual. + workflowstate: string; // The next marking workflow state. + applytoall: boolean; // If true, this grade will be applied to all members of the group (for group assignments). + plugindata?: AddonModAssignSavePluginData; // Plugin data. + advancedgradingdata?: { + guide?: { + criteria: { + criterionid: number; // Criterion id. + fillings?: { // Filling. + criterionid: number; // Criterion id. + levelid?: number; // Level id. + remark?: string; // Remark. + remarkformat?: number; // Remark format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + score: number; // Maximum score. + }[]; + }[]; + }; // Items. + rubric?: { + criteria: { + criterionid: number; // Criterion id. + fillings?: { // Filling. + criterionid: number; // Criterion id. + levelid?: number; // Level id. + remark?: string; // Remark. + remarkformat?: number; // Remark format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + }[]; + }[]; + }; // Items. + }; // Advanced grading data. +}; + +/** + * Assignment grade outcomes. + */ +export type AddonModAssignOutcomes = { [itemNumber: number]: number }; diff --git a/src/addons/mod/assign/services/database/assign.ts b/src/addons/mod/assign/services/database/assign.ts new file mode 100644 index 000000000..703f3f27a --- /dev/null +++ b/src/addons/mod/assign/services/database/assign.ts @@ -0,0 +1,150 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreSiteSchema } from '@services/sites'; + +/** + * Database variables for AddonModAssignOfflineProvider. + */export const SUBMISSIONS_TABLE = 'addon_mod_assign_submissions'; +export const SUBMISSIONS_GRADES_TABLE = 'addon_mod_assign_submissions_grading'; +export const OFFLINE_SITE_SCHEMA: CoreSiteSchema = { + name: 'AddonModAssignOfflineProvider', + version: 1, + tables: [ + { + name: SUBMISSIONS_TABLE, + columns: [ + { + name: 'assignid', + type: 'INTEGER', + }, + { + name: 'courseid', + type: 'INTEGER', + }, + { + name: 'userid', + type: 'INTEGER', + }, + { + name: 'plugindata', + type: 'TEXT', + }, + { + name: 'onlinetimemodified', + type: 'INTEGER', + }, + { + name: 'timecreated', + type: 'INTEGER', + }, + { + name: 'timemodified', + type: 'INTEGER', + }, + { + name: 'submitted', + type: 'INTEGER', + }, + { + name: 'submissionstatement', + type: 'INTEGER', + }, + ], + primaryKeys: ['assignid', 'userid'], + }, + { + name: SUBMISSIONS_GRADES_TABLE, + columns: [ + { + name: 'assignid', + type: 'INTEGER', + }, + { + name: 'courseid', + type: 'INTEGER', + }, + { + name: 'userid', + type: 'INTEGER', + }, + { + name: 'grade', + type: 'REAL', + }, + { + name: 'attemptnumber', + type: 'INTEGER', + }, + { + name: 'addattempt', + type: 'INTEGER', + }, + { + name: 'workflowstate', + type: 'TEXT', + }, + { + name: 'applytoall', + type: 'INTEGER', + }, + { + name: 'outcomes', + type: 'TEXT', + }, + { + name: 'plugindata', + type: 'TEXT', + }, + { + name: 'timemodified', + type: 'INTEGER', + }, + ], + primaryKeys: ['assignid', 'userid'], + }, + ], +}; + +/** + * Data about assign submissions to sync. + */ +export type AddonModAssignSubmissionsDBRecord = { + assignid: number; // Primary key. + userid: number; // Primary key. + courseid: number; + plugindata: string; + onlinetimemodified: number; + timecreated: number; + timemodified: number; + submitted: number; + submissionstatement?: number; +}; + +/** + * Data about assign submission grades to sync. + */ +export type AddonModAssignSubmissionsGradingDBRecord = { + assignid: number; // Primary key. + userid: number; // Primary key. + courseid: number; + grade?: number; // Real. + attemptnumber: number; + addattempt: number; + workflowstate: string; + applytoall: number; + outcomes: string; + plugindata: string; + timemodified: number; +}; diff --git a/src/addons/mod/assign/services/feedback-delegate.ts b/src/addons/mod/assign/services/feedback-delegate.ts new file mode 100644 index 000000000..b21ca5f35 --- /dev/null +++ b/src/addons/mod/assign/services/feedback-delegate.ts @@ -0,0 +1,374 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate'; +import { AddonModAssignDefaultFeedbackHandler } from './handlers/default-feedback'; +import { AddonModAssignAssign, AddonModAssignSubmission, AddonModAssignPlugin } from './assign'; +import { makeSingleton } from '@singletons'; +import { CoreWSExternalFile } from '@services/ws'; + +/** + * Interface that all feedback handlers must implement. + */ +export interface AddonModAssignFeedbackHandler extends CoreDelegateHandler { + + /** + * Name of the type of feedback the handler supports. E.g. 'file'. + */ + type: string; + + /** + * Discard the draft data of the feedback plugin. + * + * @param assignId The assignment ID. + * @param userId User ID. + * @param siteId Site ID. If not defined, current site. + * @return If the function is async, it should return a Promise resolved when done. + */ + discardDraft?(assignId: number, userId: number, siteId?: string): void | Promise; + + /** + * Return the Component to use to display the plugin data. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @param plugin The plugin object. + * @return The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent?(plugin: AddonModAssignPlugin): any | Promise; + + /** + * Return the draft saved data of the feedback plugin. + * + * @param assignId The assignment ID. + * @param userId User ID. + * @param siteId Site ID. If not defined, current site. + * @return Data (or promise resolved with the data). + */ + getDraft?(assignId: number, userId: number, siteId?: string): any | Promise; + + /** + * Get files used by this plugin. + * The files returned by this function will be prefetched when the user prefetches the assign. + * + * @param assign The assignment. + * @param submission The submission. + * @param plugin The plugin object. + * @param siteId Site ID. If not defined, current site. + * @return The files (or promise resolved with the files). + */ + getPluginFiles?( + assign: AddonModAssignAssign, + submission: AddonModAssignSubmission, + plugin: AddonModAssignPlugin, + siteId?: string, + ): CoreWSExternalFile[] | Promise; + + /** + * Get a readable name to use for the plugin. + * + * @param plugin The plugin object. + * @return The plugin name. + */ + getPluginName?(plugin: AddonModAssignPlugin): string; + + /** + * Check if the feedback data has changed for this plugin. + * + * @param assign The assignment. + * @param submission The submission. + * @param plugin The plugin object. + * @param inputData Data entered by the user for the feedback. + * @param userId User ID of the submission. + * @return Boolean (or promise resolved with boolean): whether the data has changed. + */ + hasDataChanged?( + assign: AddonModAssignAssign, + submission: AddonModAssignSubmission, + plugin: AddonModAssignPlugin, + inputData: any, + userId: number, + ): boolean | Promise; + + /** + * Check whether the plugin has draft data stored. + * + * @param assignId The assignment ID. + * @param userId User ID. + * @param siteId Site ID. If not defined, current site. + * @return Boolean or promise resolved with boolean: whether the plugin has draft data. + */ + hasDraftData?(assignId: number, userId: number, siteId?: string): boolean | Promise; + + /** + * Prefetch any required data for the plugin. + * This should NOT prefetch files. Files to be prefetched should be returned by the getPluginFiles function. + * + * @param assign The assignment. + * @param submission The submission. + * @param plugin The plugin object. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + prefetch?( + assign: AddonModAssignAssign, + submission: AddonModAssignSubmission, + plugin: AddonModAssignPlugin, + siteId?: string, + ): Promise; + + /** + * Prepare and add to pluginData the data to send to the server based on the draft data saved. + * + * @param assignId The assignment ID. + * @param userId User ID. + * @param plugin The plugin object. + * @param pluginData Object where to store the data to send. + * @param siteId Site ID. If not defined, current site. + * @return If the function is async, it should return a Promise resolved when done. + */ + prepareFeedbackData?( + assignId: number, + userId: number, + plugin: AddonModAssignPlugin, + pluginData: any, + siteId?: string, + ): void | Promise; + + /** + * Save draft data of the feedback plugin. + * + * @param assignId The assignment ID. + * @param userId User ID. + * @param plugin The plugin object. + * @param data The data to save. + * @param siteId Site ID. If not defined, current site. + * @return If the function is async, it should return a Promise resolved when done. + */ + saveDraft?(assignId: number, userId: number, plugin: AddonModAssignPlugin, data: any, siteId?: string): void | Promise; +} + +/** + * Delegate to register plugins for assign feedback. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModAssignFeedbackDelegateService extends CoreDelegate { + + protected handlerNameProperty = 'type'; + + constructor( + protected defaultHandler: AddonModAssignDefaultFeedbackHandler, + ) { + super('AddonModAssignFeedbackDelegate', true); + } + + /** + * Discard the draft data of the feedback plugin. + * + * @param assignId The assignment ID. + * @param userId User ID. + * @param plugin The plugin object. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async discardPluginFeedbackData( + assignId: number, + userId: number, + plugin: AddonModAssignPlugin, + siteId?: string, + ): Promise { + return await this.executeFunctionOnEnabled(plugin.type, 'discardDraft', [assignId, userId, siteId]); + } + + /** + * Get the component to use for a certain feedback plugin. + * + * @param plugin The plugin object. + * @return Promise resolved with the component to use, undefined if not found. + */ + async getComponentForPlugin(plugin: AddonModAssignPlugin): Promise { + return await this.executeFunctionOnEnabled(plugin.type, 'getComponent', [plugin]); + } + + /** + * Return the draft saved data of the feedback plugin. + * + * @param assignId The assignment ID. + * @param userId User ID. + * @param plugin The plugin object. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the draft data. + */ + async getPluginDraftData( + assignId: number, + userId: number, + plugin: AddonModAssignPlugin, + siteId?: string, + ): Promise { + return await this.executeFunctionOnEnabled(plugin.type, 'getDraft', [assignId, userId, siteId]); + } + + /** + * Get files used by this plugin. + * The files returned by this function will be prefetched when the user prefetches the assign. + * + * @param assign The assignment. + * @param submission The submission. + * @param plugin The plugin object. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the files. + */ + async getPluginFiles( + assign: AddonModAssignAssign, + submission: AddonModAssignSubmission, + plugin: AddonModAssignPlugin, + siteId?: string, + ): Promise { + const files: CoreWSExternalFile[] | undefined = + await this.executeFunctionOnEnabled(plugin.type, 'getPluginFiles', [assign, submission, plugin, siteId]); + + return files || []; + } + + /** + * Get a readable name to use for a certain feedback plugin. + * + * @param plugin Plugin to get the name for. + * @return Human readable name. + */ + getPluginName(plugin: AddonModAssignPlugin): string | undefined { + return this.executeFunctionOnEnabled(plugin.type, 'getPluginName', [plugin]); + } + + /** + * Check if the feedback data has changed for a certain plugin. + * + * @param assign The assignment. + * @param submission The submission. + * @param plugin The plugin object. + * @param inputData Data entered by the user for the feedback. + * @param userId User ID of the submission. + * @return Promise resolved with true if data has changed, resolved with false otherwise. + */ + async hasPluginDataChanged( + assign: AddonModAssignAssign, + submission: AddonModAssignSubmission, + plugin: AddonModAssignPlugin, + inputData: any, + userId: number, + ): Promise { + return await this.executeFunctionOnEnabled( + plugin.type, + 'hasDataChanged', + [assign, submission, plugin, inputData, userId], + ); + } + + /** + * Check whether the plugin has draft data stored. + * + * @param assignId The assignment ID. + * @param userId User ID. + * @param plugin The plugin object. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with true if it has draft data. + */ + async hasPluginDraftData( + assignId: number, + userId: number, + plugin: AddonModAssignPlugin, + siteId?: string, + ): Promise { + return await this.executeFunctionOnEnabled(plugin.type, 'hasDraftData', [assignId, userId, siteId]); + } + + /** + * Check if a feedback plugin is supported. + * + * @param pluginType Type of the plugin. + * @return Whether it's supported. + */ + isPluginSupported(pluginType: string): boolean { + return this.hasHandler(pluginType, true); + } + + /** + * Prefetch any required data for a feedback plugin. + * + * @param assign The assignment. + * @param submission The submission. + * @param plugin The plugin object. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async prefetch( + assign: AddonModAssignAssign, + submission: AddonModAssignSubmission, + plugin: AddonModAssignPlugin, + siteId?: string, + ): Promise { + return await this.executeFunctionOnEnabled(plugin.type, 'prefetch', [assign, submission, plugin, siteId]); + } + + /** + * Prepare and add to pluginData the data to submit for a certain feedback plugin. + * + * @param assignId The assignment ID. + * @param userId User ID. + * @param plugin The plugin object. + * @param pluginData Object where to store the data to send. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when data has been gathered. + */ + async preparePluginFeedbackData( + assignId: number, + userId: number, + plugin: AddonModAssignPlugin, + pluginData: any, + siteId?: string, + ): Promise { + + return await this.executeFunctionOnEnabled( + plugin.type, + 'prepareFeedbackData', + [assignId, userId, plugin, pluginData, siteId], + ); + } + + /** + * Save draft data of the feedback plugin. + * + * @param assignId The assignment ID. + * @param userId User ID. + * @param plugin The plugin object. + * @param inputData Data to save. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when data has been saved. + */ + async saveFeedbackDraft( + assignId: number, + userId: number, + plugin: AddonModAssignPlugin, + inputData: any, + siteId?: string, + ): Promise { + return await this.executeFunctionOnEnabled( + plugin.type, + 'saveDraft', + [assignId, userId, plugin, inputData, siteId], + ); + } + +} +export const AddonModAssignFeedbackDelegate = makeSingleton(AddonModAssignFeedbackDelegateService); diff --git a/src/addons/mod/assign/services/handlers/default-feedback.ts b/src/addons/mod/assign/services/handlers/default-feedback.ts new file mode 100644 index 000000000..5bfc76bd6 --- /dev/null +++ b/src/addons/mod/assign/services/handlers/default-feedback.ts @@ -0,0 +1,146 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { Translate } from '@singletons'; +import { AddonModAssignPlugin } from '../assign'; +import { AddonModAssignFeedbackHandler } from '../feedback-delegate'; + +/** + * Default handler used when a feedback plugin doesn't have a specific implementation. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModAssignDefaultFeedbackHandler implements AddonModAssignFeedbackHandler { + + name = 'AddonModAssignDefaultFeedbackHandler'; + type = 'default'; + + /** + * Discard the draft data of the feedback plugin. + * + * @return If the function is async, it should return a Promise resolved when done. + */ + discardDraft(): void { + // Nothing to do. + } + + /** + * Return the Component to use to display the plugin data. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @return The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(): void { + // Nothing to do. + } + + /** + * Return the draft saved data of the feedback plugin. + * + * @return Data (or promise resolved with the data). + */ + getDraft(): void { + // Nothing to do. + } + + /** + * Get files used by this plugin. + * The files returned by this function will be prefetched when the user prefetches the assign. + * + * @return The files (or promise resolved with the files). + */ + getPluginFiles(): any[] { + return []; + } + + /** + * Get a readable name to use for the plugin. + * + * @param plugin The plugin object. + * @return The plugin name. + */ + getPluginName(plugin: AddonModAssignPlugin): string { + // Check if there's a translated string for the plugin. + const translationId = 'addon.mod_assign_feedback_' + plugin.type + '.pluginname'; + const translation = Translate.instance.instant(translationId); + + if (translationId != translation) { + // Translation found, use it. + return translation; + } + + // Fallback to WS string. + if (plugin.name) { + return plugin.name; + } + + return ''; + } + + /** + * Check if the feedback data has changed for this plugin. + * + * @return Boolean (or promise resolved with boolean): whether the data has changed. + */ + hasDataChanged(): boolean { + return false; + } + + /** + * Check whether the plugin has draft data stored. + * + * @return Boolean or promise resolved with boolean: whether the plugin has draft data. + */ + hasDraftData(): boolean { + return false; + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return True or promise resolved with true if enabled. + */ + async isEnabled(): Promise { + return true; + } + + /** + * Prefetch any required data for the plugin. + * This should NOT prefetch files. Files to be prefetched should be returned by the getPluginFiles function. + * + * @return Promise resolved when done. + */ + async prefetch(): Promise { + return; + } + + /** + * Prepare and add to pluginData the data to send to the server based on the draft data saved. + * + * @return If the function is async, it should return a Promise resolved when done. + */ + prepareFeedbackData(): void { + // Nothing to do. + } + + /** + * Save draft data of the feedback plugin. + * + * @return If the function is async, it should return a Promise resolved when done. + */ + saveDraft(): void { + // Nothing to do. + } + +} diff --git a/src/addons/mod/assign/services/handlers/default-submission.ts b/src/addons/mod/assign/services/handlers/default-submission.ts new file mode 100644 index 000000000..a83032c1c --- /dev/null +++ b/src/addons/mod/assign/services/handlers/default-submission.ts @@ -0,0 +1,210 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { Translate } from '@singletons'; +import { AddonModAssignPlugin } from '../assign'; +import { AddonModAssignSubmissionHandler } from '../submission-delegate'; + +/** + * Default handler used when a submission plugin doesn't have a specific implementation. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModAssignDefaultSubmissionHandler implements AddonModAssignSubmissionHandler { + + name = 'AddonModAssignBaseSubmissionHandler'; + type = 'base'; + + /** + * Whether the plugin can be edited in offline for existing submissions. In general, this should return false if the + * plugin uses Moodle filters. The reason is that the app only prefetches filtered data, and the user should edit + * unfiltered data. + * + * @return Boolean or promise resolved with boolean: whether it can be edited in offline. + */ + canEditOffline(): boolean | Promise { + return false; + } + + /** + * Check if a plugin has no data. + * + * @return Whether the plugin is empty. + */ + isEmpty(): boolean { + return true; + } + + /** + * Should clear temporary data for a cancelled submission. + */ + clearTmpData(): void { + // Nothing to do. + } + + /** + * This function will be called when the user wants to create a new submission based on the previous one. + * It should add to pluginData the data to send to server based in the data in plugin (previous attempt). + * + * @return If the function is async, it should return a Promise resolved when done. + */ + copySubmissionData(): void { + // Nothing to do. + } + + /** + * Delete any stored data for the plugin and submission. + * + * @return If the function is async, it should return a Promise resolved when done. + */ + deleteOfflineData(): void { + // Nothing to do. + } + + /** + * Return the Component to use to display the plugin data, either in read or in edit mode. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @return The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(): void { + // Nothing to do. + } + + /** + * Get files used by this plugin. + * The files returned by this function will be prefetched when the user prefetches the assign. + * + * @return The files (or promise resolved with the files). + */ + getPluginFiles(): any[] { + return []; + } + + /** + * Get a readable name to use for the plugin. + * + * @param plugin The plugin object. + * @return The plugin name. + */ + getPluginName(plugin: AddonModAssignPlugin): string { + // Check if there's a translated string for the plugin. + const translationId = 'addon.mod_assign_submission_' + plugin.type + '.pluginname'; + const translation = Translate.instance.instant(translationId); + + if (translationId != translation) { + // Translation found, use it. + return translation; + } + + // Fallback to WS string. + if (plugin.name) { + return plugin.name; + } + + return ''; + } + + /** + * Get the size of data (in bytes) this plugin will send to copy a previous submission. + * + * @param assign The assignment. + * @param plugin The plugin object. + * @return The size (or promise resolved with size). + */ + getSizeForCopy(): number { + return 0; + } + + /** + * Get the size of data (in bytes) this plugin will send to add or edit a submission. + * + * @param assign The assignment. + * @param submission The submission. + * @param plugin The plugin object. + * @return The size (or promise resolved with size). + */ + getSizeForEdit(): number { + return 0; + } + + /** + * Check if the submission data has changed for this plugin. + * + * @param assign The assignment. + * @param submission The submission. + * @param plugin The plugin object. + * @param inputData Data entered by the user for the submission. + * @return Boolean (or promise resolved with boolean): whether the data has changed. + */ + hasDataChanged(): boolean { + return false; + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return True or promise resolved with true if enabled. + */ + async isEnabled(): Promise { + return true; + } + + /** + * Whether or not the handler is enabled for edit on a site level. + * + * @return Whether or not the handler is enabled for edit on a site level. + */ + isEnabledForEdit(): boolean { + return false; + } + + /** + * Prefetch any required data for the plugin. + * This should NOT prefetch files. Files to be prefetched should be returned by the getPluginFiles function. + * + * @return Promise resolved when done. + */ + async prefetch(): Promise { + return; + } + + /** + * Prepare and add to pluginData the data to send to the server based on the input data. + * + * @param assign The assignment. + * @param submission The submission. + * @param plugin The plugin object. + * @param inputData Data entered by the user for the submission. + * @param pluginData Object where to store the data to send. + * @param offline Whether the user is editing in offline. + * @param userId User ID. If not defined, site's current user. + * @param siteId Site ID. If not defined, current site. + * @return If the function is async, it should return a Promise resolved when done. + */ + prepareSubmissionData(): void { + // Nothing to do. + } + + /** + * Prepare and add to pluginData the data to send to the server based on the offline data stored. + * This will be used when performing a synchronization. + * + * @return If the function is async, it should return a Promise resolved when done. + */ + prepareSyncData(): void { + // Nothing to do. + } + +} diff --git a/src/addons/mod/assign/services/handlers/index-link.ts b/src/addons/mod/assign/services/handlers/index-link.ts new file mode 100644 index 000000000..3162d8d15 --- /dev/null +++ b/src/addons/mod/assign/services/handlers/index-link.ts @@ -0,0 +1,32 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreContentLinksModuleIndexHandler } from '@features/contentlinks/classes/module-index-handler'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to treat links to assign index page. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModAssignIndexLinkHandlerService extends CoreContentLinksModuleIndexHandler { + + name = 'AddonModAssignIndexLinkHandler'; + + constructor() { + super('AddonModAssign', 'assign'); + } + +} +export const AddonModAssignIndexLinkHandler = makeSingleton(AddonModAssignIndexLinkHandlerService); diff --git a/src/addons/mod/assign/services/handlers/list-link.ts b/src/addons/mod/assign/services/handlers/list-link.ts new file mode 100644 index 000000000..8778082b4 --- /dev/null +++ b/src/addons/mod/assign/services/handlers/list-link.ts @@ -0,0 +1,32 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreContentLinksModuleListHandler } from '@features/contentlinks/classes/module-list-handler'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to treat links to assign list page. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModAssignListLinkHandlerService extends CoreContentLinksModuleListHandler { + + name = 'AddonModAssignListLinkHandler'; + + constructor() { + super('AddonModAssign', 'assign'); + } + +} +export const AddonModAssignListLinkHandler = makeSingleton(AddonModAssignListLinkHandlerService); diff --git a/src/addons/mod/assign/services/handlers/module.ts b/src/addons/mod/assign/services/handlers/module.ts new file mode 100644 index 000000000..bd74d4468 --- /dev/null +++ b/src/addons/mod/assign/services/handlers/module.ts @@ -0,0 +1,94 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreConstants } from '@/core/constants'; +import { Injectable, Type } from '@angular/core'; +import { CoreCourseModuleHandler, CoreCourseModuleHandlerData } from '@features/course/services/module-delegate'; +import { AddonModAssignIndexComponent } from '../../components/index'; +import { makeSingleton } from '@singletons'; +import { CoreCourse, CoreCourseAnyModuleData } from '@features/course/services/course'; +import { CoreCourseModule } from '@features/course/services/course-helper'; +import { CoreNavigationOptions, CoreNavigator } from '@services/navigator'; +import { AddonModAssign } from '../assign'; + +/** + * Handler to support assign modules. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModAssignModuleHandlerService implements CoreCourseModuleHandler { + + static readonly PAGE_NAME = 'mod_assign'; + + name = 'AddonModAssign'; + modName = 'assign'; + + supportedFeatures = { + [CoreConstants.FEATURE_GROUPS]: true, + [CoreConstants.FEATURE_GROUPINGS]: true, + [CoreConstants.FEATURE_MOD_INTRO]: true, + [CoreConstants.FEATURE_COMPLETION_TRACKS_VIEWS]: true, + [CoreConstants.FEATURE_COMPLETION_HAS_RULES]: true, + [CoreConstants.FEATURE_GRADE_HAS_GRADE]: true, + [CoreConstants.FEATURE_GRADE_OUTCOMES]: true, + [CoreConstants.FEATURE_BACKUP_MOODLE2]: true, + [CoreConstants.FEATURE_SHOW_DESCRIPTION]: true, + [CoreConstants.FEATURE_ADVANCED_GRADING]: true, + [CoreConstants.FEATURE_PLAGIARISM]: true, + [CoreConstants.FEATURE_COMMENT]: true, + }; + + /** + * Check if the handler is enabled on a site level. + * + * @return Whether or not the handler is enabled on a site level. + */ + async isEnabled(): Promise { + return AddonModAssign.instance.isPluginEnabled(); + } + + /** + * Get the data required to display the module in the course contents view. + * + * @param module The module object. + * @return Data to render the module. + */ + getData(module: CoreCourseAnyModuleData): CoreCourseModuleHandlerData { + return { + icon: CoreCourse.instance.getModuleIconSrc(this.modName, 'modicon' in module ? module.modicon : undefined), + title: module.name, + class: 'addon-mod_assign-handler', + showDownloadButton: true, + action(event: Event, module: CoreCourseModule, courseId: number, options?: CoreNavigationOptions): void { + options = options || {}; + options.params = options.params || {}; + Object.assign(options.params, { module }); + const routeParams = '/' + courseId + '/' + module.id; + + CoreNavigator.instance.navigateToSitePath(AddonModAssignModuleHandlerService.PAGE_NAME + routeParams, options); + }, + }; + } + + /** + * Get the component to render the module. This is needed to support singleactivity course format. + * The component returned must implement CoreCourseModuleMainComponent. + * + * @return The component to use, undefined if not found. + */ + async getMainComponent(): Promise | undefined> { + return AddonModAssignIndexComponent; + } + +} +export const AddonModAssignModuleHandler = makeSingleton(AddonModAssignModuleHandlerService); diff --git a/src/addons/mod/assign/services/handlers/prefetch.ts b/src/addons/mod/assign/services/handlers/prefetch.ts new file mode 100644 index 000000000..966e7e842 --- /dev/null +++ b/src/addons/mod/assign/services/handlers/prefetch.ts @@ -0,0 +1,531 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreSites, CoreSitesCommonWSOptions, CoreSitesReadingStrategy } from '@services/sites'; +import { makeSingleton } from '@singletons'; +import { + AddonModAssign, + AddonModAssignAssign, + AddonModAssignProvider, + AddonModAssignSubmission, + AddonModAssignSubmissionStatusOptions, +} from '../assign'; +import { AddonModAssignSubmissionDelegate } from '../submission-delegate'; +import { AddonModAssignFeedbackDelegate } from '../feedback-delegate'; +import { CoreCourseActivityPrefetchHandlerBase } from '@features/course/classes/activity-prefetch-handler'; +import { CoreCourse, CoreCourseAnyModuleData, CoreCourseCommonModWSOptions } from '@features/course/services/course'; +import { CoreWSExternalFile } from '@services/ws'; +import { AddonModAssignHelper, AddonModAssignSubmissionFormatted } from '../assign-helper'; +import { CoreCourseHelper } from '@features/course/services/course-helper'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreFilepool } from '@services/filepool'; +import { CoreGroups } from '@services/groups'; +import { AddonModAssignSync, AddonModAssignSyncResult } from '../assign-sync'; +import { CoreUser } from '@features/user/services/user'; +import { CoreGradesHelper } from '@features/grades/services/grades-helper'; + +/** + * Handler to prefetch assigns. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModAssignPrefetchHandlerService extends CoreCourseActivityPrefetchHandlerBase { + + name = 'AddonModAssign'; + modName = 'assign'; + component = AddonModAssignProvider.COMPONENT; + updatesNames = /^configuration$|^.*files$|^submissions$|^grades$|^gradeitems$|^outcomes$|^comments$/; + + /** + * Check if a certain module can use core_course_check_updates to check if it has updates. + * If not defined, it will assume all modules can be checked. + * The modules that return false will always be shown as outdated when they're downloaded. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @return Whether the module can use check_updates. The promise should never be rejected. + */ + async canUseCheckUpdates(module: CoreCourseAnyModuleData, courseId: number): Promise { + // Teachers cannot use the WS because it doesn't check student submissions. + try { + const assign = await AddonModAssign.instance.getAssignment(courseId, module.id); + + const data = await AddonModAssign.instance.getSubmissions(assign.id, { cmId: module.id }); + if (data.canviewsubmissions) { + return false; + } + + // Check if the user can view their own submission. + await AddonModAssign.instance.getSubmissionStatus(assign.id, { cmId: module.id }); + + return true; + } catch { + return false; + } + } + + /** + * Get list of files. If not defined, we'll assume they're in module.contents. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @return Promise resolved with the list of files. + */ + async getFiles(module: CoreCourseAnyModuleData, courseId: number): Promise { + const siteId = CoreSites.instance.getCurrentSiteId(); + + try { + const assign = await AddonModAssign.instance.getAssignment(courseId, module.id, { siteId }); + // Get intro files and attachments. + let files = assign.introattachments || []; + files = files.concat(this.getIntroFilesFromInstance(module, assign)); + + // Now get the files in the submissions. + const submissionData = await AddonModAssign.instance.getSubmissions(assign.id, { cmId: module.id, siteId }); + + if (submissionData.canviewsubmissions) { + // Teacher, get all submissions. + const submissions = + await AddonModAssignHelper.instance.getSubmissionsUserData(assign, submissionData.submissions, 0, { siteId }); + + // Get all the files in the submissions. + const promises = submissions.map((submission) => + this.getSubmissionFiles(assign, submission.submitid!, !!submission.blindid, siteId).then((submissionFiles) => { + files = files.concat(submissionFiles); + + return; + }).catch((error) => { + if (error && error.errorcode == 'nopermission') { + // The user does not have persmission to view this submission, ignore it. + return; + } + + throw error; + })); + + await Promise.all(promises); + } else { + // Student, get only his/her submissions. + const userId = CoreSites.instance.getCurrentSiteUserId(); + const blindMarking = !!assign.blindmarking && !assign.revealidentities; + + const submissionFiles = await this.getSubmissionFiles(assign, userId, blindMarking, siteId); + files = files.concat(submissionFiles); + } + + return files; + } catch { + // Error getting data, return empty list. + return []; + } + } + + /** + * Get submission files. + * + * @param assign Assign. + * @param submitId User ID of the submission to get. + * @param blindMarking True if blind marking, false otherwise. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with array of files. + */ + protected async getSubmissionFiles( + assign: AddonModAssignAssign, + submitId: number, + blindMarking: boolean, + siteId?: string, + ): Promise { + + const submissionStatus = await AddonModAssign.instance.getSubmissionStatusWithRetry(assign, { + userId: submitId, + isBlind: blindMarking, + siteId, + }); + const userSubmission = AddonModAssign.instance.getSubmissionObjectFromAttempt(assign, submissionStatus.lastattempt); + + if (!submissionStatus.lastattempt || !userSubmission) { + return []; + } + + const promises: Promise[] = []; + + if (userSubmission.plugins) { + // Add submission plugin files. + userSubmission.plugins.forEach((plugin) => { + promises.push(AddonModAssignSubmissionDelegate.instance.getPluginFiles(assign, userSubmission, plugin, siteId)); + }); + } + + if (submissionStatus.feedback && submissionStatus.feedback.plugins) { + // Add feedback plugin files. + submissionStatus.feedback.plugins.forEach((plugin) => { + promises.push(AddonModAssignFeedbackDelegate.instance.getPluginFiles(assign, userSubmission, plugin, siteId)); + }); + } + + const filesLists = await Promise.all(promises); + + return [].concat.apply([], filesLists); + } + + /** + * Invalidate the prefetched content. + * + * @param moduleId The module ID. + * @param courseId The course ID the module belongs to. + * @return Promise resolved when the data is invalidated. + */ + async invalidateContent(moduleId: number, courseId: number): Promise { + await AddonModAssign.instance.invalidateContent(moduleId, courseId); + } + + /** + * Invalidate WS calls needed to determine module status. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @return Promise resolved when invalidated. + */ + async invalidateModule(module: CoreCourseAnyModuleData): Promise { + return CoreCourse.instance.invalidateModule(module.id); + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return A boolean, or a promise resolved with a boolean, indicating if the handler is enabled. + */ + async isEnabled(): Promise { + return AddonModAssign.instance.isPluginEnabled(); + } + + /** + * Prefetch a module. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @return Promise resolved when done. + */ + prefetch(module: CoreCourseAnyModuleData, courseId?: number): Promise { + return this.prefetchPackage(module, courseId, this.prefetchAssign.bind(this, module, courseId)); + } + + /** + * Prefetch an assignment. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @return Promise resolved when done. + */ + protected async prefetchAssign(module: CoreCourseAnyModuleData, courseId?: number): Promise { + const userId = CoreSites.instance.getCurrentSiteUserId(); + courseId = courseId || module.course || CoreSites.instance.getCurrentSiteHomeId(); + const siteId = CoreSites.instance.getCurrentSiteId(); + + const options: CoreSitesCommonWSOptions = { + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + siteId, + }; + + const modOptions: CoreCourseCommonModWSOptions = { + cmId: module.id, + ...options, + }; + + // Get assignment to retrieve all its submissions. + const assign = await AddonModAssign.instance.getAssignment(courseId, module.id, options); + const promises: Promise[] = []; + const blindMarking = assign.blindmarking && !assign.revealidentities; + + if (blindMarking) { + promises.push( + CoreUtils.instance.ignoreErrors(AddonModAssign.instance.getAssignmentUserMappings(assign.id, -1, modOptions)), + ); + } + + promises.push(this.prefetchSubmissions(assign, courseId, module.id, userId, siteId)); + + promises.push(CoreCourseHelper.instance.getModuleCourseIdByInstance(assign.id, 'assign', siteId)); + + // Download intro files and attachments. Do not call getFiles because it'd call some WS twice. + let files = assign.introattachments || []; + files = files.concat(this.getIntroFilesFromInstance(module, assign)); + + promises.push(CoreFilepool.instance.addFilesToQueue(siteId, files, this.component, module.id)); + + await Promise.all(promises); + + } + + /** + * Prefetch assign submissions. + * + * @param assign Assign. + * @param courseId Course ID. + * @param moduleId Module ID. + * @param userId User ID. If not defined, site's current user. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when prefetched, rejected otherwise. + */ + protected async prefetchSubmissions( + assign: AddonModAssignAssign, + courseId: number, + moduleId: number, + userId: number, + siteId: string, + ): Promise { + const modOptions: CoreCourseCommonModWSOptions = { + cmId: moduleId, + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + siteId, + }; + + // Get submissions. + const submissions = await AddonModAssign.instance.getSubmissions(assign.id, modOptions); + const promises: Promise[] = []; + + promises.push(this.prefetchParticipantSubmissions( + assign, + submissions.canviewsubmissions, + submissions.submissions, + moduleId, + courseId, + userId, + siteId, + )); + + // Prefetch own submission, we need to do this for teachers too so the response with error is cached. + promises.push( + this.prefetchSubmission( + assign, + courseId, + moduleId, + { + userId, + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + siteId, + }, + true, + ), + ); + + await Promise.all(promises); + } + + protected async prefetchParticipantSubmissions( + assign: AddonModAssignAssign, + canviewsubmissions: boolean, + submissions: AddonModAssignSubmission[] = [], + moduleId: number, + courseId: number, + userId: number, + siteId: string, + ): Promise { + + const options: CoreSitesCommonWSOptions = { + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + siteId, + }; + + const modOptions: CoreCourseCommonModWSOptions = { + cmId: moduleId, + ...options, + }; + + // Always prefetch groupInfo. + const groupInfo = await CoreGroups.instance.getActivityGroupInfo(assign.cmid, false, undefined, siteId); + if (!canviewsubmissions) { + + return; + } + + // Teacher, prefetch all submissions. + if (!groupInfo.groups || groupInfo.groups.length == 0) { + groupInfo.groups = [{ id: 0, name: '' }]; + } + + const promises = groupInfo.groups.map((group) => + AddonModAssignHelper.instance.getSubmissionsUserData(assign, submissions, group.id, options) + .then((submissions: AddonModAssignSubmissionFormatted[]) => { + + const subPromises: Promise[] = submissions.map((submission) => { + const submissionOptions = { + userId: submission.submitid, + groupId: group.id, + isBlind: !!submission.blindid, + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + siteId, + }; + + return this.prefetchSubmission(assign, courseId, moduleId, submissionOptions, true); + }); + + if (!assign.markingworkflow) { + // Get assignment grades only if workflow is not enabled to check grading date. + subPromises.push(AddonModAssign.instance.getAssignmentGrades(assign.id, modOptions)); + } + + // Prefetch the submission of the current user even if it does not exist, this will be create it. + if (!submissions || !submissions.find((subm: AddonModAssignSubmissionFormatted) => subm.submitid == userId)) { + const submissionOptions = { + userId, + groupId: group.id, + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + siteId, + }; + + subPromises.push(this.prefetchSubmission(assign, courseId, moduleId, submissionOptions)); + } + + return Promise.all(subPromises); + }).then(async () => { + // Participiants already fetched, we don't need to ignore cache now. + const participants = await AddonModAssignHelper.instance.getParticipants(assign, group.id, { siteId }); + + // Fail silently (Moodle < 3.2). + await CoreUtils.instance.ignoreErrors( + CoreUser.instance.prefetchUserAvatars(participants, 'profileimageurl', siteId), + ); + + return; + })); + + await Promise.all(promises); + } + + /** + * Prefetch a submission. + * + * @param assign Assign. + * @param courseId Course ID. + * @param moduleId Module ID. + * @param options Other options, see getSubmissionStatusWithRetry. + * @param resolveOnNoPermission If true, will avoid throwing if a nopermission error is raised. + * @return Promise resolved when prefetched, rejected otherwise. + */ + protected async prefetchSubmission( + assign: AddonModAssignAssign, + courseId: number, + moduleId: number, + options: AddonModAssignSubmissionStatusOptions = {}, + resolveOnNoPermission = false, + ): Promise { + const submission = await AddonModAssign.instance.getSubmissionStatusWithRetry(assign, options); + const siteId = options.siteId!; + const userId = options.userId; + + try { + const promises: Promise[] = []; + const blindMarking = !!assign.blindmarking && !assign.revealidentities; + let userIds: number[] = []; + const userSubmission = AddonModAssign.instance.getSubmissionObjectFromAttempt(assign, submission.lastattempt); + + if (submission.lastattempt) { + // Get IDs of the members who need to submit. + if (!blindMarking && submission.lastattempt.submissiongroupmemberswhoneedtosubmit) { + userIds = userIds.concat(submission.lastattempt.submissiongroupmemberswhoneedtosubmit); + } + + if (userSubmission && userSubmission.id) { + // Prefetch submission plugins data. + if (userSubmission.plugins) { + userSubmission.plugins.forEach((plugin) => { + // Prefetch the plugin WS data. + promises.push( + AddonModAssignSubmissionDelegate.instance.prefetch(assign, userSubmission, plugin, siteId), + ); + + // Prefetch the plugin files. + promises.push( + AddonModAssignSubmissionDelegate.instance.getPluginFiles(assign, userSubmission, plugin, siteId) + .then((files) => + CoreFilepool.instance.addFilesToQueue(siteId, files, this.component, module.id)) + .catch(() => { + // Ignore errors. + }), + ); + }); + } + + // Get ID of the user who did the submission. + if (userSubmission.userid) { + userIds.push(userSubmission.userid); + } + } + } + + // Prefetch grade items. + if (userId) { + promises.push(CoreCourse.instance.getModuleBasicGradeInfo(moduleId, siteId).then((gradeInfo) => { + if (gradeInfo) { + promises.push( + CoreGradesHelper.instance.getGradeModuleItems(courseId, moduleId, userId, undefined, siteId, true), + ); + } + + return; + })); + } + + // Prefetch feedback. + if (submission.feedback) { + // Get profile and image of the grader. + if (submission.feedback.grade && submission.feedback.grade.grader > 0) { + userIds.push(submission.feedback.grade.grader); + } + + // Prefetch feedback plugins data. + if (submission.feedback.plugins && userSubmission && userSubmission.id) { + submission.feedback.plugins.forEach((plugin) => { + // Prefetch the plugin WS data. + promises.push(AddonModAssignFeedbackDelegate.instance.prefetch(assign, userSubmission, plugin, siteId)); + + // Prefetch the plugin files. + promises.push( + AddonModAssignFeedbackDelegate.instance.getPluginFiles(assign, userSubmission, plugin, siteId) + .then((files) => CoreFilepool.instance.addFilesToQueue(siteId, files, this.component, module.id)) + .catch(() => { + // Ignore errors. + }), + ); + }); + } + } + + // Prefetch user profiles. + promises.push(CoreUser.instance.prefetchProfiles(userIds, courseId, siteId)); + + await Promise.all(promises); + } catch (error) { + // Ignore if the user can't view their own submission. + if (resolveOnNoPermission && error.errorcode != 'nopermission') { + throw error; + } + } + } + + /** + * Sync a module. + * + * @param module Module. + * @param courseId Course ID the module belongs to + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + sync(module: CoreCourseAnyModuleData, courseId: number, siteId?: string): Promise { + return AddonModAssignSync.instance.syncAssign(module.instance!, siteId); + } + +} +export const AddonModAssignPrefetchHandler = makeSingleton(AddonModAssignPrefetchHandlerService); diff --git a/src/addons/mod/assign/services/handlers/push-click.ts b/src/addons/mod/assign/services/handlers/push-click.ts new file mode 100644 index 000000000..57a0e49b1 --- /dev/null +++ b/src/addons/mod/assign/services/handlers/push-click.ts @@ -0,0 +1,66 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreCourseHelper } from '@features/course/services/course-helper'; +import { CorePushNotificationsClickHandler } from '@features/pushnotifications/services/push-delegate'; +import { CorePushNotificationsNotificationBasicData } from '@features/pushnotifications/services/pushnotifications'; +import { CoreUrlUtils } from '@services/utils/url'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton } from '@singletons'; +import { AddonModAssign } from '../assign'; + +/** + * Handler for assign push notifications clicks. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModAssignPushClickHandlerService implements CorePushNotificationsClickHandler { + + name = 'AddonModAssignPushClickHandler'; + priority = 200; + featureName = 'CoreCourseModuleDelegate_AddonModAssign'; + + /** + * Check if a notification click is handled by this handler. + * + * @param notification The notification to check. + * @return Whether the notification click is handled by this handler + */ + async handles(notification: NotificationData): Promise { + return CoreUtils.instance.isTrueOrOne(notification.notif) && notification.moodlecomponent == 'mod_assign' && + notification.name == 'assign_notification'; + } + + /** + * Handle the notification click. + * + * @param notification The notification to check. + * @return Promise resolved when done. + */ + async handleClick(notification: NotificationData): Promise { + const contextUrlParams = CoreUrlUtils.instance.extractUrlParams(notification.contexturl); + const courseId = Number(notification.courseid); + const moduleId = Number(contextUrlParams.id); + + await CoreUtils.instance.ignoreErrors(AddonModAssign.instance.invalidateContent(moduleId, courseId, notification.site)); + await CoreCourseHelper.instance.navigateToModule(moduleId, notification.site, courseId); + } + +} +export const AddonModAssignPushClickHandler = makeSingleton(AddonModAssignPushClickHandlerService); + +type NotificationData = CorePushNotificationsNotificationBasicData & { + courseid: number; + contexturl: string; +}; diff --git a/src/addons/mod/assign/services/handlers/sync-cron.ts b/src/addons/mod/assign/services/handlers/sync-cron.ts new file mode 100644 index 000000000..b0092185f --- /dev/null +++ b/src/addons/mod/assign/services/handlers/sync-cron.ts @@ -0,0 +1,50 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreCronHandler } from '@services/cron'; +import { makeSingleton } from '@singletons'; +import { AddonModAssignSync } from '../assign-sync'; + +/** + * Synchronization cron handler. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModAssignSyncCronHandlerService implements CoreCronHandler { + + name = 'AddonModAssignSyncCronHandler'; + + /** + * Execute the process. + * Receives the ID of the site affected, undefined for all sites. + * + * @param siteId ID of the site affected, undefined for all sites. + * @param force Wether the execution is forced (manual sync). + * @return Promise resolved when done, rejected if failure. + */ + execute(siteId?: string, force?: boolean): Promise { + return AddonModAssignSync.instance.syncAllAssignments(siteId, force); + } + + /** + * Get the time between consecutive executions. + * + * @return Time between consecutive executions (in ms). + */ + getInterval(): number { + return AddonModAssignSync.instance.syncInterval; + } + +} +export const AddonModAssignSyncCronHandler = makeSingleton(AddonModAssignSyncCronHandlerService); diff --git a/src/addons/mod/assign/services/submission-delegate.ts b/src/addons/mod/assign/services/submission-delegate.ts new file mode 100644 index 000000000..81394a312 --- /dev/null +++ b/src/addons/mod/assign/services/submission-delegate.ts @@ -0,0 +1,565 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate'; +import { AddonModAssignDefaultSubmissionHandler } from './handlers/default-submission'; +import { AddonModAssignAssign, AddonModAssignSubmission, AddonModAssignPlugin } from './assign'; +import { makeSingleton } from '@singletons'; +import { CoreWSExternalFile } from '@services/ws'; + +/** + * Interface that all submission handlers must implement. + */ +export interface AddonModAssignSubmissionHandler extends CoreDelegateHandler { + + /** + * Name of the type of submission the handler supports. E.g. 'file'. + */ + type: string; + + /** + * Whether the plugin can be edited in offline for existing submissions. In general, this should return false if the + * plugin uses Moodle filters. The reason is that the app only prefetches filtered data, and the user should edit + * unfiltered data. + * + * @param assign The assignment. + * @param submission The submission. + * @param plugin The plugin object. + * @return Boolean or promise resolved with boolean: whether it can be edited in offline. + */ + canEditOffline?( + assign: AddonModAssignAssign, + submission: AddonModAssignSubmission, + plugin: AddonModAssignPlugin, + ): boolean | Promise; + + /** + * Check if a plugin has no data. + * + * @param assign The assignment. + * @param plugin The plugin object. + * @return Whether the plugin is empty. + */ + isEmpty?( + assign: AddonModAssignAssign, + plugin: AddonModAssignPlugin, + ): boolean; + + /** + * Should clear temporary data for a cancelled submission. + * + * @param assign The assignment. + * @param submission The submission. + * @param plugin The plugin object. + * @param inputData Data entered by the user for the submission. + */ + clearTmpData?( + assign: AddonModAssignAssign, + submission: AddonModAssignSubmission, + plugin: AddonModAssignPlugin, + inputData: any, + ): void; + + /** + * This function will be called when the user wants to create a new submission based on the previous one. + * It should add to pluginData the data to send to server based in the data in plugin (previous attempt). + * + * @param assign The assignment. + * @param plugin The plugin object. + * @param pluginData Object where to store the data to send. + * @param userId User ID. If not defined, site's current user. + * @param siteId Site ID. If not defined, current site. + * @return If the function is async, it should return a Promise resolved when done. + */ + copySubmissionData?( + assign: AddonModAssignAssign, + plugin: AddonModAssignPlugin, + pluginData: any, + userId?: number, + siteId?: string, + ): void | Promise; + + /** + * Delete any stored data for the plugin and submission. + * + * @param assign The assignment. + * @param submission The submission. + * @param plugin The plugin object. + * @param offlineData Offline data stored. + * @param siteId Site ID. If not defined, current site. + * @return If the function is async, it should return a Promise resolved when done. + */ + deleteOfflineData?( + assign: AddonModAssignAssign, + submission: AddonModAssignSubmission, + plugin: AddonModAssignPlugin, + offlineData: any, + siteId?: string, + ): void | Promise; + + /** + * Return the Component to use to display the plugin data, either in read or in edit mode. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @param plugin The plugin object. + * @param edit Whether the user is editing. + * @return The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent?( + plugin: AddonModAssignPlugin, + edit?: boolean, + ): any | Promise; + + /** + * Get files used by this plugin. + * The files returned by this function will be prefetched when the user prefetches the assign. + * + * @param assign The assignment. + * @param submission The submission. + * @param plugin The plugin object. + * @param siteId Site ID. If not defined, current site. + * @return The files (or promise resolved with the files). + */ + getPluginFiles?( + assign: AddonModAssignAssign, + submission: AddonModAssignSubmission, + plugin: AddonModAssignPlugin, + siteId?: string, + ): CoreWSExternalFile[] | Promise; + + /** + * Get a readable name to use for the plugin. + * + * @param plugin The plugin object. + * @return The plugin name. + */ + getPluginName?(plugin: AddonModAssignPlugin): string; + + /** + * Get the size of data (in bytes) this plugin will send to copy a previous submission. + * + * @param assign The assignment. + * @param plugin The plugin object. + * @return The size (or promise resolved with size). + */ + getSizeForCopy?( + assign: AddonModAssignAssign, + plugin: AddonModAssignPlugin, + ): number | Promise; + + /** + * Get the size of data (in bytes) this plugin will send to add or edit a submission. + * + * @param assign The assignment. + * @param submission The submission. + * @param plugin The plugin object. + * @param inputData Data entered by the user for the submission. + * @return The size (or promise resolved with size). + */ + getSizeForEdit?( + assign: AddonModAssignAssign, + submission: AddonModAssignSubmission, + plugin: AddonModAssignPlugin, + inputData: any, + ): number | Promise; + + /** + * Check if the submission data has changed for this plugin. + * + * @param assign The assignment. + * @param submission The submission. + * @param plugin The plugin object. + * @param inputData Data entered by the user for the submission. + * @return Boolean (or promise resolved with boolean): whether the data has changed. + */ + hasDataChanged?( + assign: AddonModAssignAssign, + submission: AddonModAssignSubmission, + plugin: AddonModAssignPlugin, + inputData: any, + ): boolean | Promise; + + /** + * Whether or not the handler is enabled for edit on a site level. + * + * @return Whether or not the handler is enabled for edit on a site level. + */ + isEnabledForEdit?(): boolean | Promise; + + /** + * Prefetch any required data for the plugin. + * This should NOT prefetch files. Files to be prefetched should be returned by the getPluginFiles function. + * + * @param assign The assignment. + * @param submission The submission. + * @param plugin The plugin object. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + prefetch?( + assign: AddonModAssignAssign, + submission: AddonModAssignSubmission, + plugin: AddonModAssignPlugin, + siteId?: string, + ): Promise; + + /** + * Prepare and add to pluginData the data to send to the server based on the input data. + * + * @param assign The assignment. + * @param submission The submission. + * @param plugin The plugin object. + * @param inputData Data entered by the user for the submission. + * @param pluginData Object where to store the data to send. + * @param offline Whether the user is editing in offline. + * @param userId User ID. If not defined, site's current user. + * @param siteId Site ID. If not defined, current site. + * @return If the function is async, it should return a Promise resolved when done. + */ + prepareSubmissionData?( + assign: AddonModAssignAssign, + submission: AddonModAssignSubmission, + plugin: AddonModAssignPlugin, + inputData: any, + pluginData: any, + offline?: boolean, + userId?: number, + siteId?: string, + ): void | Promise; + + /** + * Prepare and add to pluginData the data to send to the server based on the offline data stored. + * This will be used when performing a synchronization. + * + * @param assign The assignment. + * @param submission The submission. + * @param plugin The plugin object. + * @param offlineData Offline data stored. + * @param pluginData Object where to store the data to send. + * @param siteId Site ID. If not defined, current site. + * @return If the function is async, it should return a Promise resolved when done. + */ + prepareSyncData?( + assign: AddonModAssignAssign, + submission: AddonModAssignSubmission, + plugin: AddonModAssignPlugin, + offlineData: any, + pluginData: any, + siteId?: string, + ): void | Promise; +} + +/** + * Delegate to register plugins for assign submission. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModAssignSubmissionDelegateService extends CoreDelegate { + + protected handlerNameProperty = 'type'; + + constructor( + protected defaultHandler: AddonModAssignDefaultSubmissionHandler, + ) { + super('AddonModAssignSubmissionDelegate', true); + } + + /** + * Whether the plugin can be edited in offline for existing submissions. + * + * @param assign The assignment. + * @param submission The submission. + * @param plugin The plugin object. + * @return Promise resolved with boolean: whether it can be edited in offline. + */ + async canPluginEditOffline( + assign: AddonModAssignAssign, + submission: AddonModAssignSubmission, + plugin: AddonModAssignPlugin, + ): Promise { + return await this.executeFunctionOnEnabled(plugin.type, 'canEditOffline', [assign, submission, plugin]); + } + + /** + * Clear some temporary data for a certain plugin because a submission was cancelled. + * + * @param assign The assignment. + * @param submission The submission. + * @param plugin The plugin object. + * @param inputData Data entered by the user for the submission. + */ + clearTmpData( + assign: AddonModAssignAssign, + submission: AddonModAssignSubmission, + plugin: AddonModAssignPlugin, + inputData: any, + ): void { + return this.executeFunctionOnEnabled(plugin.type, 'clearTmpData', [assign, submission, plugin, inputData]); + } + + /** + * Copy the data from last submitted attempt to the current submission for a certain plugin. + * + * @param assign The assignment. + * @param plugin The plugin object. + * @param pluginData Object where to store the data to send. + * @param userId User ID. If not defined, site's current user. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data has been copied. + */ + async copyPluginSubmissionData( + assign: AddonModAssignAssign, + plugin: AddonModAssignPlugin, + pluginData: any, + userId?: number, + siteId?: string, + ): Promise { + return await this.executeFunctionOnEnabled( + plugin.type, + 'copySubmissionData', + [assign, plugin, pluginData, userId, siteId], + ); + } + + /** + * Delete offline data stored for a certain submission and plugin. + * + * @param assign The assignment. + * @param submission The submission. + * @param plugin The plugin object. + * @param offlineData Offline data stored. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async deletePluginOfflineData( + assign: AddonModAssignAssign, + submission: AddonModAssignSubmission, + plugin: AddonModAssignPlugin, + offlineData: any, + siteId?: string, + ): Promise { + return await this.executeFunctionOnEnabled( + plugin.type, + 'deleteOfflineData', + [assign, submission, plugin, offlineData, siteId], + ); + } + + /** + * Get the component to use for a certain submission plugin. + * + * @param plugin The plugin object. + * @param edit Whether the user is editing. + * @return Promise resolved with the component to use, undefined if not found. + */ + async getComponentForPlugin(plugin: AddonModAssignPlugin, edit?: boolean): Promise { + return await this.executeFunctionOnEnabled(plugin.type, 'getComponent', [plugin, edit]); + } + + /** + * Get files used by this plugin. + * The files returned by this function will be prefetched when the user prefetches the assign. + * + * @param assign The assignment. + * @param submission The submission. + * @param plugin The plugin object. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the files. + */ + async getPluginFiles( + assign: AddonModAssignAssign, + submission: AddonModAssignSubmission, + plugin: AddonModAssignPlugin, + siteId?: string, + ): Promise { + const files: CoreWSExternalFile[] | undefined = + await this.executeFunctionOnEnabled(plugin.type, 'getPluginFiles', [assign, submission, plugin, siteId]); + + return files || []; + } + + /** + * Get a readable name to use for a certain submission plugin. + * + * @param plugin Plugin to get the name for. + * @return Human readable name. + */ + getPluginName(plugin: AddonModAssignPlugin): string | undefined { + return this.executeFunctionOnEnabled(plugin.type, 'getPluginName', [plugin]); + } + + /** + * Get the size of data (in bytes) this plugin will send to copy a previous submission. + * + * @param assign The assignment. + * @param plugin The plugin object. + * @return Promise resolved with size. + */ + async getPluginSizeForCopy(assign: AddonModAssignAssign, plugin: AddonModAssignPlugin): Promise { + return await this.executeFunctionOnEnabled(plugin.type, 'getSizeForCopy', [assign, plugin]); + } + + /** + * Get the size of data (in bytes) this plugin will send to add or edit a submission. + * + * @param assign The assignment. + * @param submission The submission. + * @param plugin The plugin object. + * @param inputData Data entered by the user for the submission. + * @return Promise resolved with size. + */ + async getPluginSizeForEdit( + assign: AddonModAssignAssign, + submission: AddonModAssignSubmission, + plugin: AddonModAssignPlugin, + inputData: any, + ): Promise { + return await this.executeFunctionOnEnabled( + plugin.type, + 'getSizeForEdit', + [assign, submission, plugin, inputData], + ); + } + + /** + * Check if the submission data has changed for a certain plugin. + * + * @param assign The assignment. + * @param submission The submission. + * @param plugin The plugin object. + * @param inputData Data entered by the user for the submission. + * @return Promise resolved with true if data has changed, resolved with false otherwise. + */ + async hasPluginDataChanged( + assign: AddonModAssignAssign, + submission: AddonModAssignSubmission, + plugin: AddonModAssignPlugin, + inputData: any, + ): Promise { + return await this.executeFunctionOnEnabled( + plugin.type, + 'hasDataChanged', + [assign, submission, plugin, inputData], + ); + } + + /** + * Check if a submission plugin is supported. + * + * @param pluginType Type of the plugin. + * @return Whether it's supported. + */ + isPluginSupported(pluginType: string): boolean { + return this.hasHandler(pluginType, true); + } + + /** + * Check if a submission plugin is supported for edit. + * + * @param pluginType Type of the plugin. + * @return Whether it's supported for edit. + */ + async isPluginSupportedForEdit(pluginType: string): Promise { + return await this.executeFunctionOnEnabled(pluginType, 'isEnabledForEdit'); + } + + /** + * Check if a plugin has no data. + * + * @param assign The assignment. + * @param plugin The plugin object. + * @return Whether the plugin is empty. + */ + isPluginEmpty(assign: AddonModAssignAssign, plugin: AddonModAssignPlugin): boolean | undefined { + return this.executeFunctionOnEnabled(plugin.type, 'isEmpty', [assign, plugin]); + } + + /** + * Prefetch any required data for a submission plugin. + * + * @param assign The assignment. + * @param submission The submission. + * @param plugin The plugin object. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async prefetch( + assign: AddonModAssignAssign, + submission: AddonModAssignSubmission, + plugin: AddonModAssignPlugin, + siteId?: string, + ): Promise { + return await this.executeFunctionOnEnabled(plugin.type, 'prefetch', [assign, submission, plugin, siteId]); + } + + /** + * Prepare and add to pluginData the data to submit for a certain submission plugin. + * + * @param assign The assignment. + * @param submission The submission. + * @param plugin The plugin object. + * @param inputData Data entered by the user for the submission. + * @param pluginData Object where to store the data to send. + * @param offline Whether the user is editing in offline. + * @param userId User ID. If not defined, site's current user. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when data has been gathered. + */ + async preparePluginSubmissionData( + assign: AddonModAssignAssign, + submission: AddonModAssignSubmission, + plugin: AddonModAssignPlugin, + inputData: any, + pluginData: any, + offline?: boolean, + userId?: number, + siteId?: string, + ): Promise { + + return await this.executeFunctionOnEnabled( + plugin.type, + 'prepareSubmissionData', + [assign, submission, plugin, inputData, pluginData, offline, userId, siteId], + ); + } + + /** + * Prepare and add to pluginData the data to send to server to synchronize an offline submission. + * + * @param assign The assignment. + * @param submission The submission. + * @param plugin The plugin object. + * @param offlineData Offline data stored. + * @param pluginData Object where to store the data to send. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when data has been gathered. + */ + async preparePluginSyncData( + assign: AddonModAssignAssign, + submission: AddonModAssignSubmission, + plugin: AddonModAssignPlugin, + offlineData: any, + pluginData: any, + siteId?: string, + ): Promise { + + return this.executeFunctionOnEnabled( + plugin.type, + 'prepareSyncData', + [assign, submission, plugin, offlineData, pluginData, siteId], + ); + } + +} +export class AddonModAssignSubmissionDelegate extends makeSingleton(AddonModAssignSubmissionDelegateService) {} diff --git a/src/addons/mod/mod.module.ts b/src/addons/mod/mod.module.ts index aafdf2906..9a8f91414 100644 --- a/src/addons/mod/mod.module.ts +++ b/src/addons/mod/mod.module.ts @@ -14,6 +14,7 @@ import { NgModule } from '@angular/core'; +import { AddonModAssignModule } from './assign/assign.module'; import { AddonModBookModule } from './book/book.module'; import { AddonModLessonModule } from './lesson/lesson.module'; import { AddonModPageModule } from './page/page.module'; @@ -21,6 +22,7 @@ import { AddonModPageModule } from './page/page.module'; @NgModule({ declarations: [], imports: [ + AddonModAssignModule, AddonModBookModule, AddonModLessonModule, AddonModPageModule, diff --git a/src/core/services/groups.ts b/src/core/services/groups.ts index 39541f9df..46465f044 100644 --- a/src/core/services/groups.ts +++ b/src/core/services/groups.ts @@ -412,7 +412,7 @@ export class CoreGroupsProvider { * @param groupInfo Group info. * @return Group ID to use. */ - validateGroupId(groupId: number, groupInfo: CoreGroupInfo): number { + validateGroupId(groupId = 0, groupInfo: CoreGroupInfo): number { if (groupId > 0 && groupInfo && groupInfo.groups && groupInfo.groups.length > 0) { // Check if the group is in the list of groups. if (groupInfo.groups.some((group) => groupId == group.id)) { diff --git a/src/core/services/navigator.ts b/src/core/services/navigator.ts index 683a366a9..67a6291f3 100644 --- a/src/core/services/navigator.ts +++ b/src/core/services/navigator.ts @@ -380,6 +380,11 @@ export class CoreNavigatorService { // IonTabs checks the URL to determine which path to load for deep linking, so we clear the URL. // @todo this.location.replaceState(''); + options = { + preferCurrentTab: true, + ...options, + }; + path = path.replace(/^(\.|\/main)?\//, ''); const pathRoot = /^[^/]+/.exec(path)?.[0] ?? ''; @@ -389,7 +394,7 @@ export class CoreNavigatorService { false, ); - if (options.preferCurrentTab === false && isMainMenuTab) { + if (!options.preferCurrentTab && isMainMenuTab) { return this.navigate(`/main/${path}`, options); } From 3281196ec04af612fb4f896756adb094c0812d24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Thu, 11 Feb 2021 12:27:11 +0100 Subject: [PATCH 05/17] MOBILE-3636 assign: Add assignment submission page --- .../assign/components/components.module.ts | 11 +- .../addon-mod-assign-feedback-plugin.html | 23 + .../feedback-plugin/feedback-plugin.ts | 117 ++ .../index/addon-mod-assign-index.html | 4 +- .../mod/assign/components/index/index.ts | 23 +- .../addon-mod-assign-submission-plugin.html | 23 + .../submission-plugin/submission-plugin.ts | 114 ++ .../addon-mod-assign-submission.html | 388 ++++++ .../components/submission/submission.scss | 263 ++++ .../components/submission/submission.ts | 1223 +++++++++++++++++ .../mod/assign/services/assign-helper.ts | 56 +- .../mod/assign/services/assign-offline.ts | 4 +- src/addons/mod/assign/services/assign-sync.ts | 4 +- src/addons/mod/assign/services/assign.ts | 13 +- .../mod/assign/services/database/assign.ts | 2 +- .../mod/assign/services/feedback-delegate.ts | 23 +- .../services/handlers/default-feedback.ts | 15 +- .../services/handlers/default-submission.ts | 15 +- .../assign/services/submission-delegate.ts | 14 +- 19 files changed, 2239 insertions(+), 96 deletions(-) create mode 100644 src/addons/mod/assign/components/feedback-plugin/addon-mod-assign-feedback-plugin.html create mode 100644 src/addons/mod/assign/components/feedback-plugin/feedback-plugin.ts create mode 100644 src/addons/mod/assign/components/submission-plugin/addon-mod-assign-submission-plugin.html create mode 100644 src/addons/mod/assign/components/submission-plugin/submission-plugin.ts create mode 100644 src/addons/mod/assign/components/submission/addon-mod-assign-submission.html create mode 100644 src/addons/mod/assign/components/submission/submission.scss create mode 100644 src/addons/mod/assign/components/submission/submission.ts diff --git a/src/addons/mod/assign/components/components.module.ts b/src/addons/mod/assign/components/components.module.ts index bdc46ebfb..5f2534604 100644 --- a/src/addons/mod/assign/components/components.module.ts +++ b/src/addons/mod/assign/components/components.module.ts @@ -21,13 +21,16 @@ import { TranslateModule } from '@ngx-translate/core'; import { CoreSharedModule } from '@/core/shared.module'; import { CoreCourseComponentsModule } from '@features/course/components/components.module'; import { AddonModAssignIndexComponent } from './index/index'; +import { AddonModAssignSubmissionComponent } from './submission/submission'; +import { AddonModAssignSubmissionPluginComponent } from './submission-plugin/submission-plugin'; +import { AddonModAssignFeedbackPluginComponent } from './feedback-plugin/feedback-plugin'; @NgModule({ declarations: [ AddonModAssignIndexComponent, - /* AddonModAssignSubmissionComponent, + AddonModAssignSubmissionComponent, AddonModAssignSubmissionPluginComponent, - AddonModAssignFeedbackPluginComponent*/ + AddonModAssignFeedbackPluginComponent, ], imports: [ CommonModule, @@ -39,9 +42,9 @@ import { AddonModAssignIndexComponent } from './index/index'; ], exports: [ AddonModAssignIndexComponent, - /* AddonModAssignSubmissionComponent, + AddonModAssignSubmissionComponent, AddonModAssignSubmissionPluginComponent, - AddonModAssignFeedbackPluginComponent */ + AddonModAssignFeedbackPluginComponent, ], }) export class AddonModAssignComponentsModule {} diff --git a/src/addons/mod/assign/components/feedback-plugin/addon-mod-assign-feedback-plugin.html b/src/addons/mod/assign/components/feedback-plugin/addon-mod-assign-feedback-plugin.html new file mode 100644 index 000000000..0c6116e3c --- /dev/null +++ b/src/addons/mod/assign/components/feedback-plugin/addon-mod-assign-feedback-plugin.html @@ -0,0 +1,23 @@ + + + + + + +

{{ plugin.name }}

+ + {{ 'addon.mod_assign.feedbacknotsupported' | translate }} + +

+ + +

+ + +
+
+
+
diff --git a/src/addons/mod/assign/components/feedback-plugin/feedback-plugin.ts b/src/addons/mod/assign/components/feedback-plugin/feedback-plugin.ts new file mode 100644 index 000000000..709c8aa72 --- /dev/null +++ b/src/addons/mod/assign/components/feedback-plugin/feedback-plugin.ts @@ -0,0 +1,117 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, OnInit, ViewChild, Type } from '@angular/core'; +import { CoreDynamicComponent } from '@components/dynamic-component/dynamic-component'; +import { CoreWSExternalFile } from '@services/ws'; +import { + AddonModAssignAssign, + AddonModAssignSubmission, + AddonModAssignPlugin, + AddonModAssignProvider, + AddonModAssign, +} from '../../services/assign'; +import { AddonModAssignHelper, AddonModAssignPluginConfig } from '../../services/assign-helper'; +import { AddonModAssignFeedbackDelegate } from '../../services/feedback-delegate'; + +/** + * Component that displays an assignment feedback plugin. + */ +@Component({ + selector: 'addon-mod-assign-feedback-plugin', + templateUrl: 'addon-mod-assign-feedback-plugin.html', +}) +export class AddonModAssignFeedbackPluginComponent implements OnInit { + + @ViewChild(CoreDynamicComponent) dynamicComponent!: CoreDynamicComponent; + + @Input() assign!: AddonModAssignAssign; // The assignment. + @Input() submission!: AddonModAssignSubmission; // The submission. + @Input() plugin!: AddonModAssignPlugin; // The plugin object. + @Input() userId!: number; // The user ID of the submission. + @Input() canEdit = false; // Whether the user can edit. + @Input() edit = false; // Whether the user is editing. + + pluginComponent?: Type; // Component to render the plugin. + data?: AddonModAssignFeedbackPluginData; // Data to pass to the component. + + // Data to render the plugin if it isn't supported. + component = AddonModAssignProvider.COMPONENT; + text = ''; + files: CoreWSExternalFile[] = []; + notSupported = false; + pluginLoaded = false; + + /** + * Component being initialized. + */ + async ngOnInit(): Promise { + if (!this.plugin) { + this.pluginLoaded = true; + + return; + } + + const name = AddonModAssignFeedbackDelegate.instance.getPluginName(this.plugin); + + if (!name) { + this.pluginLoaded = true; + + return; + } + this.plugin.name = name; + + // Check if the plugin has defined its own component to render itself. + this.pluginComponent = await AddonModAssignFeedbackDelegate.instance.getComponentForPlugin(this.plugin); + + if (this.pluginComponent) { + // Prepare the data to pass to the component. + this.data = { + assign: this.assign, + submission: this.submission, + plugin: this.plugin, + userId: this.userId, + configs: AddonModAssignHelper.instance.getPluginConfig(this.assign, 'assignfeedback', this.plugin.type), + edit: this.edit, + canEdit: this.canEdit, + }; + } else { + // Data to render the plugin. + this.text = AddonModAssign.instance.getSubmissionPluginText(this.plugin); + this.files = AddonModAssign.instance.getSubmissionPluginAttachments(this.plugin); + this.notSupported = AddonModAssignFeedbackDelegate.instance.isPluginSupported(this.plugin.type); + this.pluginLoaded = true; + } + } + + /** + * Invalidate the plugin data. + * + * @return Promise resolved when done. + */ + async invalidate(): Promise { + await this.dynamicComponent.callComponentFunction('invalidate', []); + } + +} + +export type AddonModAssignFeedbackPluginData = { + assign: AddonModAssignAssign; + submission: AddonModAssignSubmission; + plugin: AddonModAssignPlugin; + configs: AddonModAssignPluginConfig; + edit: boolean; + canEdit: boolean; + userId: number; +}; diff --git a/src/addons/mod/assign/components/index/addon-mod-assign-index.html b/src/addons/mod/assign/components/index/addon-mod-assign-index.html index 67c2169e9..c71bacbc9 100644 --- a/src/addons/mod/assign/components/index/addon-mod-assign-index.html +++ b/src/addons/mod/assign/components/index/addon-mod-assign-index.html @@ -135,8 +135,8 @@ - + diff --git a/src/addons/mod/assign/components/index/index.ts b/src/addons/mod/assign/components/index/index.ts index d62267888..89040249c 100644 --- a/src/addons/mod/assign/components/index/index.ts +++ b/src/addons/mod/assign/components/index/index.ts @@ -34,6 +34,7 @@ import { AddonModAssignGradedEventData, AddonModAssignProvider, AddonModAssignSubmissionGradingSummary, + AddonModAssignSubmittedForGradingEventData, } from '../../services/assign'; import { AddonModAssignOffline } from '../../services/assign-offline'; import { @@ -42,6 +43,7 @@ import { AddonModAssignSyncProvider, AddonModAssignSyncResult, } from '../../services/assign-sync'; +import { AddonModAssignSubmissionComponent } from '../submission/submission'; /** * Component that displays an assignment. @@ -52,8 +54,7 @@ import { }) export class AddonModAssignIndexComponent extends CoreCourseModuleMainActivityComponent implements OnInit, OnDestroy { - // @todo @ViewChild(AddonModAssignSubmissionComponent) submissionComponent?: AddonModAssignSubmissionComponent; - submissionComponent?: any; + @ViewChild(AddonModAssignSubmissionComponent) submissionComponent?: AddonModAssignSubmissionComponent; component = AddonModAssignProvider.COMPONENT; moduleName = 'assign'; @@ -112,15 +113,19 @@ export class AddonModAssignIndexComponent extends CoreCourseModuleMainActivityCo } }, this.siteId); - this.submittedObserver = CoreEvents.on(AddonModAssignProvider.SUBMITTED_FOR_GRADING_EVENT, (data) => { - if (this.assign && data.assignmentId == this.assign.id && data.userId == this.currentUserId) { + this.submittedObserver = CoreEvents.on( + AddonModAssignProvider.SUBMITTED_FOR_GRADING_EVENT, + (data) => { + if (this.assign && data.assignmentId == this.assign.id && data.userId == this.currentUserId) { // Assignment submitted, check completion. - CoreCourse.instance.checkModuleCompletion(this.courseId!, this.module!.completiondata); + CoreCourse.instance.checkModuleCompletion(this.courseId!, this.module!.completiondata); - // Reload data since it can have offline data now. - this.showLoadingAndRefresh(true, false); - } - }, this.siteId); + // Reload data since it can have offline data now. + this.showLoadingAndRefresh(true, false); + } + }, + this.siteId, + ); this.gradedObserver = CoreEvents.on(AddonModAssignProvider.GRADED_EVENT, (data) => { if (this.assign && data.assignmentId == this.assign.id && data.userId == this.currentUserId) { diff --git a/src/addons/mod/assign/components/submission-plugin/addon-mod-assign-submission-plugin.html b/src/addons/mod/assign/components/submission-plugin/addon-mod-assign-submission-plugin.html new file mode 100644 index 000000000..9ec50a95c --- /dev/null +++ b/src/addons/mod/assign/components/submission-plugin/addon-mod-assign-submission-plugin.html @@ -0,0 +1,23 @@ + + + + + + +

{{ plugin.name }}

+ + {{ 'addon.mod_assign.submissionnotsupported' | translate }} + +

+ + +

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

{{ user.fullname }}

+ +
+ +
+ + + + +

{{ 'addon.mod_assign.hiddenuser' | translate }} {{blindId}}

+ +
+ +
+ + + + +

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

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

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

+

{{ userSubmission.timemodified * 1000 | coreFormatDate }}

+
+
+ + + +

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

+

+
+
+ + + +

+

+

+

+
+
+ + + +

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

+

{{ assign.duedate * 1000 | coreFormatDate }}

+

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

+
+
+ + + +

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

+

{{ assign.cutoffdate * 1000 | coreFormatDate }}

+
+
+ + + +

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

+

{{ lastAttempt.extensionduedate * 1000 | coreFormatDate }}

+
+
+ + + +

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

+

+ {{ 'addon.mod_assign.outof' | translate : + {'$a': {'current': currentAttempt, 'total': maxAttemptsText} } }} +

+

+ {{ 'addon.mod_assign.outof' | translate : + {'$a': {'current': currentAttempt, 'total': assign.maxattempts} } }} +

+
+
+ + + + +
+ + + {{ 'addon.mod_assign.editsubmission' | translate }} + + + + {{ 'addon.mod_assign.addsubmission' | translate }} + + + + + {{ 'addon.mod_assign.addnewattemptfromprevious' | translate }} + + + {{ 'addon.mod_assign.addnewattempt' | translate }} + + + + + {{ 'addon.mod_assign.editsubmission' | translate }} + +
+
+

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

+

{{ name }}

+
+
+

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

+
+
+
+ + + + + + + + + + + + + + + {{ 'addon.mod_assign.submitassignment' | translate }} + +

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

+
+
+ + + +

+ {{ 'addon.mod_assign.cannotsubmitduetostatementsubmission' | translate }} +

+
+
+
+ + + +

{{ 'addon.mod_assign.userswhoneedtosubmit' | translate: {$a: ''} }}

+ + + + +

{{ user.fullname }}

+
+
+
+ + + + {{ 'addon.mod_assign.hiddenuser' | translate }} {{ blindId }} + + + +
+ + + +

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

+
+ + + + +

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

+

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

+

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

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

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

+

+
+ + + +
+ + + + + +

{{ 'addon.mod_assign.gradeoutof' | translate: {$a: gradeInfo!.grade} }}

+
+ + +

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

+
+ + + +

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

+ + + {{grade.label}} + + +
+ + + +

{{ outcome.name }}

+ + + {{grade.label}} + + +

{{ outcome.selected }}

+
+ + + + +

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

+

+ {{ grade.gradebookGrade }} +

+

+ {{ grade.scale[grade.gradebookGrade].label }} +

+

-

+
+
+
+ + + + + + + +

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

+

{{ workflowStatusTranslationId | translate }}

+
+
+ + + +

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

+ {{ 'addon.mod_assign.applytoteam' | translate }} + +
+ + + + + +

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

+

+ {{ 'addon.mod_assign.outof' | translate : + {'$a': {'current': currentAttempt, 'total': maxAttemptsText} } }} +

+

+ {{ 'addon.mod_assign.outof' | translate : + {'$a': {'current': currentAttempt, 'total': assign.maxattempts} } }} +

+

+ {{ 'addon.mod_assign.attemptreopenmethod' | translate }}: + {{ 'addon.mod_assign.attemptreopenmethod_' + assign.attemptreopenmethod | translate }} +

+
+
+ + {{ 'addon.mod_assign.addattempt' | translate }} + + +
+ + + + + +

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

+

{{ grader.fullname }}

+

{{ feedback.gradeddate * 1000 | coreFormatDate }}

+
+
+ + + + +

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

+

{{ feedback.gradeddate * 1000 | coreFormatDate }}

+
+
+ + +
+ +

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

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

{{lastAttempt!.submissiongroupname}}

+ +

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

+

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

+
+ +

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

+

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

+
+

+ {{ 'addon.mod_assign.defaultteam' | translate }} +

+
+
+ + + {{ statusTranslated }} + + + {{ gradingStatusTranslationId | translate }} + + diff --git a/src/addons/mod/assign/components/submission/submission.scss b/src/addons/mod/assign/components/submission/submission.scss new file mode 100644 index 000000000..dee6a5037 --- /dev/null +++ b/src/addons/mod/assign/components/submission/submission.scss @@ -0,0 +1,263 @@ +:host { + div.latesubmission, + div.overdue { + // @extend .core-danger-item; + } + + div.earlysubmission { + // @extend .core-success-item; + } + + div.submissioneditable p { + color: $red; + @include darkmode() { + color: $red-light; + } + } + + .core-grading-summary .advancedgrade { + display: none; + } +} + +core-format-text { + + .gradingform_rubric_editform .status { + font-weight: normal; + text-transform: uppercase; + font-size: 60%; + padding: 0.25em; + border: 1px solid $gray-light; + } + + .gradingform_rubric_editform .status.ready { + background-color: $green-light; + border-color: $green; + } + + .gradingform_rubric_editform .status.draft { + background-color: $yellow-light; + border-color: $yellow; + } + + .gradingform_rubric { + overflow: auto; + padding-bottom: 1.5em; + max-width: 720px; + position: relative; + margin: 0 auto; + tbody { + background: $white; + color: $text-color; + } + } + + // Do not display remark column. + .gradingform_rubric .criterion .remark { + display: none; + } + + .gradingform_rubric.editor .criterion .controls, + .gradingform_rubric .criterion .description, + .gradingform_rubric .criterion .levels, + .gradingform_rubric.editor .criterion .addlevel, + .gradingform_rubric .criterion .remark, + .gradingform_rubric .criterion .levels .level { + vertical-align: top; + } + + .gradingform_rubric.editor .criterion .controls, + .gradingform_rubric .criterion .description, + .gradingform_rubric.editor .criterion .addlevel, + .gradingform_rubric .criterion .remark, + .gradingform_rubric .criterion .levels .level { + padding: 3px; + } + + .gradingform_rubric .criteria { + height: 100%; + } + + .gradingform_rubric .criterion { + border: 1px solid $gray; + overflow: hidden; + } + + .gradingform_rubric .criterion.even { + background: $gray-lighter; + } + + .gradingform_rubric .criterion .description { + width: 150px; + font-weight: bold; + } + + .gradingform_rubric .criterion .levels table { + width: 100%; + height: 100%; + } + + .gradingform_rubric .criterion .levels, + .gradingform_rubric .criterion .levels table, + .gradingform_rubric .criterion .levels table tbody { + padding: 0; + margin: 0; + } + + .gradingform_rubric .criterion .levels .level { + border-left: 1px solid $gray; + max-width: 150px; + } + + .gradingform_rubric .criterion .levels .level .level-wrapper { + position: relative; + } + + .gradingform_rubric .criterion .levels .level.last { + border-right: 1px solid $gray; + } + + .gradingform_rubric .plainvalue.empty { + font-style: italic; + color: $gray-dark; + } + + .gradingform_rubric.editor .criterion .levels .level .delete { + position: absolute; + right: 0; + } + + .gradingform_rubric .criterion .levels .level .score { + font-style: italic; + color: $green; + font-weight: bold; + margin-top: 5px; + white-space: nowrap; + } + + .gradingform_rubric .criterion .levels .level .score .scorevalue { + padding-right: 5px; + } + + /* Make invisible the buttons 'Move up' for the first criterion and + 'Move down' for the last, because those buttons will make no change */ + .gradingform_rubric.editor .criterion.first .controls .moveup input, + .gradingform_rubric.editor .criterion.last .controls .movedown input { + display: none; + } + + /* evaluation */ + .gradingform_rubric .criterion .levels .level.currentchecked { + background: #fff0f0; + } + + .gradingform_rubric .criterion .levels .level.checked { + background: $green-light; + border: 1px solid $gray-darker; + } + + .gradingform_rubric .options .optionsheading { + font-weight: bold; + font-size: 1.1em; + padding-bottom: 5px; + } + + .gradingform_rubric .options .option { + padding-bottom: 2px; + } + + .gradingform_rubric .options .option label { + margin-left: 5px; + } + + .gradingform_rubric .options .option .value { + margin-left: 5px; + font-weight: bold; + } + + .gradingform_rubric .criterion .levels.error { + border: 1px solid $red; + } + + .gradingform_rubric .criterion .description.error, + .gradingform_rubric .criterion .levels .level .definition.error, + .gradingform_rubric .criterion .levels .level .score.error { + background: $gray-lighter; + } + + .gradingform_rubric-regrade { + padding: 10px; + background: $gray-lighter; + border: 1px solid $red-light; + margin-bottom: 10px; + } + + .gradingform_rubric-restored { + padding: 10px; + background: $yellow-light; + border: 1px solid $yellow; + margin-bottom: 10px; + } + + .gradingform_rubric-error { + color: $red; + font-weight: bold; + } + + /* special classes for elements created by rubriceditor.js */ + .gradingform_rubric.editor .hiddenelement { + display: none; + } + + .gradingform_rubric.editor .pseudotablink { + background-color: transparent; + border: 0 solid; + height: 1px; + width: 1px; + color: transparent; + padding: 0; + margin: 0; + position: relative; + float: right; + } + + .gradingform_rubric { + padding-bottom: 0; + max-width: none; + } + + .gradingform_rubric .criterion .description { + font-weight: 500; + min-width: 150px; + } + + .gradingform_rubric .criterion .levels { + background-color: $white; + } + + .gradingform_rubric .criterion, + .gradingform_rubric .criterion.even { + background-color: transparent; + } + + .gradingform_rubric.evaluate .criterion .levels .level:hover { + background-color: $green-light; + } + + .gradingform_rubric .criterion .levels .level.checked { + background-color: $green-light; + border: none; + border-left: 1px solid $gray; + } + + .gradingform_rubric .criterion .levels .level .score { + color: $green; + font-weight: 500; + font-style: normal; + margin-top: 20px; + } + + .gradingform_rubric .criterion .remark textarea { + margin-bottom: 0; + } +} diff --git a/src/addons/mod/assign/components/submission/submission.ts b/src/addons/mod/assign/components/submission/submission.ts new file mode 100644 index 000000000..e4e2114e5 --- /dev/null +++ b/src/addons/mod/assign/components/submission/submission.ts @@ -0,0 +1,1223 @@ +// (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, OnDestroy, ViewChild, Optional, ViewChildren, QueryList } from '@angular/core'; +import { CoreEvents, CoreEventObserver } from '@singletons/events'; +import { CoreSites } from '@services/sites'; +import { + AddonModAssignProvider, + AddonModAssignAssign, + AddonModAssignSubmissionFeedback, + AddonModAssignSubmissionAttempt, + AddonModAssignSubmissionPreviousAttempt, + AddonModAssignPlugin, + AddonModAssign, + AddonModAssignGetSubmissionStatusWSResponse, + AddonModAssignSubmittedForGradingEventData, + AddonModAssignSavePluginData, +} from '../../services/assign'; +import { + AddonModAssignAutoSyncData, + AddonModAssignManualSyncData, + AddonModAssignSync, + AddonModAssignSyncProvider, +} from '../../services/assign-sync'; +import { CoreTabsComponent } from '@components/tabs/tabs'; +import { CoreTabComponent } from '@components/tabs/tab'; +import { CoreSplitViewComponent } from '@components/split-view/split-view'; +import { CoreGradesFormattedItem, CoreGradesHelper } from '@features/grades/services/grades-helper'; +import { CoreMenuItem, CoreUtils } from '@services/utils/utils'; +import { AddonModAssignHelper, AddonModAssignSubmissionFormatted } from '../../services/assign-helper'; +import { CoreDomUtils } from '@services/utils/dom'; +import { Translate } from '@singletons'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreCourse, CoreCourseModuleGradeInfo, CoreCourseModuleGradeOutcome } from '@features/course/services/course'; +import { AddonModAssignOffline } from '../../services/assign-offline'; +import { CoreUser, CoreUserProfile } from '@features/user/services/user'; +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreNavigator } from '@services/navigator'; +import { CoreApp } from '@services/app'; +import { CoreFileUploaderHelper } from '@features/fileuploader/services/fileuploader-helper'; +import { CoreLang } from '@services/lang'; +import { CoreError } from '@classes/errors/error'; +import { CoreGroups } from '@services/groups'; +import { CoreSync } from '@services/sync'; +import { AddonModAssignSubmissionPluginComponent } from '../submission-plugin/submission-plugin'; + +/** + * Component that displays an assignment submission. + */ +@Component({ + selector: 'addon-mod-assign-submission', + templateUrl: 'addon-mod-assign-submission.html', +}) +export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy { + + @ViewChild(CoreTabsComponent) tabs!: CoreTabsComponent; + @ViewChildren(AddonModAssignSubmissionPluginComponent) submissionComponents!: + QueryList; + + @Input() courseId!: number; // Course ID the submission belongs to. + @Input() moduleId!: number; // Module ID the submission belongs to. + @Input() submitId!: number; // User that did the submission. + @Input() blindId?: number; // Blinded user ID (if it's blinded). + @Input() showGrade = false; // Whether to display the grade tab at start. + + loaded = false; // Whether data has been loaded. + selectedTab = 'submission'; // Tab selected on start. + assign?: AddonModAssignAssign; // The assignment the submission belongs to. + userSubmission?: AddonModAssignSubmissionFormatted; // The submission object. + isSubmittedForGrading = false; // Whether the submission has been submitted for grading. + acceptStatement = false; // Statement accepted (for grading). + feedback?: AddonModAssignSubmissionFeedbackFormatted; // The feedback. + hasOffline = false; // Whether there is offline data. + submittedOffline = false; // Whether it was submitted in offline. + fromDate?: string; // Readable date when the assign started accepting submissions. + currentAttempt = 0; // The current attempt number. + maxAttemptsText: string; // The text for maximum attempts. + blindMarking = false; // Whether blind marking is enabled. + user?: CoreUserProfile; // The user. + lastAttempt?: AddonModAssignSubmissionAttemptFormatted; // The last attempt. + membersToSubmit: CoreUserProfile[] = []; // Team members that need to submit the assignment. + membersToSubmitBlind: number[] = []; // Team members that need to submit the assignment (blindmarking). + canSubmit = false; // Whether the user can submit for grading. + canEdit = false; // Whether the user can edit the submission. + submissionStatement?: string; // The submission statement. + showErrorStatementEdit = false; // Whether to show an error in edit due to submission statement. + showErrorStatementSubmit = false; // Whether to show an error in submit due to submission statement. + gradingStatusTranslationId?: string; // Key of the text to display for the grading status. + gradingColor = ''; // Color to apply to the grading status. + workflowStatusTranslationId?: string; // Key of the text to display for the workflow status. + submissionPlugins: AddonModAssignPlugin[] = []; // List of submission plugins. + timeRemaining = ''; // Message about time remaining. + timeRemainingClass = ''; // Class to apply to time remaining message. + statusTranslated?: string; // Status. + statusColor = ''; // Color to apply to the status. + unsupportedEditPlugins: string[] = []; // List of submission plugins that don't support edit. + grade: AddonModAssignSubmissionGrade = { + method: '', + modified: 0, + addAttempt : false, + applyToAll: false, + lang: 'en', + disabled: false, + }; // Data about the grade. + + grader?: CoreUserProfile; // Profile of the teacher that graded the submission. + gradeInfo?: AddonModAssignGradeInfo; // Grade data for the assignment, retrieved from the server. + isGrading = false; // Whether the user is grading. + canSaveGrades = false; // Whether the user can save the grades. + allowAddAttempt = false; // Allow adding a new attempt when grading. + gradeUrl?: string; // URL to grade in browser. + + // Some constants. + statusNew = AddonModAssignProvider.SUBMISSION_STATUS_NEW; + statusReopened = AddonModAssignProvider.SUBMISSION_STATUS_REOPENED; + attemptReopenMethodNone = AddonModAssignProvider.ATTEMPT_REOPEN_METHOD_NONE; + unlimitedAttempts = AddonModAssignProvider.UNLIMITED_ATTEMPTS; + + protected siteId: string; // Current site ID. + protected currentUserId: number; // Current user ID. + protected previousAttempt?: AddonModAssignSubmissionPreviousAttempt; // The previous attempt. + protected isPreviousAttemptEmpty = true; // Whether the previous attempt contains an empty submission. + protected submissionStatusAvailable = false; // Whether we were able to retrieve the submission status. + protected originalGrades: AddonModAssignSubmissionOriginalGrades = { + addAttempt: false, + applyToAll: false, + outcomes: {}, + }; // Object with the original grade data, to check for changes. + + protected isDestroyed = false; // Whether the component has been destroyed. + protected syncObserver: CoreEventObserver; + protected hasOfflineGrade = false; + + constructor( + @Optional() protected splitviewCtrl: CoreSplitViewComponent, + ) { + this.siteId = CoreSites.instance.getCurrentSiteId(); + this.currentUserId = CoreSites.instance.getCurrentSiteUserId(); + this.maxAttemptsText = Translate.instance.instant('addon.mod_assign.unlimitedattempts'); + + // Refresh data if this assign is synchronized and it's grading. + const events = [AddonModAssignSyncProvider.AUTO_SYNCED, AddonModAssignSyncProvider.MANUAL_SYNCED]; + this.syncObserver = CoreEvents.onMultiple( + events, + async (data) => { + // Check that user is grading and this grade wasn't blocked when sync was performed. + if (!this.loaded || !this.isGrading || data.gradesBlocked.indexOf(this.submitId) != -1) { + return; + } + + if ('context' in data && data.context == 'submission' && data.submitId == this.submitId) { + // Manual sync triggered by this same submission, ignore it. + return; + } + + // Don't refresh if the user has modified some data. + const hasDataToSave = await this.hasDataToSave(); + + if (!hasDataToSave) { + this.invalidateAndRefresh(false); + } + }, + this.siteId, + ); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.selectedTab = this.showGrade ? 'grade' : 'submission'; + this.isSubmittedForGrading = !!this.submitId; + + this.loadData(true); + } + + /** + * Calculate the time remaining message and class. + * + * @param response Response of get submission status. + */ + protected calculateTimeRemaining(response: AddonModAssignGetSubmissionStatusWSResponse): void { + if (this.assign!.duedate <= 0) { + this.timeRemaining = ''; + this.timeRemainingClass = ''; + + return; + } + + const time = CoreTimeUtils.instance.timestamp(); + const dueDate = response.lastattempt?.extensionduedate + ? response.lastattempt.extensionduedate + : this.assign!.duedate; + const timeRemaining = dueDate - time; + + if (timeRemaining > 0) { + this.timeRemaining = CoreTimeUtils.instance.formatDuration(timeRemaining, 3); + this.timeRemainingClass = ''; + + return; + } + + // Not submitted. + if (!this.userSubmission || this.userSubmission.status != AddonModAssignProvider.SUBMISSION_STATUS_SUBMITTED) { + + if (response.lastattempt?.submissionsenabled || response.gradingsummary?.submissionsenabled) { + this.timeRemaining = Translate.instance.instant( + 'addon.mod_assign.overdue', + { $a: CoreTimeUtils.instance.formatDuration(-timeRemaining, 3) }, + ); + this.timeRemainingClass = 'overdue'; + + return; + } + + this.timeRemaining = Translate.instance.instant('addon.mod_assign.duedatereached'); + this.timeRemainingClass = ''; + + return; + } + + const timeSubmittedDiff = this.userSubmission.timemodified - dueDate; + if (timeSubmittedDiff > 0) { + this.timeRemaining = Translate.instance.instant( + 'addon.mod_assign.submittedlate', + { $a: CoreTimeUtils.instance.formatDuration(timeSubmittedDiff, 2) }, + ); + this.timeRemainingClass = 'latesubmission'; + + return; + } + + this.timeRemaining = Translate.instance.instant( + 'addon.mod_assign.submittedearly', + { $a: CoreTimeUtils.instance.formatDuration(-timeSubmittedDiff, 2) }, + ); + this.timeRemainingClass = 'earlysubmission'; + } + + /** + * Check if the user can leave the view. If there are changes to be saved, it will ask for confirm. + * + * @return Promise resolved if can leave the view, rejected otherwise. + */ + async canLeave(): Promise { + // Check if there is data to save. + const modified = await this.hasDataToSave(); + + if (modified) { + // Modified, confirm user wants to go back. + try { + await CoreDomUtils.instance.showConfirm(Translate.instance.instant('core.confirmcanceledit')); + await this.discardDrafts(); + } catch { + // Cancelled by the user. + } + } + } + + /** + * Copy a previous attempt and then go to edit. + */ + async copyPrevious(): Promise { + if (!CoreApp.instance.isOnline()) { + CoreDomUtils.instance.showErrorModal('core.networkerrormsg', true); + + return; + } + + if (!this.previousAttempt?.submission) { + // Cannot access previous attempts, just go to edit. + return this.goToEdit(); + } + + const previousSubmission = this.previousAttempt.submission; + let modal = await CoreDomUtils.instance.showModalLoading(); + + const size = await CoreUtils.instance.ignoreErrors( + AddonModAssignHelper.instance.getSubmissionSizeForCopy(this.assign!, previousSubmission), + -1, + ); // Error calculating size, return -1. + + modal.dismiss(); + + try { + // Confirm action. + await CoreFileUploaderHelper.instance.confirmUploadFile(size, true); + } catch { + // Cancelled. + return; + } + + // User confirmed, copy the attempt. + modal = await CoreDomUtils.instance.showModalLoading('core.sending', true); + + try { + await AddonModAssignHelper.instance.copyPreviousAttempt(this.assign!, previousSubmission); + // Now go to edit. + this.goToEdit(); + + if (!this.assign!.submissiondrafts) { + // No drafts allowed, so it was submitted. Trigger event. + CoreEvents.trigger(AddonModAssignProvider.SUBMITTED_FOR_GRADING_EVENT, { + assignmentId: this.assign!.id, + submissionId: this.userSubmission!.id, + userId: this.currentUserId, + }, this.siteId); + } else { + // Invalidate and refresh data to update this view. + await this.invalidateAndRefresh(true); + } + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.error', true); + } finally { + modal.dismiss(); + } + } + + /** + * Discard feedback drafts. + * + * @return Promise resolved when done. + */ + protected async discardDrafts(): Promise { + if (this.feedback && this.feedback.plugins) { + await AddonModAssignHelper.instance.discardFeedbackPluginData(this.assign!.id, this.submitId, this.feedback); + } + } + + /** + * Go to the page to add or edit submission. + */ + goToEdit(): void { + CoreNavigator.instance.navigate('AddonModAssignEditPage', { + params: { + moduleId: this.moduleId, + courseId: this.courseId, + userId: this.submitId, + blindId: this.blindId, + } }); + } + + /** + * Check if there's data to save (grade). + * + * @param isSubmit Whether the user is about to submit the grade. + * @return Promise resolved with boolean: whether there's data to save. + */ + protected async hasDataToSave(isSubmit = false): Promise { + if (!this.canSaveGrades || !this.loaded) { + return false; + } + + if (isSubmit && this.hasOfflineGrade) { + // Always allow sending if the grade is saved in offline. + return true; + } + + // Check if numeric grade and toggles changed. + if (this.originalGrades.grade != this.grade.grade || this.originalGrades.addAttempt != this.grade.addAttempt || + this.originalGrades.applyToAll != this.grade.applyToAll) { + return true; + } + + // Check if outcomes changed. + if (this.gradeInfo && this.gradeInfo.outcomes) { + for (const x in this.gradeInfo.outcomes) { + const outcome = this.gradeInfo.outcomes[x]; + + if (this.originalGrades.outcomes[outcome.id] == 'undefined' || + this.originalGrades.outcomes[outcome.id] != outcome.selectedId) { + return true; + } + } + } + + if (!this.feedback?.plugins) { + return false; + } + + try { + return AddonModAssignHelper.instance.hasFeedbackDataChanged( + this.assign!, + this.userSubmission, + this.feedback, + this.submitId, + ); + } catch (error) { + // Error ocurred, consider there are no changes. + return false; + } + } + + /** + * User entered the page that contains the component. + */ + ionViewDidEnter(): void { + this.tabs?.ionViewDidEnter(); + } + + /** + * User left the page that contains the component. + */ + ionViewDidLeave(): void { + this.tabs?.ionViewDidLeave(); + } + + /** + * Invalidate and refresh data. + * + * @param sync Whether to try to synchronize data. + * @return Promise resolved when done. + */ + async invalidateAndRefresh(sync = false): Promise { + this.loaded = false; + + const promises: Promise[] = []; + + promises.push(AddonModAssign.instance.invalidateAssignmentData(this.courseId)); + if (this.assign) { + promises.push(AddonModAssign.instance.invalidateSubmissionStatusData( + this.assign!.id, + this.submitId, + undefined, + !!this.blindId, + )); + promises.push(AddonModAssign.instance.invalidateAssignmentUserMappingsData(this.assign!.id)); + promises.push(AddonModAssign.instance.invalidateListParticipantsData(this.assign!.id)); + } + promises.push(CoreGradesHelper.instance.invalidateGradeModuleItems(this.courseId, this.submitId)); + promises.push(CoreCourse.instance.invalidateModule(this.moduleId)); + + // Invalidate plugins. + if (this.submissionComponents && this.submissionComponents.length) { + this.submissionComponents.forEach((component) => { + promises.push(component.invalidate()); + }); + } + + await CoreUtils.instance.ignoreErrors(Promise.all(promises)); + + await this.loadData(sync); + } + + /** + * Load the data to render the submission. + * + * @param sync Whether to try to synchronize data. + * @return Promise resolved when done. + */ + protected async loadData(sync = false): Promise { + let isBlind = !!this.blindId; + + this.previousAttempt = undefined; + this.isPreviousAttemptEmpty = true; + + if (!this.submitId) { + this.submitId = this.currentUserId; + isBlind = false; + } + + try { + // Get the assignment. + this.assign = await AddonModAssign.instance.getAssignment(this.courseId, this.moduleId); + + if (this.submitId != this.currentUserId && sync) { + // Teacher viewing a student submission. Try to sync the assign, there could be offline grades stored. + try { + const result = await AddonModAssignSync.instance.syncAssign(this.assign.id); + + if (result && result.updated) { + CoreEvents.trigger(AddonModAssignSyncProvider.MANUAL_SYNCED, { + assignId: this.assign.id, + warnings: result.warnings, + gradesBlocked: result.gradesBlocked, + context: 'submission', + submitId: this.submitId, + }, this.siteId); + } + } catch (error) { + // Ignore errors, probably user is offline or sync is blocked. + } + } + + const time = CoreTimeUtils.instance.timestamp(); + let promises: Promise[] = []; + + if (this.assign.allowsubmissionsfromdate && this.assign.allowsubmissionsfromdate >= time) { + this.fromDate = CoreTimeUtils.instance.userDate(this.assign.allowsubmissionsfromdate * 1000); + } + + this.blindMarking = this.isSubmittedForGrading && !!this.assign.blindmarking && !this.assign.revealidentities; + + if (!this.blindMarking && this.submitId != this.currentUserId) { + promises.push(this.loadSubmissionUserProfile()); + } + + // Check if there's any offline data for this submission. + promises.push(this.loadSubmissionOfflineData()); + + await Promise.all(promises); + + // Get submission status. + const submissionStatus = + await AddonModAssign.instance.getSubmissionStatusWithRetry(this.assign, { userId: this.submitId, isBlind }); + + this.submissionStatusAvailable = true; + this.lastAttempt = submissionStatus.lastattempt; + this.membersToSubmit = []; + this.membersToSubmitBlind = []; + + // Search the previous attempt. + if (submissionStatus.previousattempts && submissionStatus.previousattempts.length > 0) { + const previousAttempts = submissionStatus.previousattempts.sort((a, b) => a.attemptnumber - b.attemptnumber); + this.previousAttempt = previousAttempts[previousAttempts.length - 1]; + this.isPreviousAttemptEmpty = + AddonModAssignHelper.instance.isSubmissionEmpty(this.assign, this.previousAttempt.submission); + } + + // Treat last attempt. + promises = this.treatLastAttempt(submissionStatus); + + // Calculate the time remaining. + this.calculateTimeRemaining(submissionStatus); + + // Load the feedback. + promises.push(this.loadFeedback(submissionStatus.feedback)); + + // Check if there's any unsupported plugin for editing. + if (!this.userSubmission || !this.userSubmission.plugins) { + // Submission not created yet, we have to use assign configs to detect the plugins used. + this.userSubmission = AddonModAssignHelper.instance.createEmptySubmission(); + this.userSubmission.plugins = AddonModAssignHelper.instance.getPluginsEnabled(this.assign, 'assignsubmission'); + } + + // Get the submission plugins that don't support editing. + promises.push(this.loadUnsupportedPlugins()); + + await Promise.all(promises); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'Error getting assigment data.'); + } finally { + this.loaded = true; + } + } + + /** + * Load profile of submission's user. + * + * @return Promise resolved when done. + */ + protected async loadSubmissionUserProfile(): Promise { + this.user = await CoreUser.instance.getProfile(this.submitId, this.courseId); + } + + /** + * Load offline data for the submission (not the submission grade). + * + * @return Promise resolved when done. + */ + protected async loadSubmissionOfflineData(): Promise { + try { + const submission = await AddonModAssignOffline.instance.getSubmission(this.assign!.id, this.submitId); + + this.hasOffline = submission && submission.plugindata && Object.keys(submission.plugindata).length > 0; + + this.submittedOffline = !!submission?.submitted; + } catch (error) { + // No offline data found. + this.hasOffline = false; + this.submittedOffline = false; + } + } + + /** + * Load the data to render the feedback and grade. + * + * @param feedback The feedback data from the submission status. + * @return Promise resolved when done. + */ + protected async loadFeedback(feedback?: AddonModAssignSubmissionFeedback): Promise { + this.grade = { + method: '', + modified: 0, + addAttempt : false, + applyToAll: false, + lang: '', + disabled: false, + }; + + this.originalGrades = { + addAttempt: false, + applyToAll: false, + outcomes: {}, + }; + + if (feedback) { + this.feedback = feedback; + + // If we have data about the grader, get its profile. + if (feedback.grade && feedback.grade.grader > 0) { + try { + this.grader = await CoreUser.instance.getProfile(feedback.grade.grader, this.courseId); + } catch { + // Ignore errors. + } + } else { + delete this.grader; + } + + // Check if the grade uses advanced grading. + if (feedback.gradefordisplay) { + const position = feedback.gradefordisplay.indexOf('class="advancedgrade"'); + if (position > -1) { + this.feedback.advancedgrade = true; + } + } + + // Do not override already loaded grade. + if (feedback.grade && feedback.grade.grade && !this.grade.grade) { + const parsedGrade = parseFloat(feedback.grade.grade); + + this.grade!.grade = parsedGrade >= 0 ? parsedGrade : undefined; + this.grade.gradebookGrade = CoreUtils.instance.formatFloat(this.grade.grade); + this.originalGrades.grade = this.grade.grade; + } + } else { + // If no feedback, always show Submission. + this.selectedTab = 'submission'; + this.tabs.selectTab(this.selectedTab); + } + + this.grade.gradingStatus = this.lastAttempt?.gradingstatus; + + // Get the grade for the assign. + this.gradeInfo = await CoreCourse.instance.getModuleBasicGradeInfo(this.moduleId); + + if (!this.gradeInfo) { + // It won't get gradeinfo on 3.1. + return; + } + + // Treat the grade info. + await this.treatGradeInfo(); + + const isManual = this.assign!.attemptreopenmethod == AddonModAssignProvider.ATTEMPT_REOPEN_METHOD_MANUAL; + const isUnlimited = this.assign!.maxattempts == AddonModAssignProvider.UNLIMITED_ATTEMPTS; + const isLessThanMaxAttempts = !!this.userSubmission && (this.userSubmission.attemptnumber < (this.assign!.maxattempts - 1)); + + this.allowAddAttempt = isManual && (!this.userSubmission || isUnlimited || isLessThanMaxAttempts); + + if (this.assign!.teamsubmission) { + this.grade.applyToAll = true; + this.originalGrades.applyToAll = true; + } + if (this.assign!.markingworkflow && this.grade.gradingStatus) { + this.workflowStatusTranslationId = + AddonModAssign.instance.getSubmissionGradingStatusTranslationId(this.grade.gradingStatus); + } + + if (this.lastAttempt?.gradingstatus == 'graded' && !this.assign!.markingworkflow) { + if (this.feedback!.gradeddate < this.lastAttempt!.submission!.timemodified) { + this.lastAttempt.gradingstatus = AddonModAssignProvider.GRADED_FOLLOWUP_SUBMIT; + + // Get grading text and color. + this.gradingStatusTranslationId = AddonModAssign.instance.getSubmissionGradingStatusTranslationId( + this.lastAttempt.gradingstatus, + ); + this.gradingColor = AddonModAssign.instance.getSubmissionGradingStatusColor(this.lastAttempt.gradingstatus); + + } + } + + if (!this.feedback || !this.feedback.plugins) { + // Feedback plugins not present, we have to use assign configs to detect the plugins used. + this.feedback = AddonModAssignHelper.instance.createEmptyFeedback(); + this.feedback.plugins = AddonModAssignHelper.instance.getPluginsEnabled(this.assign!, 'assignfeedback'); + } + + // Check if there's any offline data for this submission. + if (!this.canSaveGrades) { + // User cannot save grades in the app. Load the URL to grade it in browser. + const mod = await CoreCourse.instance.getModule(this.moduleId, this.courseId, undefined, true); + this.gradeUrl = mod.url + '&action=grader&userid=' + this.submitId; + + return; + } + + // Submission grades aren't identified by attempt number so it can retrieve the feedback for a previous attempt. + // The app will not treat that as an special case. + const submissionGrade = await CoreUtils.instance.ignoreErrors( + AddonModAssignOffline.instance.getSubmissionGrade(this.assign!.id, this.submitId), + ); + + this.hasOfflineGrade = false; + + // Load offline grades. + if (submissionGrade && (!feedback || !feedback.gradeddate || feedback.gradeddate < submissionGrade.timemodified)) { + // If grade has been modified from gradebook, do not use offline. + if ((this.grade.modified || 0) < submissionGrade.timemodified) { + this.hasOfflineGrade = true; + this.grade.grade = !this.grade.scale + ? CoreUtils.instance.formatFloat(submissionGrade.grade) + : submissionGrade.grade; + this.gradingStatusTranslationId = 'addon.mod_assign.gradenotsynced'; + this.gradingColor = ''; + this.originalGrades.grade = this.grade.grade; + } + + this.grade.applyToAll = !!submissionGrade.applytoall; + this.grade.addAttempt = !!submissionGrade.addattempt; + this.originalGrades.applyToAll = !!this.grade.applyToAll; + this.originalGrades.addAttempt = !!this.grade.addAttempt; + + if (submissionGrade.outcomes && Object.keys(submissionGrade.outcomes).length && this.gradeInfo?.outcomes) { + this.gradeInfo.outcomes.forEach((outcome) => { + if (typeof submissionGrade.outcomes[outcome.itemNumber!] != 'undefined') { + // If outcome has been modified from gradebook, do not use offline. + if (outcome.modified! < submissionGrade.timemodified) { + outcome.selectedId = submissionGrade.outcomes[outcome.itemNumber!]; + this.originalGrades.outcomes[outcome.id] = outcome.selectedId; + } + } + }); + } + } + } + + /** + * Get the submission plugins that don't support editing. + * + * @return Promise resolved when done. + */ + protected async loadUnsupportedPlugins(): Promise { + this.unsupportedEditPlugins = await AddonModAssign.instance.getUnsupportedEditPlugins(this.userSubmission?.plugins || []); + } + + /** + * Set the submission status name and class. + * + * @param status Submission status. + */ + protected setStatusNameAndClass(status: AddonModAssignGetSubmissionStatusWSResponse): void { + const translateService = Translate.instance; + + if (this.hasOffline || this.submittedOffline) { + // Offline data. + this.statusTranslated = translateService.instant('core.notsent'); + this.statusColor = 'warning'; + } else if (!this.assign!.teamsubmission) { + + // Single submission. + if (this.userSubmission && this.userSubmission.status != this.statusNew) { + this.statusTranslated = translateService.instant('addon.mod_assign.submissionstatus_' + this.userSubmission.status); + this.statusColor = AddonModAssign.instance.getSubmissionStatusColor(this.userSubmission.status); + } else { + if (!status.lastattempt?.submissionsenabled) { + this.statusTranslated = translateService.instant('addon.mod_assign.noonlinesubmissions'); + this.statusColor = AddonModAssign.instance.getSubmissionStatusColor('noonlinesubmissions'); + } else { + this.statusTranslated = translateService.instant('addon.mod_assign.noattempt'); + this.statusColor = AddonModAssign.instance.getSubmissionStatusColor('noattempt'); + } + } + } else { + + // Team submission. + if (!status.lastattempt?.submissiongroup && this.assign!.preventsubmissionnotingroup) { + this.statusTranslated = translateService.instant('addon.mod_assign.nosubmission'); + this.statusColor = AddonModAssign.instance.getSubmissionStatusColor('nosubmission'); + } else if (this.userSubmission && this.userSubmission.status != this.statusNew) { + this.statusTranslated = translateService.instant('addon.mod_assign.submissionstatus_' + this.userSubmission.status); + this.statusColor = AddonModAssign.instance.getSubmissionStatusColor(this.userSubmission.status); + } else { + if (!status.lastattempt?.submissionsenabled) { + this.statusTranslated = translateService.instant('addon.mod_assign.noonlinesubmissions'); + this.statusColor = AddonModAssign.instance.getSubmissionStatusColor('noonlinesubmissions'); + } else { + this.statusTranslated = translateService.instant('addon.mod_assign.nosubmission'); + this.statusColor = AddonModAssign.instance.getSubmissionStatusColor('nosubmission'); + } + } + } + } + + /** + * Show advanced grade. + */ + showAdvancedGrade(): void { + if (this.feedback && this.feedback.advancedgrade) { + CoreTextUtils.instance.viewText( + Translate.instance.instant('core.grades.grade'), + this.feedback.gradefordisplay, + { + component: AddonModAssignProvider.COMPONENT, + componentId: this.moduleId, + }, + ); + } + } + + /** + * Submit for grading. + * + * @param acceptStatement Whether the statement has been accepted. + */ + async submitForGrading(acceptStatement: boolean): Promise { + if (this.assign!.requiresubmissionstatement && !acceptStatement) { + CoreDomUtils.instance.showErrorModal('addon.mod_assign.acceptsubmissionstatement', true); + + return; + } + + try { + // Ask for confirmation. @todo plugin precheck_submission + await CoreDomUtils.instance.showConfirm(Translate.instance.instant('addon.mod_assign.confirmsubmission')); + + const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true); + + try { + await AddonModAssign.instance.submitForGrading( + this.assign!.id, + this.courseId, + acceptStatement, + this.userSubmission!.timemodified, + this.hasOffline, + ); + + // Submitted, trigger event. + CoreEvents.trigger(AddonModAssignProvider.SUBMITTED_FOR_GRADING_EVENT, { + assignmentId: this.assign!.id, + submissionId: this.userSubmission!.id, + userId: this.currentUserId, + }, this.siteId); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.error', true); + } finally { + modal.dismiss(); + } + } catch { + // Cancelled. + } + } + + /** + * Submit a grade and feedback. + * + * @return Promise resolved when done. + */ + async submitGrade(): Promise { + // Check if there's something to be saved. + const modified = await this.hasDataToSave(true); + if (!modified) { + return; + } + + const attemptNumber = this.userSubmission ? this.userSubmission.attemptnumber : -1; + const outcomes: Record = {}; + // Scale "no grade" uses -1 instead of 0. + const grade = this.grade.scale && this.grade.grade == 0 + ? -1 + : CoreUtils.instance.unformatFloat(this.grade.grade, true); + + if (grade === false) { + // Grade is invalid. + throw new CoreError(Translate.instance.instant('core.grades.badgrade')); + } + + const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true); + + (this.gradeInfo?.outcomes || []).forEach((outcome) => { + if (outcome.itemNumber) { + outcomes[outcome.itemNumber] = outcome.selectedId!; + } + }); + + let pluginData: AddonModAssignSavePluginData = {}; + try { + if (this.feedback && this.feedback.plugins) { + pluginData = + await AddonModAssignHelper.instance.prepareFeedbackPluginData(this.assign!.id, this.submitId, this.feedback); + } + + try { + // We have all the data, now send it. + await AddonModAssign.instance.submitGradingForm( + this.assign!.id, + this.submitId, + this.courseId, + grade || 0, + attemptNumber, + this.grade.addAttempt, + this.grade.gradingStatus || '', + this.grade.applyToAll, + outcomes, + pluginData, + ); + + // Data sent, discard draft. + await this.discardDrafts(); + } finally { + // Invalidate and refresh data. + this.invalidateAndRefresh(true); + + CoreEvents.trigger(AddonModAssignProvider.GRADED_EVENT, { + assignmentId: this.assign!.id, + submissionId: this.submitId, + userId: this.currentUserId, + }, this.siteId); + } + } finally { + // Select submission view. + this.tabs.selectTab('submission'); + modal.dismiss(); + } + } + + /** + * Treat the grade info. + * + * @return Promise resolved when done. + */ + protected async treatGradeInfo(): Promise { + if (!this.gradeInfo) { + return; + } + + this.isGrading = true; + + // Make sure outcomes is an array. + this.gradeInfo.outcomes = this.gradeInfo.outcomes || []; + + // Check if grading method is simple or not. + if (this.gradeInfo.advancedgrading && this.gradeInfo.advancedgrading[0] && + typeof this.gradeInfo.advancedgrading[0].method != 'undefined') { + this.grade.method = this.gradeInfo.advancedgrading[0].method || 'simple'; + } else { + this.grade.method = 'simple'; + } + + this.canSaveGrades = this.grade.method == 'simple'; // Grades can be saved if simple grading. + + if (this.gradeInfo.scale) { + this.grade.scale = + CoreUtils.instance.makeMenuFromList(this.gradeInfo.scale, Translate.instance.instant('core.nograde')); + } else { + // Format the grade. + this.grade.grade = CoreUtils.instance.formatFloat(this.grade.grade); + this.originalGrades.grade = this.grade.grade; + + // Get current language to format grade input field. + this.grade.lang = await CoreLang.instance.getCurrentLanguage(); + } + + // Treat outcomes. + if (this.gradeInfo.outcomes && AddonModAssign.instance.isOutcomesEditEnabled()) { + this.gradeInfo.outcomes.forEach((outcome) => { + if (outcome.scale) { + outcome.options = + CoreUtils.instance.makeMenuFromList( + outcome.scale, + Translate.instance.instant('core.grades.nooutcome'), + ); + } + outcome.selectedId = 0; + this.originalGrades.outcomes[outcome.id] = outcome.selectedId; + }); + } + + // Get grade items. + const grades = + await CoreGradesHelper.instance.getGradeModuleItems(this.courseId, this.moduleId, this.submitId); + + const outcomes: AddonModAssignGradeOutcome[] = []; + + grades.forEach((grade: CoreGradesFormattedItem) => { + if (!grade.outcomeid && !grade.scaleid) { + + const gradeFormatted = grade.gradeformatted || ''; + // Not using outcomes or scale, get the numeric grade. + if (this.grade.scale) { + this.grade.gradebookGrade = CoreUtils.instance.formatFloat( + CoreGradesHelper.instance.getGradeValueFromLabel(this.grade.scale, gradeFormatted), + ); + } else { + const parsedGrade = parseFloat(gradeFormatted); + this.grade.gradebookGrade = parsedGrade || parsedGrade == 0 + ? CoreUtils.instance.formatFloat(parsedGrade) + : undefined; + } + + this.grade.disabled = !!grade.gradeislocked || !!grade.gradeisoverridden; + this.grade.modified = grade.gradedategraded; + } else if (grade.outcomeid) { + + // Only show outcomes with info on it, outcomeid could be null if outcomes are disabled on site. + this.gradeInfo!.outcomes && this.gradeInfo!.outcomes.forEach((outcome) => { + if (outcome.id == String(grade.outcomeid)) { + outcome.selected = grade.gradeformatted; + outcome.modified = grade.gradedategraded; + if (outcome.options) { + outcome.selectedId = + CoreGradesHelper.instance.getGradeValueFromLabel(outcome.options, outcome.selected || ''); + this.originalGrades.outcomes[outcome.id] = outcome.selectedId; + outcome.itemNumber = grade.itemnumber; + } + outcomes.push(outcome); + } + }); + this.gradeInfo!.disabled = grade.gradeislocked || grade.gradeisoverridden; + } + }); + + this.gradeInfo.outcomes = outcomes; + } + + /** + * Treat the last attempt. + * + * @param submissionStatus Response of get submission status. + * @param promises List where to add the promises. + */ + protected treatLastAttempt(submissionStatus: AddonModAssignGetSubmissionStatusWSResponse): Promise[] { + const promises: Promise[] =[]; + + if (!submissionStatus.lastattempt) { + return []; + } + + const submissionStatementMissing = !!this.assign!.requiresubmissionstatement && + typeof this.assign!.submissionstatement == 'undefined'; + + this.canSubmit = !this.isSubmittedForGrading && !this.submittedOffline && (submissionStatus.lastattempt.cansubmit || + (this.hasOffline && AddonModAssign.instance.canSubmitOffline(this.assign!, submissionStatus))); + + this.canEdit = !this.isSubmittedForGrading && submissionStatus.lastattempt.canedit && + (!this.submittedOffline || !this.assign!.submissiondrafts); + + // Get submission statement if needed. + if (this.assign!.requiresubmissionstatement && this.assign!.submissiondrafts && this.submitId == this.currentUserId) { + this.submissionStatement = this.assign!.submissionstatement; + this.acceptStatement = false; + } else { + this.submissionStatement = undefined; + this.acceptStatement = true; // No submission statement, so it's accepted. + } + + // Show error if submission statement should be shown but it couldn't be retrieved. + this.showErrorStatementEdit = submissionStatementMissing && !this.assign!.submissiondrafts && + this.submitId == this.currentUserId; + + this.showErrorStatementSubmit = submissionStatementMissing && !!this.assign!.submissiondrafts; + + this.userSubmission = AddonModAssign.instance.getSubmissionObjectFromAttempt(this.assign!, submissionStatus.lastattempt); + + if (this.assign!.attemptreopenmethod != this.attemptReopenMethodNone && this.userSubmission) { + this.currentAttempt = this.userSubmission.attemptnumber + 1; + } + + this.setStatusNameAndClass(submissionStatus); + + if (this.assign!.teamsubmission) { + if (submissionStatus.lastattempt.submissiongroup) { + // Get the name of the group. + promises.push(CoreGroups.instance.getActivityAllowedGroups(this.assign!.cmid).then((result) => { + const group = result.groups.find((group) => group.id == submissionStatus.lastattempt!.submissiongroup); + if (group) { + this.lastAttempt!.submissiongroupname = group.name; + } + + return; + })); + } + + // Get the members that need to submit. + if (this.userSubmission && + this.userSubmission.status != this.statusNew && + submissionStatus.lastattempt.submissiongroupmemberswhoneedtosubmit + ) { + submissionStatus.lastattempt.submissiongroupmemberswhoneedtosubmit.forEach((member) => { + if (this.blindMarking) { + // Users not blinded! (Moodle < 3.1.1, 3.2). + promises.push(AddonModAssign.instance.getAssignmentUserMappings(this.assign!.id, member, { + cmId: this.moduleId, + }).then((blindId) => { + this.membersToSubmitBlind.push(blindId); + + return; + })); + } else { + promises.push(CoreUser.instance.getProfile(member, this.courseId).then((profile) => { + this.membersToSubmit.push(profile); + + return; + })); + } + }); + } + } + + // Get grading text and color. + this.gradingStatusTranslationId = AddonModAssign.instance.getSubmissionGradingStatusTranslationId( + submissionStatus.lastattempt.gradingstatus, + ); + this.gradingColor = AddonModAssign.instance.getSubmissionGradingStatusColor(submissionStatus.lastattempt.gradingstatus); + + // Get the submission plugins. + if (this.userSubmission) { + if (!this.assign!.teamsubmission || + !submissionStatus.lastattempt.submissiongroup || + !this.assign!.preventsubmissionnotingroup + ) { + if (this.previousAttempt && this.previousAttempt.submission!.plugins && + this.userSubmission.status == this.statusReopened) { + // Get latest attempt if avalaible. + this.submissionPlugins = this.previousAttempt.submission!.plugins; + } else { + this.submissionPlugins = this.userSubmission.plugins!; + } + } + } + + return promises; + } + + /** + * Block or unblock the automatic sync of the user grade. + * + * @param block Whether to block or unblock. + */ + protected setGradeSyncBlocked(block = false): void { + if (this.isDestroyed || !this.assign || !this.isGrading) { + return; + } + + const syncId = AddonModAssignSync.instance.getGradeSyncId(this.assign!.id, this.submitId); + + if (block) { + CoreSync.instance.blockOperation(AddonModAssignProvider.COMPONENT, syncId); + } else { + CoreSync.instance.unblockOperation(AddonModAssignProvider.COMPONENT, syncId); + } + } + + /** + * A certain tab has been selected, either manually or automatically. + * + * @param tab The tab that was selected. + */ + tabSelected(tab: CoreTabComponent): void { + // Block sync when selecting grade tab, unblock when leaving it. + this.setGradeSyncBlocked(tab.id === 'grade'); + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + this.setGradeSyncBlocked(false); + this.isDestroyed = true; + this.syncObserver?.off(); + } + +} + +/** + * Submission attempt with some calculated data. + */ +type AddonModAssignSubmissionAttemptFormatted = AddonModAssignSubmissionAttempt & { + submissiongroupname?: string; // Calculated in the app. Group name the attempt belongs to. +}; + +/** + * Feedback of an assign submission with some calculated data. + */ +type AddonModAssignSubmissionFeedbackFormatted = AddonModAssignSubmissionFeedback & { + advancedgrade?: boolean; // Calculated in the app. Whether it uses advanced grading. +}; + + +type AddonModAssignSubmissionGrade = { + method: string; + grade?: number | string; + gradebookGrade?: string; + modified?: number; + gradingStatus?: string; + addAttempt: boolean; + applyToAll: boolean; + scale?: CoreMenuItem[]; + lang: string; + disabled: boolean; +}; + +type AddonModAssignSubmissionOriginalGrades = { + grade?: number | string; + addAttempt: boolean; + applyToAll: boolean; + outcomes: Record; +}; + +type AddonModAssignGradeInfo = Omit & { + outcomes?: AddonModAssignGradeOutcome[]; + disabled?: boolean; +}; + +type AddonModAssignGradeOutcome = CoreCourseModuleGradeOutcome & { + selectedId?: number; + selected?: string; + modified?: number; + options?: CoreMenuItem[]; + itemNumber?: number; +}; diff --git a/src/addons/mod/assign/services/assign-helper.ts b/src/addons/mod/assign/services/assign-helper.ts index 08cc4c45f..fa84c6885 100644 --- a/src/addons/mod/assign/services/assign-helper.ts +++ b/src/addons/mod/assign/services/assign-helper.ts @@ -24,6 +24,8 @@ import { AddonModAssignParticipant, AddonModAssignSubmissionFeedback, AddonModAssign, + AddonModAssignPlugin, + AddonModAssignSavePluginData, } from './assign'; import { AddonModAssignOffline } from './assign-offline'; import { CoreUtils } from '@services/utils/utils'; @@ -98,7 +100,7 @@ export class AddonModAssignHelperProvider { * @return Promise resolved when done. */ async copyPreviousAttempt(assign: AddonModAssignAssign, previousSubmission: AddonModAssignSubmission): Promise { - const pluginData: any = {}; + const pluginData: AddonModAssignSavePluginData = {}; const promises = previousSubmission.plugins ? previousSubmission.plugins.map((plugin) => AddonModAssignSubmissionDelegate.instance.copyPluginSubmissionData(assign, plugin, pluginData)) @@ -121,8 +123,8 @@ export class AddonModAssignHelperProvider { createEmptyFeedback(): AddonModAssignSubmissionFeedback { return { grade: undefined, - gradefordisplay: undefined, - gradeddate: undefined, + gradefordisplay: '', + gradeddate: 0, }; } @@ -133,13 +135,13 @@ export class AddonModAssignHelperProvider { */ createEmptySubmission(): AddonModAssignSubmissionFormatted { return { - id: undefined, - userid: undefined, - attemptnumber: undefined, - timecreated: undefined, - timemodified: undefined, - status: undefined, - groupid: undefined, + id: 0, + userid: 0, + attemptnumber: 0, + timecreated: 0, + timemodified: 0, + status: '', + groupid: 0, }; } @@ -283,14 +285,15 @@ export class AddonModAssignHelperProvider { * @param subtype Subtype name (assignsubmission or assignfeedback) * @return List of enabled plugins for the assign. */ - getPluginsEnabled(assign: AddonModAssignAssign, subtype: string): AddonModAssignPluginsEnabled { - const enabled: AddonModAssignPluginsEnabled = []; + getPluginsEnabled(assign: AddonModAssignAssign, subtype: string): AddonModAssignPlugin[] { + const enabled: AddonModAssignPlugin[] = []; assign.configs.forEach((config) => { if (config.subtype == subtype && config.name == 'enabled' && parseInt(config.value, 10) === 1) { // Format the plugin objects. enabled.push({ type: config.plugin, + name: config.plugin, }); } }); @@ -564,7 +567,7 @@ export class AddonModAssignHelperProvider { userId: number, feedback: AddonModAssignSubmissionFeedback, siteId?: string, - ): Promise { + ): Promise { const pluginData = {}; const promises = feedback.plugins @@ -673,20 +676,22 @@ export class AddonModAssignHelperProvider { * @param siteId Site ID. If not defined, current site. * @return Promise resolved when done. */ - uploadOrStoreFiles( + async uploadOrStoreFiles( assignId: number, folderName: string, files: (CoreWSExternalFile | FileEntry)[], offline = false, userId?: number, siteId?: string, - ): Promise { + ): Promise { if (offline) { - return this.storeSubmissionFiles(assignId, folderName, files, userId, siteId); + await this.storeSubmissionFiles(assignId, folderName, files, userId, siteId); + + return; } - return this.uploadFiles(assignId, files, siteId); + await this.uploadFiles(assignId, files, siteId); } } @@ -697,14 +702,8 @@ export const AddonModAssignHelper = makeSingleton(AddonModAssignHelperProvider); * Assign submission with some calculated data. */ export type AddonModAssignSubmissionFormatted = - Omit & { - id?: number; // Submission id. + Omit & { userid?: number; // Student id. - attemptnumber?: number; // Attempt number. - timecreated?: number; // Submission creation time. - timemodified?: number; // Submission last modified time. - status?: string; // Submission status. - groupid?: number; // Group id. blindid?: number; // Calculated in the app. Blindid of the user that did the submission. submitid?: number; // Calculated in the app. Userid or blindid of the user that did the submission. userfullname?: string; // Calculated in the app. Full name of the user that did the submission. @@ -715,13 +714,6 @@ export type AddonModAssignSubmissionFormatted = }; /** - * Assingment subplugins type enabled. - */ -export type AddonModAssignPluginsEnabled = { - type: string; // Plugin type. -}[]; - -/** - * Assingment plugin config. + * Assignment plugin config. */ export type AddonModAssignPluginConfig = {[name: string]: string}; diff --git a/src/addons/mod/assign/services/assign-offline.ts b/src/addons/mod/assign/services/assign-offline.ts index 78da7092e..81a56a300 100644 --- a/src/addons/mod/assign/services/assign-offline.ts +++ b/src/addons/mod/assign/services/assign-offline.ts @@ -114,7 +114,7 @@ export class AddonModAssignOfflineProvider { * @return Promise resolved with submissions. */ async getAssignSubmissions(assignId: number, siteId?: string): Promise { - return this.getAssignSubmissionsFormatted({ assingid: assignId }, siteId); + return this.getAssignSubmissionsFormatted({ assignid: assignId }, siteId); } /** @@ -167,7 +167,7 @@ export class AddonModAssignOfflineProvider { assignId: number, siteId?: string, ): Promise { - return this.getAssignSubmissionsGradeFormatted({ assingid: assignId }, siteId); + return this.getAssignSubmissionsGradeFormatted({ assignid: assignId }, siteId); } /** diff --git a/src/addons/mod/assign/services/assign-sync.ts b/src/addons/mod/assign/services/assign-sync.ts index 5335cd234..cf78c7603 100644 --- a/src/addons/mod/assign/services/assign-sync.ts +++ b/src/addons/mod/assign/services/assign-sync.ts @@ -453,7 +453,7 @@ export class AddonModAssignSyncProvider extends CoreCourseActivitySyncBaseProvid const status = await AddonModAssign.instance.getSubmissionStatus(assign.id, options); - const timemodified = (status.feedback && (status.feedback.gradeddate || status.feedback.grade.timemodified)) || 0; + const timemodified = (status.feedback && (status.feedback.gradeddate || status.feedback.grade?.timemodified)) || 0; if (timemodified > offlineData.timemodified) { // The submission grade was modified in Moodle, discard it. @@ -480,7 +480,7 @@ export class AddonModAssignSyncProvider extends CoreCourseActivitySyncBaseProvid if (gradeInfo && gradeInfo.scale) { offlineData.grade = this.getSelectedScaleId(gradeInfo.scale, grade.grade || ''); } else { - offlineData.grade = parseFloat(grade.grade || '') || undefined; + offlineData.grade = parseFloat(grade.grade || ''); } } else if (gradeInfo && grade.outcomeid && AddonModAssign.instance.isOutcomesEditEnabled() && gradeInfo.outcomes) { gradeInfo.outcomes.forEach((outcome, index) => { diff --git a/src/addons/mod/assign/services/assign.ts b/src/addons/mod/assign/services/assign.ts index 8ac694051..2e793647f 100644 --- a/src/addons/mod/assign/services/assign.ts +++ b/src/addons/mod/assign/services/assign.ts @@ -1326,7 +1326,7 @@ export class AddonModAssignProvider { async submitGradingFormOnline( assignId: number, userId: number, - grade: number | undefined, + grade: number, attemptNumber: number, addAttempt: boolean, workflowState: string, @@ -1553,7 +1553,7 @@ export type AddonModAssignSubmissionPreviousAttempt = { * Feedback of an assign submission. */ export type AddonModAssignSubmissionFeedback = { - grade: AddonModAssignGrade; // Grade information. + grade?: AddonModAssignGrade; // Grade information. gradefordisplay: string; // Grade rendered into a format suitable for display. gradeddate: number; // The date the user was graded. plugins?: AddonModAssignPlugin[]; // Plugins info. @@ -1853,3 +1853,12 @@ type AddonModAssignSaveGradeWSParams = { * Assignment grade outcomes. */ export type AddonModAssignOutcomes = { [itemNumber: number]: number }; + +/** + * Data sent by SUBMITTED_FOR_GRADING_EVENT event. + */ +export type AddonModAssignSubmittedForGradingEventData = { + assignmentId: number; + submissionId: number; + userId: number; +}; diff --git a/src/addons/mod/assign/services/database/assign.ts b/src/addons/mod/assign/services/database/assign.ts index 703f3f27a..949a1e580 100644 --- a/src/addons/mod/assign/services/database/assign.ts +++ b/src/addons/mod/assign/services/database/assign.ts @@ -139,7 +139,7 @@ export type AddonModAssignSubmissionsGradingDBRecord = { assignid: number; // Primary key. userid: number; // Primary key. courseid: number; - grade?: number; // Real. + grade: number; // Real. attemptnumber: number; addattempt: number; workflowstate: string; diff --git a/src/addons/mod/assign/services/feedback-delegate.ts b/src/addons/mod/assign/services/feedback-delegate.ts index b21ca5f35..978739474 100644 --- a/src/addons/mod/assign/services/feedback-delegate.ts +++ b/src/addons/mod/assign/services/feedback-delegate.ts @@ -12,10 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Injectable } from '@angular/core'; +import { Injectable, Type } from '@angular/core'; import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate'; import { AddonModAssignDefaultFeedbackHandler } from './handlers/default-feedback'; -import { AddonModAssignAssign, AddonModAssignSubmission, AddonModAssignPlugin } from './assign'; +import { AddonModAssignAssign, AddonModAssignSubmission, AddonModAssignPlugin, AddonModAssignSavePluginData } from './assign'; import { makeSingleton } from '@singletons'; import { CoreWSExternalFile } from '@services/ws'; @@ -37,7 +37,7 @@ export interface AddonModAssignFeedbackHandler extends CoreDelegateHandler { * @param siteId Site ID. If not defined, current site. * @return If the function is async, it should return a Promise resolved when done. */ - discardDraft?(assignId: number, userId: number, siteId?: string): void | Promise; + discardDraft?(assignId: number, userId: number, siteId?: string): void | Promise; /** * Return the Component to use to display the plugin data. @@ -46,7 +46,8 @@ export interface AddonModAssignFeedbackHandler extends CoreDelegateHandler { * @param plugin The plugin object. * @return The component (or promise resolved with component) to use, undefined if not found. */ - getComponent?(plugin: AddonModAssignPlugin): any | Promise; + getComponent?(plugin: AddonModAssignPlugin): Type | undefined | Promise | undefined>; + /** * Return the draft saved data of the feedback plugin. @@ -126,7 +127,7 @@ export interface AddonModAssignFeedbackHandler extends CoreDelegateHandler { submission: AddonModAssignSubmission, plugin: AddonModAssignPlugin, siteId?: string, - ): Promise; + ): Promise; /** * Prepare and add to pluginData the data to send to the server based on the draft data saved. @@ -142,9 +143,9 @@ export interface AddonModAssignFeedbackHandler extends CoreDelegateHandler { assignId: number, userId: number, plugin: AddonModAssignPlugin, - pluginData: any, + pluginData: AddonModAssignSavePluginData, siteId?: string, - ): void | Promise; + ): void | Promise; /** * Save draft data of the feedback plugin. @@ -197,7 +198,7 @@ export class AddonModAssignFeedbackDelegateService extends CoreDelegate { + async getComponentForPlugin(plugin: AddonModAssignPlugin): Promise | undefined> { return await this.executeFunctionOnEnabled(plugin.type, 'getComponent', [plugin]); } @@ -317,7 +318,7 @@ export class AddonModAssignFeedbackDelegateService extends CoreDelegate { + ): Promise { return await this.executeFunctionOnEnabled(plugin.type, 'prefetch', [assign, submission, plugin, siteId]); } @@ -335,9 +336,9 @@ export class AddonModAssignFeedbackDelegateService extends CoreDelegate { + ): Promise { return await this.executeFunctionOnEnabled( plugin.type, diff --git a/src/addons/mod/assign/services/handlers/default-feedback.ts b/src/addons/mod/assign/services/handlers/default-feedback.ts index 5bfc76bd6..02451fd2c 100644 --- a/src/addons/mod/assign/services/handlers/default-feedback.ts +++ b/src/addons/mod/assign/services/handlers/default-feedback.ts @@ -13,6 +13,7 @@ // limitations under the License. import { Injectable } from '@angular/core'; +import { CoreWSExternalFile } from '@services/ws'; import { Translate } from '@singletons'; import { AddonModAssignPlugin } from '../assign'; import { AddonModAssignFeedbackHandler } from '../feedback-delegate'; @@ -35,16 +36,6 @@ export class AddonModAssignDefaultFeedbackHandler implements AddonModAssignFeedb // Nothing to do. } - /** - * Return the Component to use to display the plugin data. - * It's recommended to return the class of the component, but you can also return an instance of the component. - * - * @return The component (or promise resolved with component) to use, undefined if not found. - */ - getComponent(): void { - // Nothing to do. - } - /** * Return the draft saved data of the feedback plugin. * @@ -60,7 +51,7 @@ export class AddonModAssignDefaultFeedbackHandler implements AddonModAssignFeedb * * @return The files (or promise resolved with the files). */ - getPluginFiles(): any[] { + getPluginFiles(): CoreWSExternalFile[] { return []; } @@ -121,7 +112,7 @@ export class AddonModAssignDefaultFeedbackHandler implements AddonModAssignFeedb * * @return Promise resolved when done. */ - async prefetch(): Promise { + async prefetch(): Promise { return; } diff --git a/src/addons/mod/assign/services/handlers/default-submission.ts b/src/addons/mod/assign/services/handlers/default-submission.ts index a83032c1c..55e838efc 100644 --- a/src/addons/mod/assign/services/handlers/default-submission.ts +++ b/src/addons/mod/assign/services/handlers/default-submission.ts @@ -13,6 +13,7 @@ // limitations under the License. import { Injectable } from '@angular/core'; +import { CoreWSExternalFile } from '@services/ws'; import { Translate } from '@singletons'; import { AddonModAssignPlugin } from '../assign'; import { AddonModAssignSubmissionHandler } from '../submission-delegate'; @@ -72,23 +73,13 @@ export class AddonModAssignDefaultSubmissionHandler implements AddonModAssignSub // Nothing to do. } - /** - * Return the Component to use to display the plugin data, either in read or in edit mode. - * It's recommended to return the class of the component, but you can also return an instance of the component. - * - * @return The component (or promise resolved with component) to use, undefined if not found. - */ - getComponent(): void { - // Nothing to do. - } - /** * Get files used by this plugin. * The files returned by this function will be prefetched when the user prefetches the assign. * * @return The files (or promise resolved with the files). */ - getPluginFiles(): any[] { + getPluginFiles(): CoreWSExternalFile[] { return []; } @@ -176,7 +167,7 @@ export class AddonModAssignDefaultSubmissionHandler implements AddonModAssignSub * * @return Promise resolved when done. */ - async prefetch(): Promise { + async prefetch(): Promise { return; } diff --git a/src/addons/mod/assign/services/submission-delegate.ts b/src/addons/mod/assign/services/submission-delegate.ts index 81394a312..745ce1fff 100644 --- a/src/addons/mod/assign/services/submission-delegate.ts +++ b/src/addons/mod/assign/services/submission-delegate.ts @@ -12,10 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Injectable } from '@angular/core'; +import { Injectable, Type } from '@angular/core'; import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate'; import { AddonModAssignDefaultSubmissionHandler } from './handlers/default-submission'; -import { AddonModAssignAssign, AddonModAssignSubmission, AddonModAssignPlugin } from './assign'; +import { AddonModAssignAssign, AddonModAssignSubmission, AddonModAssignPlugin, AddonModAssignSavePluginData } from './assign'; import { makeSingleton } from '@singletons'; import { CoreWSExternalFile } from '@services/ws'; @@ -86,7 +86,7 @@ export interface AddonModAssignSubmissionHandler extends CoreDelegateHandler { copySubmissionData?( assign: AddonModAssignAssign, plugin: AddonModAssignPlugin, - pluginData: any, + pluginData: AddonModAssignSavePluginData, userId?: number, siteId?: string, ): void | Promise; @@ -120,7 +120,7 @@ export interface AddonModAssignSubmissionHandler extends CoreDelegateHandler { getComponent?( plugin: AddonModAssignPlugin, edit?: boolean, - ): any | Promise; + ): Type | undefined | Promise | undefined>; /** * Get files used by this plugin. @@ -233,7 +233,7 @@ export interface AddonModAssignSubmissionHandler extends CoreDelegateHandler { submission: AddonModAssignSubmission, plugin: AddonModAssignPlugin, inputData: any, - pluginData: any, + pluginData: AddonModAssignSavePluginData, offline?: boolean, userId?: number, siteId?: string, @@ -321,7 +321,7 @@ export class AddonModAssignSubmissionDelegateService extends CoreDelegate { @@ -363,7 +363,7 @@ export class AddonModAssignSubmissionDelegateService extends CoreDelegate { + async getComponentForPlugin(plugin: AddonModAssignPlugin, edit?: boolean): Promise | undefined> { return await this.executeFunctionOnEnabled(plugin.type, 'getComponent', [plugin, edit]); } From 85f79bb9440a0491b201f21d7243ad0f41fb8434 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Fri, 12 Feb 2021 16:43:59 +0100 Subject: [PATCH 06/17] MOBILE-3636 assign: Submission plugins --- src/addons/mod/assign/assign.module.ts | 2 + .../submission-plugin/submission-plugin.ts | 3 +- .../components/submission/submission.ts | 2 +- .../mod/assign/services/assign-helper.ts | 10 +- src/addons/mod/assign/services/assign.ts | 3 +- .../mod/assign/services/feedback-delegate.ts | 3 +- .../assign/services/submission-delegate.ts | 45 +- .../submission/comments/comments.module.ts | 48 +++ .../addon-mod-assign-submission-comments.html | 8 + .../submission/comments/component/comments.ts | 61 +++ .../mod/assign/submission/comments/lang.json | 3 + .../submission/comments/services/handler.ts | 107 +++++ .../addon-mod-assign-submission-file.html | 19 + .../assign/submission/file/component/file.ts | 85 ++++ .../mod/assign/submission/file/file.module.ts | 46 +++ .../mod/assign/submission/file/lang.json | 3 + .../submission/file/services/handler.ts | 388 ++++++++++++++++++ ...ddon-mod-assign-submission-onlinetext.html | 35 ++ .../onlinetext/component/onlinetext.ts | 130 ++++++ .../assign/submission/onlinetext/lang.json | 4 + .../onlinetext/onlinetext.module.ts | 48 +++ .../submission/onlinetext/services/handler.ts | 323 +++++++++++++++ .../assign/submission/submission.module.ts | 27 ++ 23 files changed, 1371 insertions(+), 32 deletions(-) create mode 100644 src/addons/mod/assign/submission/comments/comments.module.ts create mode 100644 src/addons/mod/assign/submission/comments/component/addon-mod-assign-submission-comments.html create mode 100644 src/addons/mod/assign/submission/comments/component/comments.ts create mode 100644 src/addons/mod/assign/submission/comments/lang.json create mode 100644 src/addons/mod/assign/submission/comments/services/handler.ts create mode 100644 src/addons/mod/assign/submission/file/component/addon-mod-assign-submission-file.html create mode 100644 src/addons/mod/assign/submission/file/component/file.ts create mode 100644 src/addons/mod/assign/submission/file/file.module.ts create mode 100644 src/addons/mod/assign/submission/file/lang.json create mode 100644 src/addons/mod/assign/submission/file/services/handler.ts create mode 100644 src/addons/mod/assign/submission/onlinetext/component/addon-mod-assign-submission-onlinetext.html create mode 100644 src/addons/mod/assign/submission/onlinetext/component/onlinetext.ts create mode 100644 src/addons/mod/assign/submission/onlinetext/lang.json create mode 100644 src/addons/mod/assign/submission/onlinetext/onlinetext.module.ts create mode 100644 src/addons/mod/assign/submission/onlinetext/services/handler.ts create mode 100644 src/addons/mod/assign/submission/submission.module.ts diff --git a/src/addons/mod/assign/assign.module.ts b/src/addons/mod/assign/assign.module.ts index 1a7565342..fbf319570 100644 --- a/src/addons/mod/assign/assign.module.ts +++ b/src/addons/mod/assign/assign.module.ts @@ -29,6 +29,7 @@ import { AddonModAssignModuleHandler, AddonModAssignModuleHandlerService } from import { AddonModAssignPrefetchHandler } from './services/handlers/prefetch'; import { AddonModAssignPushClickHandler } from './services/handlers/push-click'; import { AddonModAssignSyncCronHandler } from './services/handlers/sync-cron'; +import { AddonModAssignSubmissionModule } from './submission/submission.module'; const routes: Routes = [ { @@ -41,6 +42,7 @@ const routes: Routes = [ imports: [ CoreMainMenuTabRoutingModule.forChild(routes), AddonModAssignComponentsModule, + AddonModAssignSubmissionModule, ], providers: [ { diff --git a/src/addons/mod/assign/components/submission-plugin/submission-plugin.ts b/src/addons/mod/assign/components/submission-plugin/submission-plugin.ts index 5507f6273..fcbb25bec 100644 --- a/src/addons/mod/assign/components/submission-plugin/submission-plugin.ts +++ b/src/addons/mod/assign/components/submission-plugin/submission-plugin.ts @@ -24,6 +24,7 @@ import { } from '../../services/assign'; import { AddonModAssignHelper, AddonModAssignPluginConfig } from '../../services/assign-helper'; import { AddonModAssignSubmissionDelegate } from '../../services/submission-delegate'; +import { FileEntry } from '@ionic-native/file/ngx'; /** * Component that displays an assignment submission plugin. @@ -48,7 +49,7 @@ export class AddonModAssignSubmissionPluginComponent implements OnInit { // Data to render the plugin if it isn't supported. component = AddonModAssignProvider.COMPONENT; text = ''; - files: CoreWSExternalFile[] = []; + files: (FileEntry | CoreWSExternalFile)[] = []; notSupported = false; pluginLoaded = false; diff --git a/src/addons/mod/assign/components/submission/submission.ts b/src/addons/mod/assign/components/submission/submission.ts index e4e2114e5..0015d71e8 100644 --- a/src/addons/mod/assign/components/submission/submission.ts +++ b/src/addons/mod/assign/components/submission/submission.ts @@ -392,7 +392,7 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy { try { return AddonModAssignHelper.instance.hasFeedbackDataChanged( this.assign!, - this.userSubmission, + this.userSubmission!, // @todo this.feedback, this.submitId, ); diff --git a/src/addons/mod/assign/services/assign-helper.ts b/src/addons/mod/assign/services/assign-helper.ts index fa84c6885..6db331096 100644 --- a/src/addons/mod/assign/services/assign-helper.ts +++ b/src/addons/mod/assign/services/assign-helper.ts @@ -492,7 +492,7 @@ export class AddonModAssignHelperProvider { */ async hasFeedbackDataChanged( assign: AddonModAssignAssign, - submission: AddonModAssignSubmission, + submission: AddonModAssignSubmission | AddonModAssignSubmissionFormatted, feedback: AddonModAssignSubmissionFeedback, userId: number, ): Promise { @@ -683,15 +683,13 @@ export class AddonModAssignHelperProvider { offline = false, userId?: number, siteId?: string, - ): Promise { + ): Promise { if (offline) { - await this.storeSubmissionFiles(assignId, folderName, files, userId, siteId); - - return; + return await this.storeSubmissionFiles(assignId, folderName, files, userId, siteId); } - await this.uploadFiles(assignId, files, siteId); + return await this.uploadFiles(assignId, files, siteId); } } diff --git a/src/addons/mod/assign/services/assign.ts b/src/addons/mod/assign/services/assign.ts index 2e793647f..1c8973075 100644 --- a/src/addons/mod/assign/services/assign.ts +++ b/src/addons/mod/assign/services/assign.ts @@ -29,6 +29,7 @@ import { CoreApp } from '@services/app'; import { CoreUtils } from '@services/utils/utils'; import { AddonModAssignOffline } from './assign-offline'; import { AddonModAssignSubmissionDelegate } from './submission-delegate'; +import { CoreComments } from '@features/comments/services/comments'; const ROOT_CACHE_KEY = 'mmaModAssign:'; @@ -754,7 +755,7 @@ export class AddonModAssignProvider { promises.push(this.invalidateAssignmentUserMappingsData(assign.id, siteId)); promises.push(this.invalidateAssignmentGradesData(assign.id, siteId)); promises.push(this.invalidateListParticipantsData(assign.id, siteId)); - // @todo promises.push(CoreComments.instance.invalidateCommentsByInstance('module', assign.id, siteId)); + promises.push(CoreComments.instance.invalidateCommentsByInstance('module', assign.id, siteId)); promises.push(this.invalidateAssignmentData(courseId, siteId)); promises.push(CoreGrades.instance.invalidateAllCourseGradesData(courseId)); diff --git a/src/addons/mod/assign/services/feedback-delegate.ts b/src/addons/mod/assign/services/feedback-delegate.ts index 978739474..8f78851ff 100644 --- a/src/addons/mod/assign/services/feedback-delegate.ts +++ b/src/addons/mod/assign/services/feedback-delegate.ts @@ -18,6 +18,7 @@ import { AddonModAssignDefaultFeedbackHandler } from './handlers/default-feedbac import { AddonModAssignAssign, AddonModAssignSubmission, AddonModAssignPlugin, AddonModAssignSavePluginData } from './assign'; import { makeSingleton } from '@singletons'; import { CoreWSExternalFile } from '@services/ws'; +import { AddonModAssignSubmissionFormatted } from './assign-helper'; /** * Interface that all feedback handlers must implement. @@ -264,7 +265,7 @@ export class AddonModAssignFeedbackDelegateService extends CoreDelegate, ): void; /** @@ -105,9 +106,9 @@ export interface AddonModAssignSubmissionHandler extends CoreDelegateHandler { assign: AddonModAssignAssign, submission: AddonModAssignSubmission, plugin: AddonModAssignPlugin, - offlineData: any, + offlineData: AddonModAssignSubmissionsDBRecordFormatted, siteId?: string, - ): void | Promise; + ): void | Promise; /** * Return the Component to use to display the plugin data, either in read or in edit mode. @@ -172,7 +173,7 @@ export interface AddonModAssignSubmissionHandler extends CoreDelegateHandler { assign: AddonModAssignAssign, submission: AddonModAssignSubmission, plugin: AddonModAssignPlugin, - inputData: any, + inputData: Record, ): number | Promise; /** @@ -188,7 +189,7 @@ export interface AddonModAssignSubmissionHandler extends CoreDelegateHandler { assign: AddonModAssignAssign, submission: AddonModAssignSubmission, plugin: AddonModAssignPlugin, - inputData: any, + inputData: Record, ): boolean | Promise; /** @@ -232,12 +233,12 @@ export interface AddonModAssignSubmissionHandler extends CoreDelegateHandler { assign: AddonModAssignAssign, submission: AddonModAssignSubmission, plugin: AddonModAssignPlugin, - inputData: any, + inputData: Record, pluginData: AddonModAssignSavePluginData, offline?: boolean, userId?: number, siteId?: string, - ): void | Promise; + ): void | Promise; /** * Prepare and add to pluginData the data to send to the server based on the offline data stored. @@ -255,10 +256,10 @@ export interface AddonModAssignSubmissionHandler extends CoreDelegateHandler { assign: AddonModAssignAssign, submission: AddonModAssignSubmission, plugin: AddonModAssignPlugin, - offlineData: any, - pluginData: any, + offlineData: AddonModAssignSubmissionsDBRecordFormatted, + pluginData: AddonModAssignSavePluginData, siteId?: string, - ): void | Promise; + ): void | Promise; } /** @@ -303,7 +304,7 @@ export class AddonModAssignSubmissionDelegateService extends CoreDelegate, ): void { return this.executeFunctionOnEnabled(plugin.type, 'clearTmpData', [assign, submission, plugin, inputData]); } @@ -346,9 +347,9 @@ export class AddonModAssignSubmissionDelegateService extends CoreDelegate { + ): Promise { return await this.executeFunctionOnEnabled( plugin.type, 'deleteOfflineData', @@ -423,7 +424,7 @@ export class AddonModAssignSubmissionDelegateService extends CoreDelegate, ): Promise { return await this.executeFunctionOnEnabled( plugin.type, @@ -445,7 +446,7 @@ export class AddonModAssignSubmissionDelegateService extends CoreDelegate, ): Promise { return await this.executeFunctionOnEnabled( plugin.type, @@ -520,12 +521,12 @@ export class AddonModAssignSubmissionDelegateService extends CoreDelegate, + pluginData: AddonModAssignSavePluginData, offline?: boolean, userId?: number, siteId?: string, - ): Promise { + ): Promise { return await this.executeFunctionOnEnabled( plugin.type, @@ -549,10 +550,10 @@ export class AddonModAssignSubmissionDelegateService extends CoreDelegate { + ): Promise { return this.executeFunctionOnEnabled( plugin.type, @@ -562,4 +563,4 @@ export class AddonModAssignSubmissionDelegateService extends CoreDelegate () => { + AddonModAssignSubmissionDelegate.instance.registerHandler(AddonModAssignSubmissionCommentsHandler.instance); + }, + }, + ], + exports: [ + AddonModAssignSubmissionCommentsComponent, + ], + entryComponents: [ + AddonModAssignSubmissionCommentsComponent, + ], +}) +export class AddonModAssignSubmissionCommentsModule {} diff --git a/src/addons/mod/assign/submission/comments/component/addon-mod-assign-submission-comments.html b/src/addons/mod/assign/submission/comments/component/addon-mod-assign-submission-comments.html new file mode 100644 index 000000000..d23d23601 --- /dev/null +++ b/src/addons/mod/assign/submission/comments/component/addon-mod-assign-submission-comments.html @@ -0,0 +1,8 @@ + + +

{{plugin.name}}

+ + +
+
diff --git a/src/addons/mod/assign/submission/comments/component/comments.ts b/src/addons/mod/assign/submission/comments/component/comments.ts new file mode 100644 index 000000000..38bf30cc0 --- /dev/null +++ b/src/addons/mod/assign/submission/comments/component/comments.ts @@ -0,0 +1,61 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, ViewChild } from '@angular/core'; +import { AddonModAssignSubmissionPluginComponent } from '@addons/mod/assign/components/submission-plugin/submission-plugin'; +import { CoreCommentsCommentsComponent } from '@features/comments/components/comments/comments'; +import { CoreComments } from '@features/comments/services/comments'; + +/** + * Component to render a comments submission plugin. + */ +@Component({ + selector: 'addon-mod-assign-submission-comments', + templateUrl: 'addon-mod-assign-submission-comments.html', +}) +export class AddonModAssignSubmissionCommentsComponent extends AddonModAssignSubmissionPluginComponent { + + @ViewChild(CoreCommentsCommentsComponent) commentsComponent!: CoreCommentsCommentsComponent; + + commentsEnabled: boolean; + + constructor() { + super(); + + this.commentsEnabled = !CoreComments.instance.areCommentsDisabledInSite(); + } + + /** + * Invalidate the data. + * + * @return Promise resolved when done. + */ + invalidate(): Promise { + return CoreComments.instance.invalidateCommentsData( + 'module', + this.assign.cmid, + 'assignsubmission_comments', + this.submission.id, + 'submission_comments', + ); + } + + /** + * Show the comments. + */ + showComments(e?: Event): void { + this.commentsComponent?.openComments(e); + } + +} diff --git a/src/addons/mod/assign/submission/comments/lang.json b/src/addons/mod/assign/submission/comments/lang.json new file mode 100644 index 000000000..c69c732aa --- /dev/null +++ b/src/addons/mod/assign/submission/comments/lang.json @@ -0,0 +1,3 @@ +{ + "pluginname": "Submission comments" +} \ No newline at end of file diff --git a/src/addons/mod/assign/submission/comments/services/handler.ts b/src/addons/mod/assign/submission/comments/services/handler.ts new file mode 100644 index 000000000..b32bd290d --- /dev/null +++ b/src/addons/mod/assign/submission/comments/services/handler.ts @@ -0,0 +1,107 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { AddonModAssignAssign, AddonModAssignSubmission, AddonModAssignPlugin } from '@addons/mod/assign/services/assign'; +import { AddonModAssignSubmissionHandler } from '@addons/mod/assign/services/submission-delegate'; +import { Injectable, Type } from '@angular/core'; +import { CoreComments } from '@features/comments/services/comments'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton } from '@singletons'; +import { AddonModAssignSubmissionCommentsComponent } from '../component/comments'; + +/** + * Handler for comments submission plugin. + */ +@Injectable( { providedIn: 'root' }) +export class AddonModAssignSubmissionCommentsHandlerService implements AddonModAssignSubmissionHandler { + + name = 'AddonModAssignSubmissionCommentsHandler'; + type = 'comments'; + + + /** + * Whether the plugin can be edited in offline for existing submissions. In general, this should return false if the + * plugin uses Moodle filters. The reason is that the app only prefetches filtered data, and the user should edit + * unfiltered data. + * + * @return Boolean or promise resolved with boolean: whether it can be edited in offline. + */ + canEditOffline(): boolean { + // This plugin is read only, but return true to prevent blocking the edition. + return true; + } + + /** + * Return the Component to use to display the plugin data, either in read or in edit mode. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @param plugin The plugin object. + * @param edit Whether the user is editing. + * @return The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(plugin: AddonModAssignPlugin, edit = false): Type | undefined { + return edit ? undefined : AddonModAssignSubmissionCommentsComponent; + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return True or promise resolved with true if enabled. + */ + async isEnabled(): Promise { + return true; + } + + /** + * Whether or not the handler is enabled for edit on a site level. + * + * @return Whether or not the handler is enabled for edit on a site level. + */ + isEnabledForEdit(): boolean{ + return true; + } + + /** + * Prefetch any required data for the plugin. + * This should NOT prefetch files. Files to be prefetched should be returned by the getPluginFiles function. + * + * @param assign The assignment. + * @param submission The submission. + * @param plugin The plugin object. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async prefetch( + assign: AddonModAssignAssign, + submission: AddonModAssignSubmission, + plugin: AddonModAssignPlugin, + siteId?: string, + ): Promise { + + // Fail silently (Moodle < 3.1.1, 3.2) + await CoreUtils.instance.ignoreErrors( + CoreComments.instance.getComments( + 'module', + assign.cmid, + 'assignsubmission_comments', + submission.id, + 'submission_comments', + 0, + siteId, + ), + ); + } + +} +export const AddonModAssignSubmissionCommentsHandler = makeSingleton(AddonModAssignSubmissionCommentsHandlerService); diff --git a/src/addons/mod/assign/submission/file/component/addon-mod-assign-submission-file.html b/src/addons/mod/assign/submission/file/component/addon-mod-assign-submission-file.html new file mode 100644 index 000000000..92517dd92 --- /dev/null +++ b/src/addons/mod/assign/submission/file/component/addon-mod-assign-submission-file.html @@ -0,0 +1,19 @@ + + + +

{{ plugin.name }}

+
+ +
+
+
+ + +
+ +

{{ plugin.name }}

+
+ + +
diff --git a/src/addons/mod/assign/submission/file/component/file.ts b/src/addons/mod/assign/submission/file/component/file.ts new file mode 100644 index 000000000..2dff6e98a --- /dev/null +++ b/src/addons/mod/assign/submission/file/component/file.ts @@ -0,0 +1,85 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { AddonModAssignSubmissionPluginComponent } from '@addons/mod/assign/components/submission-plugin/submission-plugin'; +import { AddonModAssign, AddonModAssignProvider } from '@addons/mod/assign/services/assign'; +import { AddonModAssignHelper } from '@addons/mod/assign/services/assign-helper'; +import { AddonModAssignOffline } from '@addons/mod/assign/services/assign-offline'; +import { Component, OnInit } from '@angular/core'; +import { CoreFileUploaderStoreFilesResult } from '@features/fileuploader/services/fileuploader'; +import { CoreFileSession } from '@services/file-session'; +import { CoreUtils } from '@services/utils/utils'; +import { AddonModAssignSubmissionFileHandlerService } from '../services/handler'; +import { FileEntry } from '@ionic-native/file/ngx'; + +/** + * Component to render a file submission plugin. + */ +@Component({ + selector: 'addon-mod-assign-submission-file', + templateUrl: 'addon-mod-assign-submission-file.html', +}) +export class AddonModAssignSubmissionFileComponent extends AddonModAssignSubmissionPluginComponent implements OnInit { + + component = AddonModAssignProvider.COMPONENT; + + maxSize?: number; + acceptedTypes?: string; + maxSubmissions?: number; + + /** + * Component being initialized. + */ + async nOnInit(): Promise { + // Get the offline data. + const filesData = await CoreUtils.instance.ignoreErrors( + AddonModAssignOffline.instance.getSubmission(this.assign.id), + undefined, + ); + + this.acceptedTypes = this.data?.configs.filetypeslist; + this.maxSize = this.data?.configs.maxsubmissionsizebytes + ? parseInt(this.data?.configs.maxsubmissionsizebytes, 10) + : undefined; + this.maxSubmissions = this.data?.configs.maxfilesubmissions + ? parseInt(this.data?.configs.maxfilesubmissions, 10) + : undefined; + + try { + if (filesData && filesData.plugindata && filesData.plugindata.files_filemanager) { + const offlineDataFiles = filesData.plugindata.files_filemanager; + // It has offline data. + let offlineFiles: FileEntry[] = []; + if (offlineDataFiles.offline) { + offlineFiles = await CoreUtils.instance.ignoreErrors( + AddonModAssignHelper.instance.getStoredSubmissionFiles( + this.assign.id, + AddonModAssignSubmissionFileHandlerService.FOLDER_NAME, + ), + [], + ); + } + + this.files = offlineDataFiles.online || []; + this.files = this.files.concat(offlineFiles); + } else { + // No offline data, get the online files. + this.files = AddonModAssign.instance.getSubmissionPluginAttachments(this.plugin); + } + } finally { + CoreFileSession.instance.setFiles(this.component, this.assign.id, this.files); + } + } + +} diff --git a/src/addons/mod/assign/submission/file/file.module.ts b/src/addons/mod/assign/submission/file/file.module.ts new file mode 100644 index 000000000..cf97e85f7 --- /dev/null +++ b/src/addons/mod/assign/submission/file/file.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 { APP_INITIALIZER, NgModule } from '@angular/core'; +import { AddonModAssignSubmissionFileHandler } from './services/handler'; +import { AddonModAssignSubmissionFileComponent } from './component/file'; +import { CoreSharedModule } from '@/core/shared.module'; +import { AddonModAssignSubmissionDelegate } from '../../services/submission-delegate'; + +@NgModule({ + declarations: [ + AddonModAssignSubmissionFileComponent, + ], + imports: [ + CoreSharedModule, + ], + providers: [ + AddonModAssignSubmissionFileHandler, + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + AddonModAssignSubmissionDelegate.instance.registerHandler(AddonModAssignSubmissionFileHandler.instance); + }, + }, + ], + exports: [ + AddonModAssignSubmissionFileComponent, + ], + entryComponents: [ + AddonModAssignSubmissionFileComponent, + ], +}) +export class AddonModAssignSubmissionFileModule {} diff --git a/src/addons/mod/assign/submission/file/lang.json b/src/addons/mod/assign/submission/file/lang.json new file mode 100644 index 000000000..7262ba217 --- /dev/null +++ b/src/addons/mod/assign/submission/file/lang.json @@ -0,0 +1,3 @@ +{ + "pluginname": "File submissions" +} \ No newline at end of file diff --git a/src/addons/mod/assign/submission/file/services/handler.ts b/src/addons/mod/assign/submission/file/services/handler.ts new file mode 100644 index 000000000..344b8cc3a --- /dev/null +++ b/src/addons/mod/assign/submission/file/services/handler.ts @@ -0,0 +1,388 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + AddonModAssignAssign, + AddonModAssignSubmission, + AddonModAssignPlugin, + AddonModAssignProvider, + AddonModAssign, +} from '@addons/mod/assign/services/assign'; +import { AddonModAssignHelper } from '@addons/mod/assign/services/assign-helper'; +import { AddonModAssignOffline, AddonModAssignSubmissionsDBRecordFormatted } from '@addons/mod/assign/services/assign-offline'; +import { AddonModAssignSubmissionHandler } from '@addons/mod/assign/services/submission-delegate'; +import { Injectable, Type } from '@angular/core'; +import { CoreFileUploader, CoreFileUploaderStoreFilesResult } from '@features/fileuploader/services/fileuploader'; +import { CoreFileHelper } from '@services/file-helper'; +import { CoreFileSession } from '@services/file-session'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreWSExternalFile } from '@services/ws'; +import { makeSingleton } from '@singletons'; +import { AddonModAssignSubmissionFileComponent } from '../component/file'; +import { FileEntry } from '@ionic-native/file/ngx'; + +/** + * Handler for file submission plugin. + */ +@Injectable( { providedIn: 'root' }) +export class AddonModAssignSubmissionFileHandlerService implements AddonModAssignSubmissionHandler { + + static readonly FOLDER_NAME = 'submission_file'; + + name = 'AddonModAssignSubmissionFileHandler'; + type = 'file'; + + /** + * Whether the plugin can be edited in offline for existing submissions. In general, this should return false if the + * plugin uses Moodle filters. The reason is that the app only prefetches filtered data, and the user should edit + * unfiltered data. + * + * @return Boolean or promise resolved with boolean: whether it can be edited in offline. + */ + canEditOffline(): boolean { + // This plugin doesn't use Moodle filters, it can be edited in offline. + return true; + } + + /** + * Check if a plugin has no data. + * + * @param assign The assignment. + * @param plugin The plugin object. + * @return Whether the plugin is empty. + */ + isEmpty(assign: AddonModAssignAssign, plugin: AddonModAssignPlugin): boolean { + const files = AddonModAssign.instance.getSubmissionPluginAttachments(plugin); + + return files.length === 0; + } + + /** + * Should clear temporary data for a cancelled submission. + * + * @param assign The assignment. + */ + clearTmpData(assign: AddonModAssignAssign): void { + const files = CoreFileSession.instance.getFiles(AddonModAssignProvider.COMPONENT, assign.id); + + // Clear the files in session for this assign. + CoreFileSession.instance.clearFiles(AddonModAssignProvider.COMPONENT, assign.id); + + // Now delete the local files from the tmp folder. + CoreFileUploader.instance.clearTmpFiles(files); + } + + /** + * This function will be called when the user wants to create a new submission based on the previous one. + * It should add to pluginData the data to send to server based in the data in plugin (previous attempt). + * + * @param assign The assignment. + * @param plugin The plugin object. + * @param pluginData Object where to store the data to send. + * @return If the function is async, it should return a Promise resolved when done. + */ + async copySubmissionData( + assign: AddonModAssignAssign, + plugin: AddonModAssignPlugin, + pluginData: AddonModAssignSubmissionFilePluginData, + ): Promise { + // We need to re-upload all the existing files. + const files = AddonModAssign.instance.getSubmissionPluginAttachments(plugin); + + // Get the itemId. + pluginData.files_filemanager = await AddonModAssignHelper.instance.uploadFiles(assign.id, files); + } + + /** + * Return the Component to use to display the plugin data, either in read or in edit mode. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @return The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(): Type { + return AddonModAssignSubmissionFileComponent; + } + + /** + * Delete any stored data for the plugin and submission. + * + * @param assign The assignment. + * @param submission The submission. + * @param plugin The plugin object. + * @param offlineData Offline data stored. + * @param siteId Site ID. If not defined, current site. + * @return If the function is async, it should return a Promise resolved when done. + */ + async deleteOfflineData( + assign: AddonModAssignAssign, + submission: AddonModAssignSubmission, + plugin: AddonModAssignPlugin, + offlineData: AddonModAssignSubmissionsDBRecordFormatted, + siteId?: string, + ): Promise { + + await CoreUtils.instance.ignoreErrors( + AddonModAssignHelper.instance.deleteStoredSubmissionFiles( + assign.id, + AddonModAssignSubmissionFileHandlerService.FOLDER_NAME, + submission.userid, + siteId, + ), + ); + } + + /** + * Get files used by this plugin. + * The files returned by this function will be prefetched when the user prefetches the assign. + * + * @param assign The assignment. + * @param submission The submission. + * @param plugin The plugin object. + * @param siteId Site ID. If not defined, current site. + * @return The files (or promise resolved with the files). + */ + getPluginFiles( + assign: AddonModAssignAssign, + submission: AddonModAssignSubmission, + plugin: AddonModAssignPlugin, + ): CoreWSExternalFile[] { + return AddonModAssign.instance.getSubmissionPluginAttachments(plugin); + } + + /** + * Get the size of data (in bytes) this plugin will send to copy a previous submission. + * + * @param assign The assignment. + * @param plugin The plugin object. + * @return The size (or promise resolved with size). + */ + async getSizeForCopy(assign: AddonModAssignAssign, plugin: AddonModAssignPlugin): Promise { + const files = AddonModAssign.instance.getSubmissionPluginAttachments(plugin); + + return CoreFileHelper.instance.getTotalFilesSize(files); + } + + /** + * Get the size of data (in bytes) this plugin will send to add or edit a submission. + * + * @param assign The assignment. + * @param submission The submission. + * @param plugin The plugin object. + * @return The size (or promise resolved with size). + */ + async getSizeForEdit( + assign: AddonModAssignAssign, + submission: AddonModAssignSubmission, + plugin: AddonModAssignPlugin, + ): Promise { + // Check if there's any change. + if (this.hasDataChanged(assign, submission, plugin)) { + const files = CoreFileSession.instance.getFiles(AddonModAssignProvider.COMPONENT, assign.id); + + return CoreFileHelper.instance.getTotalFilesSize(files); + } else { + // Nothing has changed, we won't upload any file. + return 0; + } + } + + /** + * Check if the submission data has changed for this plugin. + * + * @param assign The assignment. + * @param submission The submission. + * @param plugin The plugin object. + * @return Boolean (or promise resolved with boolean): whether the data has changed. + */ + async hasDataChanged( + assign: AddonModAssignAssign, + submission: AddonModAssignSubmission, + plugin: AddonModAssignPlugin, + ): Promise { + const offlineData = await CoreUtils.instance.ignoreErrors( + // Check if there's any offline data. + AddonModAssignOffline.instance.getSubmission(assign.id, submission.userid), + undefined, + ); + + let numFiles: number; + if (offlineData && offlineData.plugindata && offlineData.plugindata.files_filemanager) { + const offlineDataFiles = offlineData.plugindata.files_filemanager; + // Has offline data, return the number of files. + numFiles = offlineDataFiles.offline + offlineDataFiles.online.length; + } else { + // No offline data, return the number of online files. + const pluginFiles = AddonModAssign.instance.getSubmissionPluginAttachments(plugin); + + numFiles = pluginFiles && pluginFiles.length; + } + + const currentFiles = CoreFileSession.instance.getFiles(AddonModAssignProvider.COMPONENT, assign.id); + + if (currentFiles.length != numFiles) { + // Number of files has changed. + return true; + } + + const files = await this.getSubmissionFilesToSync(assign, submission, offlineData); + + // Check if there is any local file added and list has changed. + return CoreFileUploader.instance.areFileListDifferent(currentFiles, files); + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return True or promise resolved with true if enabled. + */ + async isEnabled(): Promise { + return true; + } + + /** + * Whether or not the handler is enabled for edit on a site level. + * + * @return Whether or not the handler is enabled for edit on a site level. + */ + isEnabledForEdit(): boolean { + return true; + } + + /** + * Prepare and add to pluginData the data to send to the server based on the input data. + * + * @param assign The assignment. + * @param submission The submission. + * @param plugin The plugin object. + * @param inputData Data entered by the user for the submission. + * @param pluginData Object where to store the data to send. + * @param offline Whether the user is editing in offline. + * @param userId User ID. If not defined, site's current user. + * @param siteId Site ID. If not defined, current site. + * @return If the function is async, it should return a Promise resolved when done. + */ + async prepareSubmissionData( + assign: AddonModAssignAssign, + submission: AddonModAssignSubmission, + plugin: AddonModAssignPlugin, + inputData: AddonModAssignSubmissionFileData, + pluginData: AddonModAssignSubmissionFilePluginData, + offline?: boolean, + userId?: number, + siteId?: string, + ): Promise { + + const changed = await this.hasDataChanged(assign, submission, plugin); + if (!changed) { + return; + } + + // Data has changed, we need to upload new files and re-upload all the existing files. + const currentFiles = CoreFileSession.instance.getFiles(AddonModAssignProvider.COMPONENT, assign.id); + const error = CoreUtils.instance.hasRepeatedFilenames(currentFiles); + + if (error) { + throw error; + } + + pluginData.files_filemanager = await AddonModAssignHelper.instance.uploadOrStoreFiles( + assign.id, + AddonModAssignSubmissionFileHandlerService.FOLDER_NAME, + currentFiles, + offline, + userId, + siteId, + ); + } + + /** + * Prepare and add to pluginData the data to send to the server based on the offline data stored. + * This will be used when performing a synchronization. + * + * @param assign The assignment. + * @param submission The submission. + * @param plugin The plugin object. + * @param offlineData Offline data stored. + * @param pluginData Object where to store the data to send. + * @param siteId Site ID. If not defined, current site. + * @return If the function is async, it should return a Promise resolved when done. + */ + async prepareSyncData( + assign: AddonModAssignAssign, + submission: AddonModAssignSubmission, + plugin: AddonModAssignPlugin, + offlineData: AddonModAssignSubmissionsDBRecordFormatted, + pluginData: AddonModAssignSubmissionFilePluginData, + siteId?: string, + ): Promise { + + const files = await this.getSubmissionFilesToSync(assign, submission, offlineData, siteId); + + if (files.length == 0) { + return; + } + + pluginData.files_filemanager = await AddonModAssignHelper.instance.uploadFiles(assign.id, files, siteId); + } + + /** + * Get the file list to be synced. + * + * @param assign The assignment. + * @param submission The submission. + * @param offlineData Offline data stored. + * @param siteId Site ID. If not defined, current site. + * @return File entries when is all resolved. + */ + protected async getSubmissionFilesToSync( + assign: AddonModAssignAssign, + submission: AddonModAssignSubmission, + offlineData?: AddonModAssignSubmissionsDBRecordFormatted, + siteId?: string, + ): Promise<(FileEntry | CoreWSExternalFile)[]> { + const filesData = offlineData?.plugindata.files_filemanager; + if (!filesData) { + return []; + } + + // Has some data to sync. + let files: (FileEntry | CoreWSExternalFile)[] = filesData.online || []; + + if (filesData.offline) { + // Has offline files, get them and add them to the list. + const storedFiles = await CoreUtils.instance.ignoreErrors( + AddonModAssignHelper.instance.getStoredSubmissionFiles( + assign.id, + AddonModAssignSubmissionFileHandlerService.FOLDER_NAME, + submission.userid, + siteId, + ), + [], + ); + files = files.concat(storedFiles); + } + + return files; + } + +} +export const AddonModAssignSubmissionFileHandler = makeSingleton(AddonModAssignSubmissionFileHandlerService); + +// Define if ever used. +export type AddonModAssignSubmissionFileData = Record; + +export type AddonModAssignSubmissionFilePluginData = { + // The id of a draft area containing files for this submission. Or the offline file results. + files_filemanager: number | CoreFileUploaderStoreFilesResult; // eslint-disable-line @typescript-eslint/naming-convention +}; diff --git a/src/addons/mod/assign/submission/onlinetext/component/addon-mod-assign-submission-onlinetext.html b/src/addons/mod/assign/submission/onlinetext/component/addon-mod-assign-submission-onlinetext.html new file mode 100644 index 000000000..cc3c5c014 --- /dev/null +++ b/src/addons/mod/assign/submission/onlinetext/component/addon-mod-assign-submission-onlinetext.html @@ -0,0 +1,35 @@ + + + +

{{ plugin.name }}

+

{{ 'addon.mod_assign.numwords' | translate: {'$a': words} }}

+

+ + +

+
+
+ + +
+ +

{{ plugin.name }}

+
+ + +

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

+

{{ 'core.numwords' | translate: {'$a': words + ' / ' + wordLimit} }}

+
+
+ + + + + + +
diff --git a/src/addons/mod/assign/submission/onlinetext/component/onlinetext.ts b/src/addons/mod/assign/submission/onlinetext/component/onlinetext.ts new file mode 100644 index 000000000..b8a5b38ac --- /dev/null +++ b/src/addons/mod/assign/submission/onlinetext/component/onlinetext.ts @@ -0,0 +1,130 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { AddonModAssignSubmissionPluginComponent } from '@addons/mod/assign/components/submission-plugin/submission-plugin'; +import { AddonModAssignProvider, AddonModAssign } from '@addons/mod/assign/services/assign'; +import { AddonModAssignOffline } from '@addons/mod/assign/services/assign-offline'; +import { Component, OnInit, ElementRef } from '@angular/core'; +import { FormBuilder, FormControl } from '@angular/forms'; +import { CoreSites } from '@services/sites'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { AddonModAssignSubmissionOnlineTextPluginData } from '../services/handler'; + + +/** + * Component to render an onlinetext submission plugin. + */ +@Component({ + selector: 'addon-mod-assign-submission-online-text', + templateUrl: 'addon-mod-assign-submission-onlinetext.html', +}) +export class AddonModAssignSubmissionOnlineTextComponent extends AddonModAssignSubmissionPluginComponent implements OnInit { + + control?: FormControl; + words = 0; + component = AddonModAssignProvider.COMPONENT; + text = ''; + loaded = false; + wordLimitEnabled = false; + currentUserId: number; + wordLimit = 0; + + protected wordCountTimeout?: number; + protected element: HTMLElement; + + constructor( + protected fb: FormBuilder, + element: ElementRef, + ) { + super(); + this.element = element.nativeElement; + this.currentUserId = CoreSites.instance.getCurrentSiteUserId(); + } + + /** + * Component being initialized. + */ + async nOnInit(): Promise { + // Get the text. Check if we have anything offline. + const offlineData = await CoreUtils.instance.ignoreErrors( + AddonModAssignOffline.instance.getSubmission(this.assign.id), + undefined, + ); + + this.wordLimitEnabled = !!parseInt(this.data?.configs.wordlimitenabled || '0', 10); + this.wordLimit = parseInt(this.data?.configs.wordlimit || '0'); + + try { + if (offlineData && offlineData.plugindata && offlineData.plugindata.onlinetext_editor) { + this.text = (offlineData.plugindata).onlinetext_editor.text; + } else { + // No offline data found, return online text. + this.text = AddonModAssign.instance.getSubmissionPluginText(this.plugin); + } + + + // Set the text. + if (!this.edit) { + // Not editing, see full text when clicked. + this.element.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + + if (this.text) { + // Open a new state with the interpolated contents. + CoreTextUtils.instance.viewText(this.plugin.name, this.text, { + component: this.component, + componentId: this.assign.cmid, + filter: true, + contextLevel: 'module', + instanceId: this.assign.cmid, + courseId: this.assign.course, + }); + } + }); + } else { + // Create and add the control. + this.control = this.fb.control(this.text); + } + + // Calculate initial words. + if (this.wordLimitEnabled) { + this.words = CoreTextUtils.instance.countWords(this.text); + } + } finally { + this.loaded = true; + } + } + + /** + * Text changed. + * + * @param text The new text. + */ + onChange(text: string): void { + // Count words if needed. + if (this.wordLimitEnabled) { + // Cancel previous wait. + clearTimeout(this.wordCountTimeout); + + // Wait before calculating, if the user keeps inputing we won't calculate. + // This is to prevent slowing down devices, this calculation can be slow if the text is long. + this.wordCountTimeout = window.setTimeout(() => { + this.words = CoreTextUtils.instance.countWords(text); + }, 1500); + } + } + +} diff --git a/src/addons/mod/assign/submission/onlinetext/lang.json b/src/addons/mod/assign/submission/onlinetext/lang.json new file mode 100644 index 000000000..e49362133 --- /dev/null +++ b/src/addons/mod/assign/submission/onlinetext/lang.json @@ -0,0 +1,4 @@ +{ + "pluginname": "Online text submissions", + "wordlimitexceeded": "The word limit for this assignment is {{$a.limit}} words and you are attempting to submit {{$a.count}} words. Please review your submission and try again." +} \ No newline at end of file diff --git a/src/addons/mod/assign/submission/onlinetext/onlinetext.module.ts b/src/addons/mod/assign/submission/onlinetext/onlinetext.module.ts new file mode 100644 index 000000000..cbcaaa5ba --- /dev/null +++ b/src/addons/mod/assign/submission/onlinetext/onlinetext.module.ts @@ -0,0 +1,48 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { APP_INITIALIZER, NgModule } from '@angular/core'; +import { AddonModAssignSubmissionOnlineTextHandler } from './services/handler'; +import { AddonModAssignSubmissionOnlineTextComponent } from './component/onlinetext'; +import { CoreEditorComponentsModule } from '@features/editor/components/components.module'; +import { CoreSharedModule } from '@/core/shared.module'; +import { AddonModAssignSubmissionDelegate } from '../../services/submission-delegate'; + +@NgModule({ + declarations: [ + AddonModAssignSubmissionOnlineTextComponent, + ], + imports: [ + CoreSharedModule, + CoreEditorComponentsModule, + ], + providers: [ + AddonModAssignSubmissionOnlineTextHandler, + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + AddonModAssignSubmissionDelegate.instance.registerHandler(AddonModAssignSubmissionOnlineTextHandler.instance); + }, + }, + ], + exports: [ + AddonModAssignSubmissionOnlineTextComponent, + ], + entryComponents: [ + AddonModAssignSubmissionOnlineTextComponent, + ], +}) +export class AddonModAssignSubmissionOnlineTextModule {} diff --git a/src/addons/mod/assign/submission/onlinetext/services/handler.ts b/src/addons/mod/assign/submission/onlinetext/services/handler.ts new file mode 100644 index 000000000..97696f2d5 --- /dev/null +++ b/src/addons/mod/assign/submission/onlinetext/services/handler.ts @@ -0,0 +1,323 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + AddonModAssignAssign, + AddonModAssignSubmission, + AddonModAssignPlugin, + AddonModAssign, +} from '@addons/mod/assign/services/assign'; +import { AddonModAssignHelper } from '@addons/mod/assign/services/assign-helper'; +import { AddonModAssignOffline, AddonModAssignSubmissionsDBRecordFormatted } from '@addons/mod/assign/services/assign-offline'; +import { AddonModAssignSubmissionHandler } from '@addons/mod/assign/services/submission-delegate'; +import { Injectable, Type } from '@angular/core'; +import { CoreError } from '@classes/errors/error'; +import { CoreFileHelper } from '@services/file-helper'; +import { CoreSites } from '@services/sites'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreWSExternalFile } from '@services/ws'; +import { makeSingleton, Translate } from '@singletons'; +import { AddonModAssignSubmissionOnlineTextComponent } from '../component/onlinetext'; + +/** + * Handler for online text submission plugin. + */ +@Injectable( { providedIn: 'root' }) +export class AddonModAssignSubmissionOnlineTextHandlerService implements AddonModAssignSubmissionHandler { + + name = 'AddonModAssignSubmissionOnlineTextHandler'; + type = 'onlinetext'; + + /** + * Whether the plugin can be edited in offline for existing submissions. In general, this should return false if the + * plugin uses Moodle filters. The reason is that the app only prefetches filtered data, and the user should edit + * unfiltered data. + * + * @return Boolean or promise resolved with boolean: whether it can be edited in offline. + */ + canEditOffline(): boolean { + // This plugin uses Moodle filters, it cannot be edited in offline. + return false; + } + + /** + * Check if a plugin has no data. + * + * @param assign The assignment. + * @param plugin The plugin object. + * @return Whether the plugin is empty. + */ + isEmpty(assign: AddonModAssignAssign, plugin: AddonModAssignPlugin): boolean { + const text = AddonModAssign.instance.getSubmissionPluginText(plugin, true); + + // If the text is empty, we can ignore files because they won't be visible anyways. + return text.trim().length === 0; + } + + /** + * This function will be called when the user wants to create a new submission based on the previous one. + * It should add to pluginData the data to send to server based in the data in plugin (previous attempt). + * + * @param assign The assignment. + * @param plugin The plugin object. + * @param pluginData Object where to store the data to send. + * @param userId User ID. If not defined, site's current user. + * @param siteId Site ID. If not defined, current site. + * @return If the function is async, it should return a Promise resolved when done. + */ + async copySubmissionData( + assign: AddonModAssignAssign, + plugin: AddonModAssignPlugin, + pluginData: AddonModAssignSubmissionOnlineTextPluginData, + userId?: number, + siteId?: string, + ): Promise { + + const text = AddonModAssign.instance.getSubmissionPluginText(plugin, true); + const files = AddonModAssign.instance.getSubmissionPluginAttachments(plugin); + let itemId = 0; + + if (files.length) { + // Re-upload the files. + itemId = await AddonModAssignHelper.instance.uploadFiles(assign.id, files, siteId); + } + + pluginData.onlinetext_editor = { + text: text, + format: 1, + itemid: itemId, + }; + } + + /** + * Return the Component to use to display the plugin data, either in read or in edit mode. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @return The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(): Type { + return AddonModAssignSubmissionOnlineTextComponent; + } + + /** + * Get files used by this plugin. + * The files returned by this function will be prefetched when the user prefetches the assign. + * + * @param assign The assignment. + * @param submission The submission. + * @param plugin The plugin object. + * @return The files (or promise resolved with the files). + */ + getPluginFiles( + assign: AddonModAssignAssign, + submission: AddonModAssignSubmission, + plugin: AddonModAssignPlugin, + ): CoreWSExternalFile[] { + return AddonModAssign.instance.getSubmissionPluginAttachments(plugin); + } + + /** + * Get the size of data (in bytes) this plugin will send to copy a previous submission. + * + * @param assign The assignment. + * @param plugin The plugin object. + * @return The size (or promise resolved with size). + */ + async getSizeForCopy(assign: AddonModAssignAssign, plugin: AddonModAssignPlugin): Promise { + const text = AddonModAssign.instance.getSubmissionPluginText(plugin, true); + const files = AddonModAssign.instance.getSubmissionPluginAttachments(plugin); + + const filesSize = await CoreFileHelper.instance.getTotalFilesSize(files); + + return text.length + filesSize; + } + + /** + * Get the size of data (in bytes) this plugin will send to add or edit a submission. + * + * @param assign The assignment. + * @param submission The submission. + * @param plugin The plugin object. + * @param inputData Data entered by the user for the submission. + * @return The size (or promise resolved with size). + */ + getSizeForEdit( + assign: AddonModAssignAssign, + submission: AddonModAssignSubmission, + plugin: AddonModAssignPlugin, + ): number { + const text = AddonModAssign.instance.getSubmissionPluginText(plugin, true); + + return text.length; + } + + /** + * Get the text to submit. + * + * @param plugin The plugin object. + * @param inputData Data entered by the user for the submission. + * @return Text to submit. + */ + protected getTextToSubmit(plugin: AddonModAssignPlugin, inputData: AddonModAssignSubmissionOnlineTextData): string { + const text = inputData.onlinetext_editor_text; + const files = plugin.fileareas && plugin.fileareas[0] && plugin.fileareas[0].files || []; + + return CoreTextUtils.instance.restorePluginfileUrls(text, files || []); + } + + /** + * Check if the submission data has changed for this plugin. + * + * @param assign The assignment. + * @param submission The submission. + * @param plugin The plugin object. + * @param inputData Data entered by the user for the submission. + * @return Boolean (or promise resolved with boolean): whether the data has changed. + */ + async hasDataChanged( + assign: AddonModAssignAssign, + submission: AddonModAssignSubmission, + plugin: AddonModAssignPlugin, + inputData: AddonModAssignSubmissionOnlineTextData, + ): Promise { + + // Get the original text from plugin or offline. + const offlineData = + await CoreUtils.instance.ignoreErrors(AddonModAssignOffline.instance.getSubmission(assign.id, submission.userid)); + + let initialText = ''; + if (offlineData && offlineData.plugindata && offlineData.plugindata.onlinetext_editor) { + initialText = (offlineData.plugindata).onlinetext_editor.text; + } else { + // No offline data found, get text from plugin. + initialText = plugin.editorfields && plugin.editorfields[0] ? plugin.editorfields[0].text : ''; + } + + // Check if text has changed. + return initialText != this.getTextToSubmit(plugin, inputData); + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return True or promise resolved with true if enabled. + */ + async isEnabled(): Promise { + return true; + } + + /** + * Whether or not the handler is enabled for edit on a site level. + * + * @return Whether or not the handler is enabled for edit on a site level. + */ + isEnabledForEdit(): boolean { + // There's a bug in Moodle 3.1.0 that doesn't allow submitting HTML, so we'll disable this plugin in that case. + // Bug was fixed in 3.1.1 minor release and in 3.2. + const currentSite = CoreSites.instance.getCurrentSite(); + + return !!currentSite?.isVersionGreaterEqualThan('3.1.1') || !!currentSite?.checkIfAppUsesLocalMobile(); + } + + /** + * Prepare and add to pluginData the data to send to the server based on the input data. + * + * @param assign The assignment. + * @param submission The submission. + * @param plugin The plugin object. + * @param inputData Data entered by the user for the submission. + * @param pluginData Object where to store the data to send. + * @param offline Whether the user is editing in offline. + * @param userId User ID. If not defined, site's current user. + * @param siteId Site ID. If not defined, current site. + * @return If the function is async, it should return a Promise resolved when done. + */ + prepareSubmissionData( + assign: AddonModAssignAssign, + submission: AddonModAssignSubmission, + plugin: AddonModAssignPlugin, + inputData: AddonModAssignSubmissionOnlineTextData, + pluginData: AddonModAssignSubmissionOnlineTextPluginData, + ): void | Promise { + + let text = this.getTextToSubmit(plugin, inputData); + + // Check word limit. + const configs = AddonModAssignHelper.instance.getPluginConfig(assign, 'assignsubmission', plugin.type); + if (parseInt(configs.wordlimitenabled, 10)) { + const words = CoreTextUtils.instance.countWords(text); + const wordlimit = parseInt(configs.wordlimit, 10); + if (words > wordlimit) { + const params = { $a: { count: words, limit: wordlimit } }; + const message = Translate.instance.instant('addon.mod_assign_submission_onlinetext.wordlimitexceeded', params); + + throw new CoreError(message); + } + } + + // Add some HTML to the text if needed. + text = CoreTextUtils.instance.formatHtmlLines(text); + + pluginData.onlinetext_editor = { + text: text, + format: 1, + itemid: 0, // Can't add new files yet, so we use a fake itemid. + }; + } + + /** + * Prepare and add to pluginData the data to send to the server based on the offline data stored. + * This will be used when performing a synchronization. + * + * @param assign The assignment. + * @param submission The submission. + * @param plugin The plugin object. + * @param offlineData Offline data stored. + * @param pluginData Object where to store the data to send. + * @param siteId Site ID. If not defined, current site. + * @return If the function is async, it should return a Promise resolved when done. + */ + prepareSyncData( + assign: AddonModAssignAssign, + submission: AddonModAssignSubmission, + plugin: AddonModAssignPlugin, + offlineData: AddonModAssignSubmissionsDBRecordFormatted, + pluginData: AddonModAssignSubmissionOnlineTextPluginData, + ): void | Promise { + + const offlinePluginData = (offlineData && offlineData.plugindata); + const textData = offlinePluginData.onlinetext_editor; + if (textData) { + // Has some data to sync. + pluginData.onlinetext_editor = textData; + } + } + +} +export const AddonModAssignSubmissionOnlineTextHandler = makeSingleton(AddonModAssignSubmissionOnlineTextHandlerService); + +export type AddonModAssignSubmissionOnlineTextData = { + // The text for this submission. + onlinetext_editor_text: string; // eslint-disable-line @typescript-eslint/naming-convention +}; + +export type AddonModAssignSubmissionOnlineTextPluginData = { + // Editor structure. + onlinetext_editor: { // eslint-disable-line @typescript-eslint/naming-convention + text: string; // The text for this submission. + format: number; // The format for this submission. + itemid: number; // The draft area id for files attached to the submission. + }; +}; diff --git a/src/addons/mod/assign/submission/submission.module.ts b/src/addons/mod/assign/submission/submission.module.ts new file mode 100644 index 000000000..cb01e8e7c --- /dev/null +++ b/src/addons/mod/assign/submission/submission.module.ts @@ -0,0 +1,27 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { AddonModAssignSubmissionCommentsModule } from './comments/comments.module'; +import { AddonModAssignSubmissionFileModule } from './file/file.module'; +import { AddonModAssignSubmissionOnlineTextModule } from './onlinetext/onlinetext.module'; + +@NgModule({ + imports: [ + AddonModAssignSubmissionCommentsModule, + AddonModAssignSubmissionFileModule, + AddonModAssignSubmissionOnlineTextModule, + ], +}) +export class AddonModAssignSubmissionModule { } From c4d37b00741a6aa0a187e9fd0a56532b60320694 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Thu, 18 Feb 2021 09:55:59 +0100 Subject: [PATCH 07/17] MOBILE-3636 assign: Feedback plugins --- .../feedback-plugin/feedback-plugin.ts | 45 +++ .../feedback/comments/comments.module.ts | 49 ++++ .../addon-mod-assign-feedback-comments.html | 34 +++ .../feedback/comments/component/comments.ts | 161 +++++++++++ .../mod/assign/feedback/comments/lang.json | 3 + .../feedback/comments/services/handler.ts | 266 ++++++++++++++++++ .../addon-mod-assign-feedback-editpdf.html | 11 + .../feedback/editpdf/component/editpdf.ts | 41 +++ .../assign/feedback/editpdf/editpdf.module.ts | 46 +++ .../mod/assign/feedback/editpdf/lang.json | 3 + .../feedback/editpdf/services/handler.ts | 73 +++++ .../mod/assign/feedback/feedback.module.ts | 27 ++ .../addon-mod-assign-feedback-file.html | 11 + .../assign/feedback/file/component/file.ts | 41 +++ .../mod/assign/feedback/file/file.module.ts | 46 +++ src/addons/mod/assign/feedback/file/lang.json | 3 + .../assign/feedback/file/services/handler.ts | 73 +++++ .../mod/assign/services/feedback-delegate.ts | 28 +- .../services/handlers/default-feedback.ts | 3 +- 19 files changed, 954 insertions(+), 10 deletions(-) create mode 100644 src/addons/mod/assign/feedback/comments/comments.module.ts create mode 100644 src/addons/mod/assign/feedback/comments/component/addon-mod-assign-feedback-comments.html create mode 100644 src/addons/mod/assign/feedback/comments/component/comments.ts create mode 100644 src/addons/mod/assign/feedback/comments/lang.json create mode 100644 src/addons/mod/assign/feedback/comments/services/handler.ts create mode 100644 src/addons/mod/assign/feedback/editpdf/component/addon-mod-assign-feedback-editpdf.html create mode 100644 src/addons/mod/assign/feedback/editpdf/component/editpdf.ts create mode 100644 src/addons/mod/assign/feedback/editpdf/editpdf.module.ts create mode 100644 src/addons/mod/assign/feedback/editpdf/lang.json create mode 100644 src/addons/mod/assign/feedback/editpdf/services/handler.ts create mode 100644 src/addons/mod/assign/feedback/feedback.module.ts create mode 100644 src/addons/mod/assign/feedback/file/component/addon-mod-assign-feedback-file.html create mode 100644 src/addons/mod/assign/feedback/file/component/file.ts create mode 100644 src/addons/mod/assign/feedback/file/file.module.ts create mode 100644 src/addons/mod/assign/feedback/file/lang.json create mode 100644 src/addons/mod/assign/feedback/file/services/handler.ts diff --git a/src/addons/mod/assign/components/feedback-plugin/feedback-plugin.ts b/src/addons/mod/assign/components/feedback-plugin/feedback-plugin.ts index 709c8aa72..b84b54808 100644 --- a/src/addons/mod/assign/components/feedback-plugin/feedback-plugin.ts +++ b/src/addons/mod/assign/components/feedback-plugin/feedback-plugin.ts @@ -13,8 +13,11 @@ // limitations under the License. import { Component, Input, OnInit, ViewChild, Type } from '@angular/core'; +import { CoreError } from '@classes/errors/error'; import { CoreDynamicComponent } from '@components/dynamic-component/dynamic-component'; import { CoreWSExternalFile } from '@services/ws'; +import { ModalController } from '@singletons'; +import { AddonModAssignFeedbackCommentsPluginData } from '../../feedback/comments/services/handler'; import { AddonModAssignAssign, AddonModAssignSubmission, @@ -24,6 +27,7 @@ import { } from '../../services/assign'; import { AddonModAssignHelper, AddonModAssignPluginConfig } from '../../services/assign-helper'; import { AddonModAssignFeedbackDelegate } from '../../services/feedback-delegate'; +import { AddonModAssignEditFeedbackModalComponent } from '../edit-feedback-modal/edit-feedback-modal'; /** * Component that displays an assignment feedback plugin. @@ -95,6 +99,47 @@ export class AddonModAssignFeedbackPluginComponent implements OnInit { } } + /** + * Open a modal to edit the feedback plugin. + * + * @return Promise resolved with the input data, rejected if cancelled. + */ + editFeedback(): Promise { + if (!this.canEdit) { + throw new CoreError('Cannot edit feedback'); + } + + return new Promise((resolve, reject): void => { + this.showEditFeedbackModal(resolve, reject); + }); + } + + protected async showEditFeedbackModal( + resolve: (value: AddonModAssignFeedbackCommentsPluginData | PromiseLike) => void, + reject: () => void, + ): Promise < void> { + // Create the navigation modal. + const modal = await ModalController.instance.create({ + component: AddonModAssignEditFeedbackModalComponent, + componentProps: { + assign: this.assign, + submission: this.submission, + plugin: this.plugin, + userId: this.userId, + }, + }); + + await modal.present(); + + const result = await modal.onDidDismiss(); + + if (typeof result.data == 'undefined') { + reject(); + } else { + resolve(result.data); + } + } + /** * Invalidate the plugin data. * diff --git a/src/addons/mod/assign/feedback/comments/comments.module.ts b/src/addons/mod/assign/feedback/comments/comments.module.ts new file mode 100644 index 000000000..f0f79618b --- /dev/null +++ b/src/addons/mod/assign/feedback/comments/comments.module.ts @@ -0,0 +1,49 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { APP_INITIALIZER, NgModule } from '@angular/core'; +import { AddonModAssignFeedbackCommentsHandler } from './services/handler'; +import { AddonModAssignFeedbackCommentsComponent } from './component/comments'; +import { CoreSharedModule } from '@/core/shared.module'; +import { CoreEditorComponentsModule } from '@features/editor/components/components.module'; +import { AddonModAssignFeedbackDelegate } from '../../services/feedback-delegate'; +import { AddonModAssignSubmissionFileHandler } from '../../submission/file/services/handler'; + +@NgModule({ + declarations: [ + AddonModAssignFeedbackCommentsComponent, + ], + imports: [ + CoreSharedModule, + CoreEditorComponentsModule, + ], + providers: [ + AddonModAssignFeedbackCommentsHandler, + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + AddonModAssignFeedbackDelegate.instance.registerHandler(AddonModAssignSubmissionFileHandler.instance); + }, + }, + ], + exports: [ + AddonModAssignFeedbackCommentsComponent, + ], + entryComponents: [ + AddonModAssignFeedbackCommentsComponent, + ], +}) +export class AddonModAssignFeedbackCommentsModule {} 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 new file mode 100644 index 000000000..5b01232c3 --- /dev/null +++ b/src/addons/mod/assign/feedback/comments/component/addon-mod-assign-feedback-comments.html @@ -0,0 +1,34 @@ + + + +

{{ plugin.name }}

+

+ + +

+
+
+
+ + + +
+ + + {{ 'core.notsent' | translate }} + +
+
+ + + + + + + + diff --git a/src/addons/mod/assign/feedback/comments/component/comments.ts b/src/addons/mod/assign/feedback/comments/component/comments.ts new file mode 100644 index 000000000..0bfbf4384 --- /dev/null +++ b/src/addons/mod/assign/feedback/comments/component/comments.ts @@ -0,0 +1,161 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, OnInit, ElementRef } from '@angular/core'; +import { FormBuilder, FormControl } from '@angular/forms'; +import { AddonModAssignFeedbackPluginComponent } from '@addons/mod/assign/components/feedback-plugin/feedback-plugin'; +import { AddonModAssign, AddonModAssignProvider } from '@addons/mod/assign/services/assign'; +import { CoreTextUtils } from '@services/utils/text'; +import { + AddonModAssignFeedbackCommentsDraftData, + AddonModAssignFeedbackCommentsHandler, + AddonModAssignFeedbackCommentsPluginData, +} from '../services/handler'; +import { AddonModAssignFeedbackDelegate } from '@addons/mod/assign/services/feedback-delegate'; +import { AddonModAssignOffline } from '@addons/mod/assign/services/assign-offline'; +import { CoreUtils } from '@services/utils/utils'; +/** + * Component to render a comments feedback plugin. + */ +@Component({ + selector: 'addon-mod-assign-feedback-comments', + templateUrl: 'addon-mod-assign-feedback-comments.html', +}) +export class AddonModAssignFeedbackCommentsComponent extends AddonModAssignFeedbackPluginComponent implements OnInit { + + control?: FormControl; + component = AddonModAssignProvider.COMPONENT; + text = ''; + isSent = false; + loaded = false; + + protected element: HTMLElement; + + constructor( + element: ElementRef, + protected fb: FormBuilder, + ) { + super(); + this.element = element.nativeElement; + } + + /** + * Component being initialized. + */ + async ngOnInit(): Promise { + try { + this.text = await this.getText(); + + if (!this.canEdit && !this.edit) { + // User cannot edit the comment. Show it full when clicked. + this.element.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + + if (this.text) { + // Open a new state with the text. + CoreTextUtils.instance.viewText(this.plugin.name, this.text, { + component: this.component, + componentId: this.assign.cmid, + filter: true, + contextLevel: 'module', + instanceId: this.assign.cmid, + courseId: this.assign.course, + }); + } + }); + } else if (this.edit) { + this.control = this.fb.control(this.text); + } + } finally { + this.loaded = true; + } + } + + /** + * Edit the comment. + */ + async editComment(): Promise { + try { + const inputData = await this.editFeedback(); + const text = AddonModAssignFeedbackCommentsHandler.instance.getTextFromInputData(this.plugin, inputData); + + // Update the text and save it as draft. + this.isSent = false; + this.text = this.replacePluginfileUrls(text); + AddonModAssignFeedbackDelegate.instance.saveFeedbackDraft(this.assign.id, this.userId, this.plugin, { + text: text, + format: 1, + }); + } catch { + // User cancelled, nothing to do. + } + } + + /** + * Get the text for the plugin. + * + * @return Promise resolved with the text. + */ + protected async getText(): Promise { + // Check if the user already modified the comment. + const draft: AddonModAssignFeedbackCommentsDraftData | undefined = + await AddonModAssignFeedbackDelegate.instance.getPluginDraftData(this.assign.id, this.userId, this.plugin); + + if (draft) { + this.isSent = false; + + return this.replacePluginfileUrls(draft.text); + } + + // There is no draft saved. Check if we have anything offline. + const offlineData = await CoreUtils.instance.ignoreErrors( + AddonModAssignOffline.instance.getSubmissionGrade(this.assign.id, this.userId), + undefined, + ); + + if (offlineData && offlineData.plugindata && offlineData.plugindata.assignfeedbackcomments_editor) { + const pluginData = offlineData.plugindata; + + // Save offline as draft. + this.isSent = false; + AddonModAssignFeedbackDelegate.instance.saveFeedbackDraft( + this.assign.id, + this.userId, + this.plugin, + pluginData.assignfeedbackcomments_editor, + ); + + return this.replacePluginfileUrls(pluginData.assignfeedbackcomments_editor.text); + } + + // No offline data found, return online text. + this.isSent = true; + + return AddonModAssign.instance.getSubmissionPluginText(this.plugin); + } + + /** + * Replace @@PLUGINFILE@@ wildcards with the real URL of embedded files. + * + * @param Text to treat. + * @return Treated text. + */ + replacePluginfileUrls(text: string): string { + const files = this.plugin.fileareas && this.plugin.fileareas[0] && this.plugin.fileareas[0].files; + + return CoreTextUtils.instance.replacePluginfileUrls(text, files || []); + } + +} diff --git a/src/addons/mod/assign/feedback/comments/lang.json b/src/addons/mod/assign/feedback/comments/lang.json new file mode 100644 index 000000000..637363859 --- /dev/null +++ b/src/addons/mod/assign/feedback/comments/lang.json @@ -0,0 +1,3 @@ +{ + "pluginname": "Feedback comments" +} \ No newline at end of file diff --git a/src/addons/mod/assign/feedback/comments/services/handler.ts b/src/addons/mod/assign/feedback/comments/services/handler.ts new file mode 100644 index 000000000..58c98ff34 --- /dev/null +++ b/src/addons/mod/assign/feedback/comments/services/handler.ts @@ -0,0 +1,266 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + AddonModAssignPlugin, + AddonModAssignAssign, + AddonModAssignSubmission, + AddonModAssign, + AddonModAssignSavePluginData, +} from '@addons/mod/assign/services/assign'; +import { AddonModAssignOffline } from '@addons/mod/assign/services/assign-offline'; +import { AddonModAssignFeedbackHandler } from '@addons/mod/assign/services/feedback-delegate'; +import { Injectable, Type } from '@angular/core'; +import { CoreSites } from '@services/sites'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreWSExternalFile } from '@services/ws'; +import { makeSingleton } from '@singletons'; +import { AddonModAssignFeedbackCommentsComponent } from '../component/comments'; + +/** + * Handler for comments feedback plugin. + */ +@Injectable( { providedIn: 'root' }) +export class AddonModAssignFeedbackCommentsHandlerService implements AddonModAssignFeedbackHandler { + + name = 'AddonModAssignFeedbackCommentsHandler'; + type = 'comments'; + + // Store the data in this service so it isn't lost if the user performs a PTR in the page. + protected drafts: { [draftId: string]: AddonModAssignFeedbackCommentsDraftData } = {}; + + /** + * Get the text to submit. + * + * @param textUtils Text utils instance. + * @param plugin Plugin. + * @param inputData Data entered in the feedback edit form. + * @return Text to submit. + */ + getTextFromInputData(plugin: AddonModAssignPlugin, inputData: AddonModAssignFeedbackCommentsPluginData): string { + const files = plugin.fileareas && plugin.fileareas[0] ? plugin.fileareas[0].files : []; + let text = ''; + + // The input data can have a string or an object with text and format. Get the text. + if (inputData.assignfeedbackcomments_editor && inputData.assignfeedbackcomments_editor.text) { + text = inputData.assignfeedbackcomments_editor.text; + } + + return CoreTextUtils.instance.restorePluginfileUrls(text, files || []); + } + + /** + * Discard the draft data of the feedback plugin. + * + * @param assignId The assignment ID. + * @param userId User ID. + * @param siteId Site ID. If not defined, current site. + * @return If the function is async, it should return a Promise resolved when done. + */ + discardDraft(assignId: number, userId: number, siteId?: string): void { + const id = this.getDraftId(assignId, userId, siteId); + if (typeof this.drafts[id] != 'undefined') { + delete this.drafts[id]; + } + } + + /** + * Return the Component to use to display the plugin data. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @return The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(): Type { + return AddonModAssignFeedbackCommentsComponent; + } + + /** + * Return the draft saved data of the feedback plugin. + * + * @param assignId The assignment ID. + * @param userId User ID. + * @param siteId Site ID. If not defined, current site. + * @return Data (or promise resolved with the data). + */ + getDraft(assignId: number, userId: number, siteId?: string): AddonModAssignFeedbackCommentsDraftData | undefined { + const id = this.getDraftId(assignId, userId, siteId); + + if (typeof this.drafts[id] != 'undefined') { + return this.drafts[id]; + } + } + + /** + * Get a draft ID. + * + * @param assignId The assignment ID. + * @param userId User ID. + * @param siteId Site ID. If not defined, current site. + * @return Draft ID. + */ + protected getDraftId(assignId: number, userId: number, siteId?: string): string { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + return siteId + '#' + assignId + '#' + userId; + } + + /** + * Get files used by this plugin. + * The files returned by this function will be prefetched when the user prefetches the assign. + * + * @param assign The assignment. + * @param submission The submission. + * @param plugin The plugin object. + * @return The files (or promise resolved with the files). + */ + getPluginFiles( + assign: AddonModAssignAssign, + submission: AddonModAssignSubmission, + plugin: AddonModAssignPlugin, + ): CoreWSExternalFile[] { + return AddonModAssign.instance.getSubmissionPluginAttachments(plugin); + } + + /** + * Check if the feedback data has changed for this plugin. + * + * @param assign The assignment. + * @param submission The submission. + * @param plugin The plugin object. + * @param inputData Data entered by the user for the feedback. + * @param userId User ID of the submission. + * @return Boolean (or promise resolved with boolean): whether the data has changed. + */ + async hasDataChanged( + assign: AddonModAssignAssign, + submission: AddonModAssignSubmission, + plugin: AddonModAssignPlugin, + inputData: AddonModAssignFeedbackCommentsPluginData, + userId: number, + ): Promise { + // Get it from plugin or offline. + const offlineData = await CoreUtils.instance.ignoreErrors( + AddonModAssignOffline.instance.getSubmissionGrade(assign.id, userId), + undefined, + ); + + if (offlineData?.plugindata?.assignfeedbackcomments_editor) { + const pluginData = offlineData.plugindata; + + return !!pluginData.assignfeedbackcomments_editor.text; + } + + // No offline data found, get text from plugin. + const initialText = AddonModAssign.instance.getSubmissionPluginText(plugin); + const newText = AddonModAssignFeedbackCommentsHandler.instance.getTextFromInputData(plugin, inputData); + + if (typeof newText == 'undefined') { + return false; + } + + // Check if text has changed. + return initialText != newText; + } + + /** + * Check whether the plugin has draft data stored. + * + * @param assignId The assignment ID. + * @param userId User ID. + * @param siteId Site ID. If not defined, current site. + * @return Boolean or promise resolved with boolean: whether the plugin has draft data. + */ + hasDraftData(assignId: number, userId: number, siteId?: string): boolean | Promise { + const draft = this.getDraft(assignId, userId, siteId); + + return !!draft; + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return True or promise resolved with true if enabled. + */ + async isEnabled(): Promise { + // In here we should check if comments is not disabled in site. + // But due to this is not a common comments place and it can be disabled separately into Moodle (disabling the plugin). + // We are leaving it always enabled. It's also a teacher's feature. + return true; + } + + /** + * Prepare and add to pluginData the data to send to the server based on the draft data saved. + * + * @param assignId The assignment ID. + * @param userId User ID. + * @param plugin The plugin object. + * @param pluginData Object where to store the data to send. + * @param siteId Site ID. If not defined, current site. + * @return If the function is async, it should return a Promise resolved when done. + */ + prepareFeedbackData( + assignId: number, + userId: number, + plugin: AddonModAssignPlugin, + pluginData: AddonModAssignSavePluginData, + siteId?: string, + ): void { + + const draft = this.getDraft(assignId, userId, siteId); + + if (draft) { + // Add some HTML to the text if needed. + draft.text = CoreTextUtils.instance.formatHtmlLines(draft.text); + + pluginData.assignfeedbackcomments_editor = draft; + } + } + + /** + * Save draft data of the feedback plugin. + * + * @param assignId The assignment ID. + * @param userId User ID. + * @param plugin The plugin object. + * @param data The data to save. + * @param siteId Site ID. If not defined, current site. + * @return If the function is async, it should return a Promise resolved when done. + */ + saveDraft( + assignId: number, + userId: number, + plugin: AddonModAssignPlugin, + data: AddonModAssignFeedbackCommentsDraftData, + siteId?: string, + ): void { + + if (data) { + this.drafts[this.getDraftId(assignId, userId, siteId)] = data; + } + } + +} +export const AddonModAssignFeedbackCommentsHandler = makeSingleton(AddonModAssignFeedbackCommentsHandlerService); + +export type AddonModAssignFeedbackCommentsDraftData = { + text: string; // The text for this feedback. + format: number; // The format for this feedback. +}; + +export type AddonModAssignFeedbackCommentsPluginData = { + // Editor structure. + // eslint-disable-next-line @typescript-eslint/naming-convention + assignfeedbackcomments_editor: AddonModAssignFeedbackCommentsDraftData; +}; diff --git a/src/addons/mod/assign/feedback/editpdf/component/addon-mod-assign-feedback-editpdf.html b/src/addons/mod/assign/feedback/editpdf/component/addon-mod-assign-feedback-editpdf.html new file mode 100644 index 000000000..d6f66aad3 --- /dev/null +++ b/src/addons/mod/assign/feedback/editpdf/component/addon-mod-assign-feedback-editpdf.html @@ -0,0 +1,11 @@ + + + +

{{plugin.name}}

+ + + + +
+
diff --git a/src/addons/mod/assign/feedback/editpdf/component/editpdf.ts b/src/addons/mod/assign/feedback/editpdf/component/editpdf.ts new file mode 100644 index 000000000..f77089ec6 --- /dev/null +++ b/src/addons/mod/assign/feedback/editpdf/component/editpdf.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 { AddonModAssignFeedbackPluginComponent } from '@addons/mod/assign/components/feedback-plugin/feedback-plugin'; +import { AddonModAssignProvider, AddonModAssign } from '@addons/mod/assign/services/assign'; +import { Component, OnInit } from '@angular/core'; +import { CoreWSExternalFile } from '@services/ws'; + +/** + * Component to render a edit pdf feedback plugin. + */ +@Component({ + selector: 'addon-mod-assign-feedback-edit-pdf', + templateUrl: 'addon-mod-assign-feedback-editpdf.html', +}) +export class AddonModAssignFeedbackEditPdfComponent extends AddonModAssignFeedbackPluginComponent implements OnInit { + + component = AddonModAssignProvider.COMPONENT; + files: CoreWSExternalFile[] = []; + + /** + * Component being initialized. + */ + async ngOnInit(): Promise { + if (this.plugin) { + this.files = AddonModAssign.instance.getSubmissionPluginAttachments(this.plugin); + } + } + +} diff --git a/src/addons/mod/assign/feedback/editpdf/editpdf.module.ts b/src/addons/mod/assign/feedback/editpdf/editpdf.module.ts new file mode 100644 index 000000000..1db4ecd21 --- /dev/null +++ b/src/addons/mod/assign/feedback/editpdf/editpdf.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 { APP_INITIALIZER, NgModule } from '@angular/core'; +import { AddonModAssignFeedbackEditPdfHandler } from './services/handler'; +import { AddonModAssignFeedbackEditPdfComponent } from './component/editpdf'; +import { CoreSharedModule } from '@/core/shared.module'; +import { AddonModAssignFeedbackDelegate } from '../../services/feedback-delegate'; + +@NgModule({ + declarations: [ + AddonModAssignFeedbackEditPdfComponent, + ], + imports: [ + CoreSharedModule, + ], + providers: [ + AddonModAssignFeedbackEditPdfHandler, + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + AddonModAssignFeedbackDelegate.instance.registerHandler(AddonModAssignFeedbackEditPdfHandler.instance); + }, + }, + ], + exports: [ + AddonModAssignFeedbackEditPdfComponent, + ], + entryComponents: [ + AddonModAssignFeedbackEditPdfComponent, + ], +}) +export class AddonModAssignFeedbackEditPdfModule {} diff --git a/src/addons/mod/assign/feedback/editpdf/lang.json b/src/addons/mod/assign/feedback/editpdf/lang.json new file mode 100644 index 000000000..a98c70fd9 --- /dev/null +++ b/src/addons/mod/assign/feedback/editpdf/lang.json @@ -0,0 +1,3 @@ +{ + "pluginname": "Annotate PDF" +} \ No newline at end of file diff --git a/src/addons/mod/assign/feedback/editpdf/services/handler.ts b/src/addons/mod/assign/feedback/editpdf/services/handler.ts new file mode 100644 index 000000000..216ba11c1 --- /dev/null +++ b/src/addons/mod/assign/feedback/editpdf/services/handler.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 { + AddonModAssignPlugin, + AddonModAssignAssign, + AddonModAssignSubmission, + AddonModAssign, +} from '@addons/mod/assign/services/assign'; +import { AddonModAssignFeedbackHandler } from '@addons/mod/assign/services/feedback-delegate'; +import { Injectable, Type } from '@angular/core'; +import { CoreWSExternalFile } from '@services/ws'; +import { makeSingleton } from '@singletons'; +import { AddonModAssignFeedbackEditPdfComponent } from '../component/editpdf'; + +/** + * Handler for edit pdf feedback plugin. + */ +@Injectable( { providedIn: 'root' }) +export class AddonModAssignFeedbackEditPdfHandlerService implements AddonModAssignFeedbackHandler { + + name = 'AddonModAssignFeedbackEditPdfHandler'; + type = 'editpdf'; + + /** + * Return the Component to use to display the plugin data. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @return The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(): Type { + return AddonModAssignFeedbackEditPdfComponent; + } + + /** + * Get files used by this plugin. + * The files returned by this function will be prefetched when the user prefetches the assign. + * + * @param assign The assignment. + * @param submission The submission. + * @param plugin The plugin object. + * @return The files (or promise resolved with the files). + */ + getPluginFiles( + assign: AddonModAssignAssign, + submission: AddonModAssignSubmission, + plugin: AddonModAssignPlugin, + ): CoreWSExternalFile[] { + return AddonModAssign.instance.getSubmissionPluginAttachments(plugin); + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return True or promise resolved with true if enabled. + */ + async isEnabled(): Promise { + return true; + } + +} +export const AddonModAssignFeedbackEditPdfHandler = makeSingleton(AddonModAssignFeedbackEditPdfHandlerService); diff --git a/src/addons/mod/assign/feedback/feedback.module.ts b/src/addons/mod/assign/feedback/feedback.module.ts new file mode 100644 index 000000000..dd76de25c --- /dev/null +++ b/src/addons/mod/assign/feedback/feedback.module.ts @@ -0,0 +1,27 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { AddonModAssignFeedbackCommentsModule } from './comments/comments.module'; +import { AddonModAssignFeedbackEditPdfModule } from './editpdf/editpdf.module'; +import { AddonModAssignFeedbackFileModule } from './file/file.module'; + +@NgModule({ + imports: [ + AddonModAssignFeedbackCommentsModule, + AddonModAssignFeedbackEditPdfModule, + AddonModAssignFeedbackFileModule, + ], +}) +export class AddonModAssignFeedbackModule { } diff --git a/src/addons/mod/assign/feedback/file/component/addon-mod-assign-feedback-file.html b/src/addons/mod/assign/feedback/file/component/addon-mod-assign-feedback-file.html new file mode 100644 index 000000000..d6f66aad3 --- /dev/null +++ b/src/addons/mod/assign/feedback/file/component/addon-mod-assign-feedback-file.html @@ -0,0 +1,11 @@ + + + +

{{plugin.name}}

+ + + + +
+
diff --git a/src/addons/mod/assign/feedback/file/component/file.ts b/src/addons/mod/assign/feedback/file/component/file.ts new file mode 100644 index 000000000..45c0bb9b3 --- /dev/null +++ b/src/addons/mod/assign/feedback/file/component/file.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 { AddonModAssignFeedbackPluginComponent } from '@addons/mod/assign/components/feedback-plugin/feedback-plugin'; +import { AddonModAssign, AddonModAssignProvider } from '@addons/mod/assign/services/assign'; +import { Component, OnInit } from '@angular/core'; +import { CoreWSExternalFile } from '@services/ws'; + +/** + * Component to render a file feedback plugin. + */ +@Component({ + selector: 'addon-mod-assign-feedback-file', + templateUrl: 'addon-mod-assign-feedback-file.html', +}) +export class AddonModAssignFeedbackFileComponent extends AddonModAssignFeedbackPluginComponent implements OnInit { + + component = AddonModAssignProvider.COMPONENT; + files: CoreWSExternalFile[] = []; + + /** + * Component being initialized. + */ + async ngOnInit(): Promise { + if (this.plugin) { + this.files = AddonModAssign.instance.getSubmissionPluginAttachments(this.plugin); + } + } + +} diff --git a/src/addons/mod/assign/feedback/file/file.module.ts b/src/addons/mod/assign/feedback/file/file.module.ts new file mode 100644 index 000000000..c4e59e063 --- /dev/null +++ b/src/addons/mod/assign/feedback/file/file.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 { APP_INITIALIZER, NgModule } from '@angular/core'; +import { AddonModAssignFeedbackFileHandler } from './services/handler'; +import { AddonModAssignFeedbackFileComponent } from './component/file'; +import { CoreSharedModule } from '@/core/shared.module'; +import { AddonModAssignFeedbackDelegate } from '../../services/feedback-delegate'; + +@NgModule({ + declarations: [ + AddonModAssignFeedbackFileComponent, + ], + imports: [ + CoreSharedModule, + ], + providers: [ + AddonModAssignFeedbackFileHandler, + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + AddonModAssignFeedbackDelegate.instance.registerHandler(AddonModAssignFeedbackFileHandler.instance); + }, + }, + ], + exports: [ + AddonModAssignFeedbackFileComponent, + ], + entryComponents: [ + AddonModAssignFeedbackFileComponent, + ], +}) +export class AddonModAssignFeedbackFileModule {} diff --git a/src/addons/mod/assign/feedback/file/lang.json b/src/addons/mod/assign/feedback/file/lang.json new file mode 100644 index 000000000..e5e6aeb98 --- /dev/null +++ b/src/addons/mod/assign/feedback/file/lang.json @@ -0,0 +1,3 @@ +{ + "pluginname": "File feedback" +} \ No newline at end of file diff --git a/src/addons/mod/assign/feedback/file/services/handler.ts b/src/addons/mod/assign/feedback/file/services/handler.ts new file mode 100644 index 000000000..9e12e4fd2 --- /dev/null +++ b/src/addons/mod/assign/feedback/file/services/handler.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 { + AddonModAssignPlugin, + AddonModAssignAssign, + AddonModAssignSubmission, + AddonModAssign, +} from '@addons/mod/assign/services/assign'; +import { AddonModAssignFeedbackHandler } from '@addons/mod/assign/services/feedback-delegate'; +import { Injectable, Type } from '@angular/core'; +import { CoreWSExternalFile } from '@services/ws'; +import { makeSingleton } from '@singletons'; +import { AddonModAssignFeedbackFileComponent } from '../component/file'; + +/** + * Handler for file feedback plugin. + */ +@Injectable( { providedIn: 'root' }) +export class AddonModAssignFeedbackFileHandlerService implements AddonModAssignFeedbackHandler { + + name = 'AddonModAssignFeedbackFileHandler'; + type = 'file'; + + /** + * Return the Component to use to display the plugin data. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @return The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(): Type { + return AddonModAssignFeedbackFileComponent; + } + + /** + * Get files used by this plugin. + * The files returned by this function will be prefetched when the user prefetches the assign. + * + * @param assign The assignment. + * @param submission The submission. + * @param plugin The plugin object. + * @return The files (or promise resolved with the files). + */ + getPluginFiles( + assign: AddonModAssignAssign, + submission: AddonModAssignSubmission, + plugin: AddonModAssignPlugin, + ): CoreWSExternalFile[] { + return AddonModAssign.instance.getSubmissionPluginAttachments(plugin); + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return True or promise resolved with true if enabled. + */ + async isEnabled(): Promise { + return true; + } + +} +export const AddonModAssignFeedbackFileHandler = makeSingleton(AddonModAssignFeedbackFileHandlerService); diff --git a/src/addons/mod/assign/services/feedback-delegate.ts b/src/addons/mod/assign/services/feedback-delegate.ts index 8f78851ff..03e462fb0 100644 --- a/src/addons/mod/assign/services/feedback-delegate.ts +++ b/src/addons/mod/assign/services/feedback-delegate.ts @@ -58,7 +58,11 @@ export interface AddonModAssignFeedbackHandler extends CoreDelegateHandler { * @param siteId Site ID. If not defined, current site. * @return Data (or promise resolved with the data). */ - getDraft?(assignId: number, userId: number, siteId?: string): any | Promise; + getDraft?( + assignId: number, + userId: number, + siteId?: string, + ): Record | Promise | undefined> | undefined; /** * Get files used by this plugin. @@ -99,7 +103,7 @@ export interface AddonModAssignFeedbackHandler extends CoreDelegateHandler { assign: AddonModAssignAssign, submission: AddonModAssignSubmission, plugin: AddonModAssignPlugin, - inputData: any, + inputData: Record, userId: number, ): boolean | Promise; @@ -158,7 +162,13 @@ export interface AddonModAssignFeedbackHandler extends CoreDelegateHandler { * @param siteId Site ID. If not defined, current site. * @return If the function is async, it should return a Promise resolved when done. */ - saveDraft?(assignId: number, userId: number, plugin: AddonModAssignPlugin, data: any, siteId?: string): void | Promise; + saveDraft?( + assignId: number, + userId: number, + plugin: AddonModAssignPlugin, + data: Record, + siteId?: string, + ): void | Promise; } /** @@ -189,7 +199,7 @@ export class AddonModAssignFeedbackDelegateService extends CoreDelegate { + ): Promise { return await this.executeFunctionOnEnabled(plugin.type, 'discardDraft', [assignId, userId, siteId]); } @@ -212,12 +222,12 @@ export class AddonModAssignFeedbackDelegateService extends CoreDelegate( assignId: number, userId: number, plugin: AddonModAssignPlugin, siteId?: string, - ): Promise { + ): Promise { return await this.executeFunctionOnEnabled(plugin.type, 'getDraft', [assignId, userId, siteId]); } @@ -267,7 +277,7 @@ export class AddonModAssignFeedbackDelegateService extends CoreDelegate, userId: number, ): Promise { return await this.executeFunctionOnEnabled( @@ -362,9 +372,9 @@ export class AddonModAssignFeedbackDelegateService extends CoreDelegate, siteId?: string, - ): Promise { + ): Promise { return await this.executeFunctionOnEnabled( plugin.type, 'saveDraft', diff --git a/src/addons/mod/assign/services/handlers/default-feedback.ts b/src/addons/mod/assign/services/handlers/default-feedback.ts index 02451fd2c..de0cdd3f3 100644 --- a/src/addons/mod/assign/services/handlers/default-feedback.ts +++ b/src/addons/mod/assign/services/handlers/default-feedback.ts @@ -41,8 +41,9 @@ export class AddonModAssignDefaultFeedbackHandler implements AddonModAssignFeedb * * @return Data (or promise resolved with the data). */ - getDraft(): void { + getDraft(): undefined { // Nothing to do. + return; } /** From 82cf017134b0bd3c35342ee2cbdd3b817a54d4a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Thu, 18 Feb 2021 13:46:28 +0100 Subject: [PATCH 08/17] MOBILE-3636 assign: Edit feedback modal --- .../assign/components/components.module.ts | 3 + .../edit-feedback-modal.html | 21 ++++ .../edit-feedback-modal.ts | 100 ++++++++++++++++++ 3 files changed, 124 insertions(+) create mode 100644 src/addons/mod/assign/components/edit-feedback-modal/edit-feedback-modal.html create mode 100644 src/addons/mod/assign/components/edit-feedback-modal/edit-feedback-modal.ts diff --git a/src/addons/mod/assign/components/components.module.ts b/src/addons/mod/assign/components/components.module.ts index 5f2534604..233a7c3aa 100644 --- a/src/addons/mod/assign/components/components.module.ts +++ b/src/addons/mod/assign/components/components.module.ts @@ -24,6 +24,7 @@ import { AddonModAssignIndexComponent } from './index/index'; import { AddonModAssignSubmissionComponent } from './submission/submission'; import { AddonModAssignSubmissionPluginComponent } from './submission-plugin/submission-plugin'; import { AddonModAssignFeedbackPluginComponent } from './feedback-plugin/feedback-plugin'; +import { AddonModAssignEditFeedbackModalComponent } from './edit-feedback-modal/edit-feedback-modal'; @NgModule({ declarations: [ @@ -31,6 +32,7 @@ import { AddonModAssignFeedbackPluginComponent } from './feedback-plugin/feedbac AddonModAssignSubmissionComponent, AddonModAssignSubmissionPluginComponent, AddonModAssignFeedbackPluginComponent, + AddonModAssignEditFeedbackModalComponent, ], imports: [ CommonModule, @@ -45,6 +47,7 @@ import { AddonModAssignFeedbackPluginComponent } from './feedback-plugin/feedbac AddonModAssignSubmissionComponent, AddonModAssignSubmissionPluginComponent, AddonModAssignFeedbackPluginComponent, + AddonModAssignEditFeedbackModalComponent, ], }) export class AddonModAssignComponentsModule {} diff --git a/src/addons/mod/assign/components/edit-feedback-modal/edit-feedback-modal.html b/src/addons/mod/assign/components/edit-feedback-modal/edit-feedback-modal.html new file mode 100644 index 000000000..68dcb3ce0 --- /dev/null +++ b/src/addons/mod/assign/components/edit-feedback-modal/edit-feedback-modal.html @@ -0,0 +1,21 @@ + + + + + + {{ plugin.name }} + + + + + + + + +
+ + + {{ 'core.done' | translate }} +
+
diff --git a/src/addons/mod/assign/components/edit-feedback-modal/edit-feedback-modal.ts b/src/addons/mod/assign/components/edit-feedback-modal/edit-feedback-modal.ts new file mode 100644 index 000000000..99177b118 --- /dev/null +++ b/src/addons/mod/assign/components/edit-feedback-modal/edit-feedback-modal.ts @@ -0,0 +1,100 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + + +import { Component, Input, ViewChild, ElementRef } from '@angular/core'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUtils } from '@services/utils/utils'; +import { ModalController, Translate } from '@singletons'; +import { AddonModAssignAssign, AddonModAssignPlugin, AddonModAssignSubmission } from '../../services/assign'; +import { AddonModAssignFeedbackDelegate } from '../../services/feedback-delegate'; + +/** + * Modal that allows editing a feedback plugin. + */ +@Component({ + selector: 'addon-mod-assign-edit-feedback-modal', + templateUrl: 'edit-feedback-modal.html', +}) +export class AddonModAssignEditFeedbackModalComponent { + + @Input() assign!: AddonModAssignAssign; // The assignment. + @Input() submission!: AddonModAssignSubmission; // The submission. + @Input() plugin!: AddonModAssignPlugin; // The plugin object. + @Input() userId!: number; // The user ID of the submission. + + @ViewChild('editFeedbackForm') formElement?: ElementRef; + + /** + * Close modal checking if there are changes first. + * + * @param data Data to return to the page. + */ + async closeModal(): Promise { + const changed = await this.hasDataChanged(); + if (changed) { + await CoreDomUtils.instance.showConfirm(Translate.instance.instant('core.confirmcanceledit')); + } + + CoreDomUtils.instance.triggerFormCancelledEvent(this.formElement, CoreSites.instance.getCurrentSiteId()); + + ModalController.instance.dismiss(); + } + + /** + * Done editing. + * + * @param e Click event. + */ + done(e: Event): void { + e.preventDefault(); + e.stopPropagation(); + + CoreDomUtils.instance.triggerFormSubmittedEvent(this.formElement, false, CoreSites.instance.getCurrentSiteId()); + + // Close the modal, sending the input data. + ModalController.instance.dismiss(this.getInputData()); + } + + /** + * Get the input data. + * + * @return Object with the data. + */ + protected getInputData(): Record { + return CoreDomUtils.instance.getDataFromForm(document.forms['addon-mod_assign-edit-feedback-form']); + } + + /** + * Check if data has changed. + * + * @return Promise resolved with boolean: whether the data has changed. + */ + protected async hasDataChanged(): Promise { + const changed = await CoreUtils.instance.ignoreErrors( + AddonModAssignFeedbackDelegate.instance.hasPluginDataChanged( + this.assign, + this.submission, + this.plugin, + this.getInputData(), + this.userId, + ), + true, + ); + + return !!changed; + } + +} From a4b356350bca96c60aed5911497552f7ce5df0ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Thu, 18 Feb 2021 15:34:13 +0100 Subject: [PATCH 09/17] MOBILE-3636 assign: Submissions list --- src/addons/mod/assign/assign-lazy.module.ts | 2 + .../mod/assign/components/index/index.ts | 2 +- .../components/submission/submission.ts | 3 +- .../submission-list/submission-list.html | 85 ++++ .../submission-list/submission-list.page.ts | 381 ++++++++++++++++++ src/addons/mod/assign/services/assign.ts | 11 +- 6 files changed, 481 insertions(+), 3 deletions(-) create mode 100644 src/addons/mod/assign/pages/submission-list/submission-list.html create mode 100644 src/addons/mod/assign/pages/submission-list/submission-list.page.ts diff --git a/src/addons/mod/assign/assign-lazy.module.ts b/src/addons/mod/assign/assign-lazy.module.ts index 1107997d6..00fc82ac5 100644 --- a/src/addons/mod/assign/assign-lazy.module.ts +++ b/src/addons/mod/assign/assign-lazy.module.ts @@ -17,6 +17,7 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; import { AddonModAssignComponentsModule } from './components/components.module'; import { AddonModAssignIndexPage } from './pages/index/index.page'; +import { AddonModAssignSubmissionListPage } from './pages/submission-list/submission-list.page'; const routes: Routes = [ { @@ -37,6 +38,7 @@ const routes: Routes = [ ], declarations: [ AddonModAssignIndexPage, + AddonModAssignSubmissionListPage, ], }) export class AddonModAssignLazyModule {} diff --git a/src/addons/mod/assign/components/index/index.ts b/src/addons/mod/assign/components/index/index.ts index 89040249c..1a03fe7eb 100644 --- a/src/addons/mod/assign/components/index/index.ts +++ b/src/addons/mod/assign/components/index/index.ts @@ -317,7 +317,7 @@ export class AddonModAssignIndexComponent extends CoreCourseModuleMainActivityCo if (typeof status != 'undefined') { params.status = status; } - CoreNavigator.instance.navigate('submission-list', { + CoreNavigator.instance.navigate('submission', { params, }); } diff --git a/src/addons/mod/assign/components/submission/submission.ts b/src/addons/mod/assign/components/submission/submission.ts index 0015d71e8..b01cc551d 100644 --- a/src/addons/mod/assign/components/submission/submission.ts +++ b/src/addons/mod/assign/components/submission/submission.ts @@ -26,6 +26,7 @@ import { AddonModAssignGetSubmissionStatusWSResponse, AddonModAssignSubmittedForGradingEventData, AddonModAssignSavePluginData, + AddonModAssignGradedEventData, } from '../../services/assign'; import { AddonModAssignAutoSyncData, @@ -913,7 +914,7 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy { // Invalidate and refresh data. this.invalidateAndRefresh(true); - CoreEvents.trigger(AddonModAssignProvider.GRADED_EVENT, { + CoreEvents.trigger(AddonModAssignProvider.GRADED_EVENT, { assignmentId: this.assign!.id, submissionId: this.submitId, userId: this.currentUserId, diff --git a/src/addons/mod/assign/pages/submission-list/submission-list.html b/src/addons/mod/assign/pages/submission-list/submission-list.html new file mode 100644 index 000000000..bcd21d9da --- /dev/null +++ b/src/addons/mod/assign/pages/submission-list/submission-list.html @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ 'core.groupsseparate' | translate }} + + + {{ 'core.groupsvisible' | translate }} + + + + {{groupOpt.name}} + + + + + + + + +

{{submission.userfullname}}

+

+ {{ 'addon.mod_assign.hiddenuser' | translate }}{{submission.blindid}} +

+

+ {{submission.groupname}} + + {{ 'addon.mod_assign.noteam' | translate }} + + + {{ 'addon.mod_assign.multipleteams' | translate }} + + + {{ 'addon.mod_assign.defaultteam' | translate }} + +

+
+ + {{ submission.statusTranslated }} + + + {{ submission.gradingStatusTranslationId | translate }} + +
+
+ + + + + {{ 'addon.mod_assign.notallparticipantsareshown' | translate }} + + +
+
+
+
diff --git a/src/addons/mod/assign/pages/submission-list/submission-list.page.ts b/src/addons/mod/assign/pages/submission-list/submission-list.page.ts new file mode 100644 index 000000000..a73e3adf5 --- /dev/null +++ b/src/addons/mod/assign/pages/submission-list/submission-list.page.ts @@ -0,0 +1,381 @@ +// (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 } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { IonRefresher } from '@ionic/angular'; +import { CoreGroupInfo, CoreGroups } from '@services/groups'; +import { CoreNavigator } from '@services/navigator'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUtils } from '@services/utils/utils'; +import { Translate } from '@singletons'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { + AddonModAssignAssign, + AddonModAssignSubmission, + AddonModAssignProvider, + AddonModAssign, + AddonModAssignGradedEventData, +} from '../../services/assign'; +import { AddonModAssignHelper, AddonModAssignSubmissionFormatted } from '../../services/assign-helper'; +import { AddonModAssignOffline } from '../../services/assign-offline'; +import { + AddonModAssignSyncProvider, + AddonModAssignSync, + AddonModAssignManualSyncData, + AddonModAssignAutoSyncData, +} from '../../services/assign-sync'; + +/** + * Page that displays a list of submissions of an assignment. + */ +@Component({ + selector: 'page-addon-mod-assign-submission-list', + templateUrl: 'submission-list.html', +}) +export class AddonModAssignSubmissionListPage implements OnInit, OnDestroy { + + // @ViewChild(CoreSplitViewComponent) splitviewCtrl: CoreSplitViewComponent; + + title = ''; // Title to display. + assign?: AddonModAssignAssign; // Assignment. + submissions: AddonModAssignSubmissionForList[] = []; // List of submissions + loaded = false; // Whether data has been loaded. + haveAllParticipants = true; // Whether all participants have been loaded. + selectedSubmissionId?: number; // Selected submission ID. + groupId = 0; // Group ID to show. + courseId!: number; // Course ID the assignment belongs to. + moduleId!: number; // Module ID the submission belongs to. + + groupInfo: CoreGroupInfo = { + groups: [], + separateGroups: false, + visibleGroups: false, + defaultGroupId: 0, + }; + + protected selectedStatus?: string; // The status to see. + protected gradedObserver: CoreEventObserver; // Observer to refresh data when a grade changes. + protected syncObserver: CoreEventObserver; // Observer to refresh data when the async is synchronized. + protected submissionsData: { canviewsubmissions: boolean; submissions?: AddonModAssignSubmission[] } = { + canviewsubmissions: false, + }; + + constructor( + protected route: ActivatedRoute, + ) { + // Update data if some grade changes. + this.gradedObserver = CoreEvents.on( + AddonModAssignProvider.GRADED_EVENT, + (data) => { + if ( + this.loaded && + this.assign && + data.assignmentId == this.assign.id && + data.userId == CoreSites.instance.getCurrentSiteUserId() + ) { + // Grade changed, refresh the data. + this.loaded = false; + + this.refreshAllData(true).finally(() => { + this.loaded = true; + }); + } + }, + CoreSites.instance.getCurrentSiteId(), + ); + + // Refresh data if this assign is synchronized. + const events = [AddonModAssignSyncProvider.AUTO_SYNCED, AddonModAssignSyncProvider.MANUAL_SYNCED]; + this.syncObserver = CoreEvents.onMultiple( + events, + (data) => { + if (!this.loaded || ('context' in data && data.context == 'submission-list')) { + return; + } + + this.loaded = false; + + this.refreshAllData(false).finally(() => { + this.loaded = true; + }); + }, + CoreSites.instance.getCurrentSiteId(), + ); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.moduleId = CoreNavigator.instance.getRouteNumberParam('cmId')!; + this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId')!; + + this.route.queryParams.subscribe((params) => { + this.groupId = CoreNavigator.instance.getRouteNumberParam('groupId', params) || 0; + this.selectedStatus = CoreNavigator.instance.getRouteParam('status', params); + + if (this.selectedStatus) { + if (this.selectedStatus == AddonModAssignProvider.NEED_GRADING) { + this.title = Translate.instance.instant('addon.mod_assign.numberofsubmissionsneedgrading'); + } else { + this.title = Translate.instance.instant('addon.mod_assign.submissionstatus_' + this.selectedStatus); + } + } else { + this.title = Translate.instance.instant('addon.mod_assign.numberofparticipants'); + } + this.fetchAssignment(true).finally(() => { + /* if (!this.selectedSubmissionId && this.splitviewCtrl.isOn() && this.submissions.length > 0) { + // Take first and load it. + this.loadSubmission(this.submissions[0]); + }*/ + + this.loaded = true; + }); + }); + } + + /** + * Fetch assignment data. + * + * @param sync Whether to try to synchronize data. + * @return Promise resolved when done. + */ + protected async fetchAssignment(sync?: boolean): Promise { + try { + // Get assignment data. + this.assign = await AddonModAssign.instance.getAssignment(this.courseId, this.moduleId); + + this.title = this.assign.name || this.title; + + if (sync) { + try { + // Try to synchronize data. + const result = await AddonModAssignSync.instance.syncAssign(this.assign.id); + + if (result && result.updated) { + CoreEvents.trigger( + AddonModAssignSyncProvider.MANUAL_SYNCED, + { + assignId: this.assign.id, + warnings: result.warnings, + gradesBlocked: result.gradesBlocked, + context: 'submission-list', + }, + CoreSites.instance.getCurrentSiteId(), + ); + } + } catch (error) { + // Ignore errors, probably user is offline or sync is blocked. + } + } + + // Get assignment submissions. + this.submissionsData = await AddonModAssign.instance.getSubmissions(this.assign.id, { cmId: this.assign.cmid }); + + if (!this.submissionsData.canviewsubmissions) { + // User shouldn't be able to reach here. + throw new Error('Cannot view submissions.'); + } + + // Check if groupmode is enabled to avoid showing wrong numbers. + this.groupInfo = await CoreGroups.instance.getActivityGroupInfo(this.assign.cmid, false); + + await this.setGroup(CoreGroups.instance.validateGroupId(this.groupId, this.groupInfo)); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'Error getting assigment data.'); + } + } + + /** + * Set group to see the summary. + * + * @param groupId Group ID. + * @return Resolved when done. + */ + async setGroup(groupId: number): Promise { + this.groupId = groupId; + + this.haveAllParticipants = true; + + if (!CoreSites.instance.getCurrentSite()?.wsAvailable('mod_assign_list_participants')) { + // Submissions are not displayed in Moodle 3.1 without the local plugin, see MOBILE-2968. + this.haveAllParticipants = false; + this.submissions = []; + + return; + } + + // Fetch submissions and grades. + const submissions = + await AddonModAssignHelper.instance.getSubmissionsUserData( + this.assign!, + this.submissionsData.submissions, + this.groupId, + ); + // Get assignment grades only if workflow is not enabled to check grading date. + const grades = !this.assign!.markingworkflow + ? await AddonModAssign.instance.getAssignmentGrades(this.assign!.id, { cmId: this.assign!.cmid }) + : []; + + // Filter the submissions to get only the ones with the right status and add some extra data. + const getNeedGrading = this.selectedStatus == AddonModAssignProvider.NEED_GRADING; + const searchStatus = getNeedGrading ? AddonModAssignProvider.SUBMISSION_STATUS_SUBMITTED : this.selectedStatus; + + const promises: Promise[] = []; + const showSubmissions: AddonModAssignSubmissionForList[] = []; + + submissions.forEach((submission: AddonModAssignSubmissionForList) => { + if (!searchStatus || searchStatus == submission.status) { + promises.push( + CoreUtils.instance.ignoreErrors( + AddonModAssignOffline.instance.getSubmissionGrade(this.assign!.id, submission.userid), + ).then(async (data) => { + if (getNeedGrading) { + // Only show the submissions that need to be graded. + const add = await AddonModAssign.instance.needsSubmissionToBeGraded(submission, this.assign!.id); + + if (!add) { + return; + } + } + + // Load offline grades. + const notSynced = !!data && submission.timemodified < data.timemodified; + + if (submission.gradingstatus == 'graded' && !this.assign!.markingworkflow) { + // Get the last grade of the submission. + const grade = grades + .filter((grade) => grade.userid == submission.userid) + .reduce((a, b) => (a.timemodified > b.timemodified ? a : b)); + + if (grade && grade.timemodified < submission.timemodified) { + submission.gradingstatus = AddonModAssignProvider.GRADED_FOLLOWUP_SUBMIT; + } + } + submission.statusColor = AddonModAssign.instance.getSubmissionStatusColor(submission.status); + submission.gradingColor = AddonModAssign.instance.getSubmissionGradingStatusColor( + submission.gradingstatus, + ); + + // Show submission status if not submitted for grading. + if (submission.statusColor != 'success' || !submission.gradingstatus) { + submission.statusTranslated = Translate.instance.instant( + 'addon.mod_assign.submissionstatus_' + submission.status, + ); + } else { + submission.statusTranslated = ''; + } + + if (notSynced) { + submission.gradingStatusTranslationId = 'addon.mod_assign.gradenotsynced'; + submission.gradingColor = ''; + } else if (submission.statusColor != 'danger' || submission.gradingColor != 'danger') { + // Show grading status if one of the statuses is not done. + submission.gradingStatusTranslationId = AddonModAssign.instance.getSubmissionGradingStatusTranslationId( + submission.gradingstatus, + ); + } else { + submission.gradingStatusTranslationId = ''; + } + + showSubmissions.push(submission); + + return; + }), + ); + } + }); + + await Promise.all(promises); + + this.submissions = showSubmissions; + } + + /** + * Load a certain submission. + * + * @param submission The submission to load. + */ + loadSubmission(submission: AddonModAssignSubmissionForList): void { + /* if (this.selectedSubmissionId === submission.submitid && this.splitviewCtrl.isOn()) { + // Already selected. + return; + }*/ + + this.selectedSubmissionId = submission.submitid; + + /* this.splitviewCtrl.push('AddonModAssignSubmissionReviewPage', { + courseId: this.courseId, + moduleId: this.moduleId, + submitId: submission.submitid, + blindId: submission.blindid, + });*/ + } + + /** + * Refresh all the data. + * + * @param sync Whether to try to synchronize data. + * @return Promise resolved when done. + */ + protected async refreshAllData(sync?: boolean): Promise { + const promises: Promise[] = []; + + promises.push(AddonModAssign.instance.invalidateAssignmentData(this.courseId)); + if (this.assign) { + promises.push(AddonModAssign.instance.invalidateAllSubmissionData(this.assign.id)); + promises.push(AddonModAssign.instance.invalidateAssignmentUserMappingsData(this.assign.id)); + promises.push(AddonModAssign.instance.invalidateAssignmentGradesData(this.assign.id)); + promises.push(AddonModAssign.instance.invalidateListParticipantsData(this.assign.id)); + } + + try { + await Promise.all(promises); + } finally { + this.fetchAssignment(sync); + } + } + + /** + * Refresh the list. + * + * @param refresher Refresher. + */ + refreshList(refresher?: CustomEvent): void { + this.refreshAllData(true).finally(() => { + refresher?.detail.complete(); + }); + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + this.gradedObserver?.off(); + this.syncObserver?.off(); + } + +} + +/** + * Calculated data for an assign submission. + */ +type AddonModAssignSubmissionForList = AddonModAssignSubmissionFormatted & { + statusColor?: string; // Calculated in the app. Color of the submission status. + gradingColor?: string; // Calculated in the app. Color of the submission grading status. + statusTranslated?: string; // Calculated in the app. Translated text of the submission status. + gradingStatusTranslationId?: string; // Calculated in the app. Key of the text of the submission grading status. +}; diff --git a/src/addons/mod/assign/services/assign.ts b/src/addons/mod/assign/services/assign.ts index 1c8973075..d89407a5a 100644 --- a/src/addons/mod/assign/services/assign.ts +++ b/src/addons/mod/assign/services/assign.ts @@ -328,7 +328,7 @@ export class AddonModAssignProvider { * @param status Grading status name * @return The color name. */ - getSubmissionGradingStatusColor(status: string): string { + getSubmissionGradingStatusColor(status?: string): string { if (!status) { return ''; } @@ -1863,3 +1863,12 @@ export type AddonModAssignSubmittedForGradingEventData = { submissionId: number; userId: number; }; + +/** + * Data sent by GRADED_EVENT event. + */ +export type AddonModAssignGradedEventData = { + assignmentId: number; + submissionId: number; + userId: number; +}; From 5350f715a35ba614f379c272804ae68ebf2b8427 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Fri, 19 Feb 2021 12:41:30 +0100 Subject: [PATCH 10/17] MOBILE-3636 assign: Submission review and edit pages --- src/addons/mod/assign/assign-lazy.module.ts | 45 +- .../index/addon-mod-assign-index.html | 8 +- .../mod/assign/components/index/index.ts | 23 +- .../addon-mod-assign-submission.html | 148 ++++--- .../components/submission/submission.ts | 22 +- .../addon-mod-assign-feedback-comments.html | 13 +- src/addons/mod/assign/pages/edit/edit.html | 39 ++ src/addons/mod/assign/pages/edit/edit.ts | 408 ++++++++++++++++++ .../submission-list/submission-list.html | 16 +- .../submission-list/submission-list.page.ts | 26 +- .../submission-review/submission-review.html | 29 ++ .../submission-review/submission-review.ts | 184 ++++++++ .../mod/assign/services/assign-helper.ts | 85 ++-- src/addons/mod/assign/services/assign.ts | 14 +- .../assign/submission/file/component/file.ts | 2 +- ...ddon-mod-assign-submission-onlinetext.html | 13 +- .../onlinetext/component/onlinetext.ts | 2 +- src/core/services/cron.ts | 2 +- src/core/singletons/events.ts | 7 + 19 files changed, 912 insertions(+), 174 deletions(-) create mode 100644 src/addons/mod/assign/pages/edit/edit.html create mode 100644 src/addons/mod/assign/pages/edit/edit.ts create mode 100644 src/addons/mod/assign/pages/submission-review/submission-review.html create mode 100644 src/addons/mod/assign/pages/submission-review/submission-review.ts diff --git a/src/addons/mod/assign/assign-lazy.module.ts b/src/addons/mod/assign/assign-lazy.module.ts index 00fc82ac5..90caa2792 100644 --- a/src/addons/mod/assign/assign-lazy.module.ts +++ b/src/addons/mod/assign/assign-lazy.module.ts @@ -12,22 +12,61 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { conditionalRoutes } from '@/app/app-routing.module'; import { CoreSharedModule } from '@/core/shared.module'; import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; +import { CanLeaveGuard } from '@guards/can-leave'; +import { CoreScreen } from '@services/screen'; import { AddonModAssignComponentsModule } from './components/components.module'; +import { AddonModAssignEditPage } from './pages/edit/edit'; import { AddonModAssignIndexPage } from './pages/index/index.page'; import { AddonModAssignSubmissionListPage } from './pages/submission-list/submission-list.page'; +import { AddonModAssignSubmissionReviewPage } from './pages/submission-review/submission-review'; -const routes: Routes = [ +const commonRoutes: Routes = [ { path: ':courseId/:cmId', component: AddonModAssignIndexPage, }, { - path: ':courseId/:cmId/submission-list', + path: ':courseId/:cmId/edit', + component: AddonModAssignEditPage, + canDeactivate: [CanLeaveGuard], + }, +]; + +const mobileRoutes: Routes = [ + ...commonRoutes, + { + path: ':courseId/:cmId/submission', component: AddonModAssignSubmissionListPage, }, + { + path: ':courseId/:cmId/submission/:submitId', + component: AddonModAssignSubmissionReviewPage, + canDeactivate: [CanLeaveGuard], + }, +]; + +const tabletRoutes: Routes = [ + ...commonRoutes, + { + path: ':courseId/:cmId/submission', + component: AddonModAssignSubmissionListPage, + children: [ + { + path: ':submitId', + component: AddonModAssignSubmissionReviewPage, + canDeactivate: [CanLeaveGuard], + }, + ], + }, +]; + +const routes: Routes = [ + ...conditionalRoutes(mobileRoutes, () => CoreScreen.instance.isMobile), + ...conditionalRoutes(tabletRoutes, () => CoreScreen.instance.isTablet), ]; @NgModule({ @@ -39,6 +78,8 @@ const routes: Routes = [ declarations: [ AddonModAssignIndexPage, AddonModAssignSubmissionListPage, + AddonModAssignSubmissionReviewPage, + AddonModAssignEditPage, ], }) export class AddonModAssignLazyModule {} diff --git a/src/addons/mod/assign/components/index/addon-mod-assign-index.html b/src/addons/mod/assign/components/index/addon-mod-assign-index.html index c71bacbc9..12d416cdc 100644 --- a/src/addons/mod/assign/components/index/addon-mod-assign-index.html +++ b/src/addons/mod/assign/components/index/addon-mod-assign-index.html @@ -35,7 +35,7 @@ + contextLevel="module" [contextInstanceId]="module!.id" [courseId]="courseId" (click)="expandDescription($event)"> @@ -97,7 +97,7 @@ + (click)="goToSubmissionList(submissionStatusDraft, !!summary.submissiondraftscount)">

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

{{ summary.submissiondraftscount }} @@ -107,7 +107,7 @@ + (click)="goToSubmissionList(submissionStatusSubmitted, !!summary.submissionssubmittedcount)">

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

{{ summary.submissionssubmittedcount }} @@ -136,7 +136,7 @@ + [moduleId]="module!.id"> diff --git a/src/addons/mod/assign/components/index/index.ts b/src/addons/mod/assign/components/index/index.ts index 1a03fe7eb..bf23f6e15 100644 --- a/src/addons/mod/assign/components/index/index.ts +++ b/src/addons/mod/assign/components/index/index.ts @@ -34,6 +34,7 @@ import { AddonModAssignGradedEventData, AddonModAssignProvider, AddonModAssignSubmissionGradingSummary, + AddonModAssignSubmissionSavedEventData, AddonModAssignSubmittedForGradingEventData, } from '../../services/assign'; import { AddonModAssignOffline } from '../../services/assign-offline'; @@ -106,12 +107,16 @@ export class AddonModAssignIndexComponent extends CoreCourseModuleMainActivityCo this.currentSite = CoreSites.instance.getCurrentSite(); // Listen to events. - this.savedObserver = CoreEvents.on(AddonModAssignProvider.SUBMISSION_SAVED_EVENT, (data) => { - if (this.assign && data.assignmentId == this.assign.id && data.userId == this.currentUserId) { + this.savedObserver = CoreEvents.on( + AddonModAssignProvider.SUBMISSION_SAVED_EVENT, + (data) => { + if (this.assign && data.assignmentId == this.assign.id && data.userId == this.currentUserId) { // Assignment submission saved, refresh data. - this.showLoadingAndRefresh(true, false); - } - }, this.siteId); + this.showLoadingAndRefresh(true, false); + } + }, + this.siteId, + ); this.submittedObserver = CoreEvents.on( AddonModAssignProvider.SUBMITTED_FOR_GRADING_EVENT, @@ -262,7 +267,7 @@ export class AddonModAssignIndexComponent extends CoreCourseModuleMainActivityCo * @param groupId Group ID. * @return Resolved when done. */ - async setGroup(groupId: number): Promise { + async setGroup(groupId = 0): Promise { this.group = groupId; const submissionStatus = await AddonModAssign.instance.getSubmissionStatus(this.assign!.id, { @@ -303,10 +308,10 @@ export class AddonModAssignIndexComponent extends CoreCourseModuleMainActivityCo * Go to view a list of submissions. * * @param status Status to see. - * @param count Number of submissions with the status. + * @param hasSubmissions If the status has any submission. */ - goToSubmissionList(status: string, count: number): void { - if (typeof status != 'undefined' && !count && this.showNumbers) { + goToSubmissionList(status?: string, hasSubmissions = false): void { + if (typeof status != 'undefined' && !hasSubmissions && this.showNumbers) { return; } 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 5c45c5af4..2185a1124 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 @@ -1,10 +1,10 @@ - + -

{{ user.fullname }}

+

{{ user!.fullname }}

@@ -39,10 +39,10 @@ + *ngIf="userSubmission && userSubmission!.status != statusNew && userSubmission!.timemodified">

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

-

{{ userSubmission.timemodified * 1000 | coreFormatDate }}

+

{{ userSubmission!.timemodified * 1000 | coreFormatDate }}

@@ -55,49 +55,49 @@ -

-

- +

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

-

{{ assign.duedate * 1000 | coreFormatDate }}

-

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

+

{{ assign!.duedate * 1000 | coreFormatDate }}

+

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

- +

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

-

{{ assign.cutoffdate * 1000 | coreFormatDate }}

+

{{ assign!.cutoffdate * 1000 | coreFormatDate }}

+ *ngIf="assign!.duedate && lastAttempt?.extensionduedate && !isSubmittedForGrading">

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

-

{{ lastAttempt.extensionduedate * 1000 | coreFormatDate }}

+

{{ lastAttempt!.extensionduedate * 1000 | coreFormatDate }}

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

-

+

{{ 'addon.mod_assign.outof' | translate : {'$a': {'current': currentAttempt, 'total': maxAttemptsText} } }}

-

+

{{ 'addon.mod_assign.outof' | translate : - {'$a': {'current': currentAttempt, 'total': assign.maxattempts} } }} + {'$a': {'current': currentAttempt, 'total': assign!.maxattempts} } }}

@@ -114,12 +114,12 @@ {{ 'addon.mod_assign.addsubmission' | translate }} - + {{ 'addon.mod_assign.addnewattemptfromprevious' | translate }} @@ -130,9 +130,9 @@ + *ngIf="!hasOffline && userSubmission && userSubmission!.status && + userSubmission!.status != statusNew && + userSubmission!.status != statusReopened" (click)="goToEdit()"> {{ 'addon.mod_assign.editsubmission' | translate }} @@ -176,25 +176,25 @@ - +

{{ 'addon.mod_assign.userswhoneedtosubmit' | translate: {$a: ''} }}

- - - - -

{{ user.fullname }}

-
-
+
+ + + + +

{{ user.fullname }}

+
- - - - {{ 'addon.mod_assign.hiddenuser' | translate }} {{ blindId }} - - + + + + + {{ 'addon.mod_assign.hiddenuser' | translate }} {{ blindId }} + -
+ @@ -219,12 +219,12 @@ + *ngIf="feedback?.gradefordisplay && (!isGrading || grade.method != 'simple')">

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

-

+

- +
@@ -236,7 +236,7 @@

{{ 'addon.mod_assign.gradeoutof' | translate: {$a: gradeInfo!.grade} }}

-

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

@@ -256,7 +256,7 @@

{{ outcome.name }}

+ interface="action-sheet" [disabled]="gradeInfo!.disabled"> {{grade.label}} @@ -268,18 +268,18 @@

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

-

+

{{ grade.gradebookGrade }}

-

+

{{ grade.scale[grade.gradebookGrade].label }}

-

-

+

-

- @@ -292,28 +292,30 @@
- -

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

- {{ 'addon.mod_assign.applytoteam' | translate }} + + +

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

+

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

+
- +

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

-

+

{{ 'addon.mod_assign.outof' | translate : {'$a': {'current': currentAttempt, 'total': maxAttemptsText} } }}

-

+

{{ 'addon.mod_assign.outof' | translate : - {'$a': {'current': currentAttempt, 'total': assign.maxattempts} } }} + {'$a': {'current': currentAttempt, 'total': assign!.maxattempts} } }}

{{ 'addon.mod_assign.attemptreopenmethod' | translate }}: - {{ 'addon.mod_assign.attemptreopenmethod_' + assign.attemptreopenmethod | translate }} + {{ 'addon.mod_assign.attemptreopenmethod_' + assign!.attemptreopenmethod | translate }}

@@ -324,33 +326,37 @@
- +

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

-

{{ grader.fullname }}

-

{{ feedback.gradeddate * 1000 | coreFormatDate }}

+

{{ grader!.fullname }}

+

{{ feedback!.gradeddate * 1000 | coreFormatDate }}

- +

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

-

{{ feedback.gradeddate * 1000 | coreFormatDate }}

+

{{ feedback!.gradeddate * 1000 | coreFormatDate }}

-
- -

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

- - {{ 'core.openinbrowser' | translate }} - - -
+ + + + +

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

+ + {{ 'core.openinbrowser' | translate }} + + +
+
+
@@ -360,20 +366,20 @@

{{lastAttempt!.submissiongroupname}}

-

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

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

- 1">

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

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

-

+

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

diff --git a/src/addons/mod/assign/components/submission/submission.ts b/src/addons/mod/assign/components/submission/submission.ts index b01cc551d..962bb5e05 100644 --- a/src/addons/mod/assign/components/submission/submission.ts +++ b/src/addons/mod/assign/components/submission/submission.ts @@ -55,6 +55,7 @@ import { CoreError } from '@classes/errors/error'; import { CoreGroups } from '@services/groups'; import { CoreSync } from '@services/sync'; import { AddonModAssignSubmissionPluginComponent } from '../submission-plugin/submission-plugin'; +import { AddonModAssignModuleHandlerService } from '../../services/handlers/module'; /** * Component that displays an assignment submission. @@ -73,7 +74,6 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy { @Input() moduleId!: number; // Module ID the submission belongs to. @Input() submitId!: number; // User that did the submission. @Input() blindId?: number; // Blinded user ID (if it's blinded). - @Input() showGrade = false; // Whether to display the grade tab at start. loaded = false; // Whether data has been loaded. selectedTab = 'submission'; // Tab selected on start. @@ -121,6 +121,7 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy { canSaveGrades = false; // Whether the user can save the grades. allowAddAttempt = false; // Allow adding a new attempt when grading. gradeUrl?: string; // URL to grade in browser. + isPreviousAttemptEmpty = true; // Whether the previous attempt contains an empty submission. // Some constants. statusNew = AddonModAssignProvider.SUBMISSION_STATUS_NEW; @@ -131,7 +132,6 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy { protected siteId: string; // Current site ID. protected currentUserId: number; // Current user ID. protected previousAttempt?: AddonModAssignSubmissionPreviousAttempt; // The previous attempt. - protected isPreviousAttemptEmpty = true; // Whether the previous attempt contains an empty submission. protected submissionStatusAvailable = false; // Whether we were able to retrieve the submission status. protected originalGrades: AddonModAssignSubmissionOriginalGrades = { addAttempt: false, @@ -180,7 +180,6 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy { * Component being initialized. */ ngOnInit(): void { - this.selectedTab = this.showGrade ? 'grade' : 'submission'; this.isSubmittedForGrading = !!this.submitId; this.loadData(true); @@ -343,13 +342,14 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy { * Go to the page to add or edit submission. */ goToEdit(): void { - CoreNavigator.instance.navigate('AddonModAssignEditPage', { - params: { - moduleId: this.moduleId, - courseId: this.courseId, - userId: this.submitId, - blindId: this.blindId, - } }); + CoreNavigator.instance.navigateToSitePath( + AddonModAssignModuleHandlerService.PAGE_NAME + '/' + this.courseId + '/' + this.moduleId + '/edit', + { + params: { + blindId: this.blindId, + }, + }, + ); } /** @@ -393,7 +393,7 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy { try { return AddonModAssignHelper.instance.hasFeedbackDataChanged( this.assign!, - this.userSubmission!, // @todo + this.userSubmission, this.feedback, this.submitId, ); 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 5b01232c3..e3d1fd1ab 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 @@ -24,11 +24,10 @@ - - - - + + + diff --git a/src/addons/mod/assign/pages/edit/edit.html b/src/addons/mod/assign/pages/edit/edit.html new file mode 100644 index 000000000..7acd635e3 --- /dev/null +++ b/src/addons/mod/assign/pages/edit/edit.html @@ -0,0 +1,39 @@ + + + + + + + + + + + + {{ 'core.save' | translate }} + + + + + + + + +
+ + + + + + + + + + + + + +
+
+
+
diff --git a/src/addons/mod/assign/pages/edit/edit.ts b/src/addons/mod/assign/pages/edit/edit.ts new file mode 100644 index 000000000..49198b4c4 --- /dev/null +++ b/src/addons/mod/assign/pages/edit/edit.ts @@ -0,0 +1,408 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, OnInit, OnDestroy, ViewChild, ElementRef } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { CoreError } from '@classes/errors/error'; +import { CoreIonLoadingElement } from '@classes/ion-loading'; +import { CoreFileUploaderHelper } from '@features/fileuploader/services/fileuploader-helper'; +import { CoreNavigator } from '@services/navigator'; +import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreSync } from '@services/sync'; +import { CoreDomUtils } from '@services/utils/dom'; +import { Translate } from '@singletons'; +import { CoreEventActivityDataSentData, CoreEvents } from '@singletons/events'; +import { + AddonModAssignAssign, + AddonModAssignSubmission, + AddonModAssignProvider, + AddonModAssign, + AddonModAssignSubmissionStatusOptions, + AddonModAssignGetSubmissionStatusWSResponse, + AddonModAssignSavePluginData, + AddonModAssignSubmissionSavedEventData, + AddonModAssignSubmittedForGradingEventData, +} from '../../services/assign'; +import { AddonModAssignHelper } from '../../services/assign-helper'; +import { AddonModAssignOffline } from '../../services/assign-offline'; +import { AddonModAssignSync } from '../../services/assign-sync'; + +/** + * Page that allows adding or editing an assigment submission. + */ +@Component({ + selector: 'page-addon-mod-assign-edit', + templateUrl: 'edit.html', +}) +export class AddonModAssignEditPage implements OnInit, OnDestroy { + + @ViewChild('editSubmissionForm') formElement?: ElementRef; + + title: string; // Title to display. + assign?: AddonModAssignAssign; // Assignment. + courseId!: number; // Course ID the assignment belongs to. + moduleId!: number; // Module ID the submission belongs to. + userSubmission?: AddonModAssignSubmission; // The user submission. + allowOffline = false; // Whether offline is allowed. + submissionStatement?: string; // The submission statement. + submissionStatementAccepted = false; // Whether submission statement is accepted. + loaded = false; // Whether data has been loaded. + + protected userId: number; // User doing the submission. + protected isBlind = false; // Whether blind is used. + protected editText: string; // "Edit submission" translated text. + protected saveOffline = false; // Whether to save data in offline. + protected hasOffline = false; // Whether the assignment has offline data. + protected isDestroyed = false; // Whether the component has been destroyed. + protected forceLeave = false; // To allow leaving the page without checking for changes. + + constructor( + protected route: ActivatedRoute, + ) { + this.userId = CoreSites.instance.getCurrentSiteUserId(); // Right now we can only edit current user's submissions. + this.editText = Translate.instance.instant('addon.mod_assign.editsubmission'); + this.title = this.editText; + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.moduleId = CoreNavigator.instance.getRouteNumberParam('cmId')!; + this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId')!; + this.isBlind = !!CoreNavigator.instance.getRouteNumberParam('blindId'); + + this.fetchAssignment().finally(() => { + this.loaded = true; + }); + } + + /** + * Check if we can leave the page or not. + * + * @return Resolved if we can leave it, rejected if not. + */ + async ionViewCanLeave(): Promise { + if (this.forceLeave) { + return; + } + + // Check if data has changed. + const changed = await this.hasDataChanged(); + if (changed) { + await CoreDomUtils.instance.showConfirm(Translate.instance.instant('core.confirmcanceledit')); + } + + // Nothing has changed or user confirmed to leave. Clear temporary data from plugins. + AddonModAssignHelper.instance.clearSubmissionPluginTmpData(this.assign!, this.userSubmission, this.getInputData()); + + CoreDomUtils.instance.triggerFormCancelledEvent(this.formElement, CoreSites.instance.getCurrentSiteId()); + } + + /** + * Fetch assignment data. + * + * @return Promise resolved when done. + */ + protected async fetchAssignment(): Promise { + const currentUserId = CoreSites.instance.getCurrentSiteUserId(); + + try { + // Get assignment data. + this.assign = await AddonModAssign.instance.getAssignment(this.courseId, this.moduleId); + this.title = this.assign.name || this.title; + + if (!this.isDestroyed) { + // Block the assignment. + CoreSync.instance.blockOperation(AddonModAssignProvider.COMPONENT, this.assign.id); + } + + // Wait for sync to be over (if any). + await AddonModAssignSync.instance.waitForSync(this.assign.id); + + // Get submission status. Ignore cache to get the latest data. + const options: AddonModAssignSubmissionStatusOptions = { + userId: this.userId, + isBlind: this.isBlind, + cmId: this.assign.cmid, + filter: false, + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + }; + + let submissionStatus: AddonModAssignGetSubmissionStatusWSResponse; + try { + submissionStatus = await AddonModAssign.instance.getSubmissionStatus(this.assign.id, options); + this.userSubmission = + AddonModAssign.instance.getSubmissionObjectFromAttempt(this.assign, submissionStatus.lastattempt); + } catch (error) { + // Cannot connect. Get cached data. + options.filter = true; + options.readingStrategy = CoreSitesReadingStrategy.PreferCache; + + submissionStatus = await AddonModAssign.instance.getSubmissionStatus(this.assign.id, options); + this.userSubmission = + AddonModAssign.instance.getSubmissionObjectFromAttempt(this.assign, submissionStatus.lastattempt); + + // Check if the user can edit it in offline. + const canEditOffline = + await AddonModAssignHelper.instance.canEditSubmissionOffline(this.assign, this.userSubmission); + if (!canEditOffline) { + // Submission cannot be edited in offline, reject. + this.allowOffline = false; + throw error; + } + } + + if (!submissionStatus.lastattempt?.canedit) { + // Can't edit. Reject. + throw new CoreError(Translate.instance.instant('core.nopermissions', { $a: this.editText })); + } + + this.allowOffline = true; // If offline isn't allowed we shouldn't have reached this point. + // Only show submission statement if we are editing our own submission. + if (this.assign.requiresubmissionstatement && !this.assign.submissiondrafts && this.userId == currentUserId) { + this.submissionStatement = this.assign.submissionstatement; + } else { + this.submissionStatement = undefined; + } + + try { + // Check if there's any offline data for this submission. + const offlineData = await AddonModAssignOffline.instance.getSubmission(this.assign.id, this.userId); + + this.hasOffline = offlineData?.plugindata && Object.keys(offlineData.plugindata).length > 0; + } catch { + // No offline data found. + this.hasOffline = false; + } + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'Error getting assigment data.'); + + // Leave the player. + this.leaveWithoutCheck(); + } + } + + /** + * Get the input data. + * + * @return Input data. + */ + protected getInputData(): Record { + return CoreDomUtils.instance.getDataFromForm(document.forms['addon-mod_assign-edit-form']); + } + + /** + * Check if data has changed. + * + * @return Promise resolved with boolean: whether data has changed. + */ + protected hasDataChanged(): Promise { + // Usually the hasSubmissionDataChanged call will be resolved inmediately, causing the modal to be shown just an instant. + // We'll wait a bit before showing it to prevent this "blink". + let modal: CoreIonLoadingElement; + let showModal = true; + + setTimeout(async () => { + if (showModal) { + modal = await CoreDomUtils.instance.showModalLoading(); + } + }, 100); + + const data = this.getInputData(); + + return AddonModAssignHelper.instance.hasSubmissionDataChanged(this.assign!, this.userSubmission, data).finally(() => { + if (modal) { + modal.dismiss(); + } else { + showModal = false; + } + }); + } + + /** + * Leave the view without checking for changes. + */ + protected leaveWithoutCheck(): void { + this.forceLeave = true; + CoreNavigator.instance.back(); + } + + /** + * Get data to submit based on the input data. + * + * @param inputData The input data. + * @return Promise resolved with the data to submit. + */ + protected prepareSubmissionData(inputData: Record): Promise { + // If there's offline data, always save it in offline. + this.saveOffline = this.hasOffline; + + try { + return AddonModAssignHelper.instance.prepareSubmissionPluginData( + this.assign!, + this.userSubmission, + inputData, + this.hasOffline, + ); + } catch (error) { + if (this.allowOffline && !this.saveOffline) { + // Cannot submit in online, prepare for offline usage. + this.saveOffline = true; + + return AddonModAssignHelper.instance.prepareSubmissionPluginData( + this.assign!, + this.userSubmission, + inputData, + true, + ); + } + + throw error; + } + } + + /** + * Save the submission. + */ + async save(): Promise { + // Check if data has changed. + const changed = await this.hasDataChanged(); + if (!changed) { + // Nothing to save, just go back. + this.leaveWithoutCheck(); + + return; + } + try { + await this.saveSubmission(); + this.leaveWithoutCheck(); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'Error saving submission.'); + } + } + + /** + * Save the submission. + * + * @return Promise resolved when done. + */ + protected async saveSubmission(): Promise { + const inputData = this.getInputData(); + + if (this.submissionStatement && (!inputData.submissionstatement || inputData.submissionstatement === 'false')) { + throw Translate.instance.instant('addon.mod_assign.acceptsubmissionstatement'); + } + + let modal = await CoreDomUtils.instance.showModalLoading(); + let size = -1; + + // Get size to ask for confirmation. + try { + size = await AddonModAssignHelper.instance.getSubmissionSizeForEdit(this.assign!, this.userSubmission!, inputData); + } catch (error) { + // Error calculating size, return -1. + size = -1; + } + + modal.dismiss(); + + try { + // Confirm action. + await CoreFileUploaderHelper.instance.confirmUploadFile(size, true, this.allowOffline); + + modal = await CoreDomUtils.instance.showModalLoading('core.sending', true); + + const pluginData = await this.prepareSubmissionData(inputData); + if (!Object.keys(pluginData).length) { + // Nothing to save. + return; + } + + let sent: boolean; + + if (this.saveOffline) { + // Save submission in offline. + sent = false; + await AddonModAssignOffline.instance.saveSubmission( + this.assign!.id, + this.courseId, + pluginData, + this.userSubmission!.timemodified, + !this.assign!.submissiondrafts, + this.userId, + ); + } else { + // Try to send it to server. + sent = await AddonModAssign.instance.saveSubmission( + this.assign!.id, + this.courseId, + pluginData, + this.allowOffline, + this.userSubmission!.timemodified, + !!this.assign!.submissiondrafts, + this.userId, + ); + } + + // Clear temporary data from plugins. + AddonModAssignHelper.instance.clearSubmissionPluginTmpData(this.assign!, this.userSubmission, inputData); + + if (sent) { + CoreEvents.trigger(CoreEvents.ACTIVITY_DATA_SENT, { module: 'assign' }); + } + + // Submission saved, trigger events. + CoreDomUtils.instance.triggerFormSubmittedEvent(this.formElement, sent, CoreSites.instance.getCurrentSiteId()); + + CoreEvents.trigger( + AddonModAssignProvider.SUBMISSION_SAVED_EVENT, + { + assignmentId: this.assign!.id, + submissionId: this.userSubmission!.id, + userId: this.userId, + }, + CoreSites.instance.getCurrentSiteId(), + ); + + if (!this.assign!.submissiondrafts) { + // No drafts allowed, so it was submitted. Trigger event. + CoreEvents.trigger( + AddonModAssignProvider.SUBMITTED_FOR_GRADING_EVENT, + { + assignmentId: this.assign!.id, + submissionId: this.userSubmission!.id, + userId: this.userId, + }, + CoreSites.instance.getCurrentSiteId(), + ); + } + } finally { + modal.dismiss(); + } + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + this.isDestroyed = true; + + // Unblock the assignment. + if (this.assign) { + CoreSync.instance.unblockOperation(AddonModAssignProvider.COMPONENT, this.assign.id); + } + } + +} diff --git a/src/addons/mod/assign/pages/submission-list/submission-list.html b/src/addons/mod/assign/pages/submission-list/submission-list.html index bcd21d9da..4968d47b5 100644 --- a/src/addons/mod/assign/pages/submission-list/submission-list.html +++ b/src/addons/mod/assign/pages/submission-list/submission-list.html @@ -61,15 +61,15 @@ {{ 'addon.mod_assign.defaultteam' | translate }}

+ + {{ submission.statusTranslated }} + + + {{ submission.gradingStatusTranslationId | translate }} + - - {{ submission.statusTranslated }} - - - {{ submission.gradingStatusTranslationId | translate }} -
diff --git a/src/addons/mod/assign/pages/submission-list/submission-list.page.ts b/src/addons/mod/assign/pages/submission-list/submission-list.page.ts index a73e3adf5..84ab9e0ac 100644 --- a/src/addons/mod/assign/pages/submission-list/submission-list.page.ts +++ b/src/addons/mod/assign/pages/submission-list/submission-list.page.ts @@ -17,6 +17,7 @@ import { ActivatedRoute } from '@angular/router'; import { IonRefresher } from '@ionic/angular'; import { CoreGroupInfo, CoreGroups } from '@services/groups'; import { CoreNavigator } from '@services/navigator'; +import { CoreScreen } from '@services/screen'; import { CoreSites } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreUtils } from '@services/utils/utils'; @@ -37,6 +38,7 @@ import { AddonModAssignManualSyncData, AddonModAssignAutoSyncData, } from '../../services/assign-sync'; +import { AddonModAssignModuleHandlerService } from '../../services/handlers/module'; /** * Page that displays a list of submissions of an assignment. @@ -137,10 +139,10 @@ export class AddonModAssignSubmissionListPage implements OnInit, OnDestroy { this.title = Translate.instance.instant('addon.mod_assign.numberofparticipants'); } this.fetchAssignment(true).finally(() => { - /* if (!this.selectedSubmissionId && this.splitviewCtrl.isOn() && this.submissions.length > 0) { + if (!this.selectedSubmissionId && CoreScreen.instance.isTablet && this.submissions.length > 0) { // Take first and load it. this.loadSubmission(this.submissions[0]); - }*/ + } this.loaded = true; }); @@ -153,7 +155,7 @@ export class AddonModAssignSubmissionListPage implements OnInit, OnDestroy { * @param sync Whether to try to synchronize data. * @return Promise resolved when done. */ - protected async fetchAssignment(sync?: boolean): Promise { + protected async fetchAssignment(sync = false): Promise { try { // Get assignment data. this.assign = await AddonModAssign.instance.getAssignment(this.courseId, this.moduleId); @@ -310,19 +312,21 @@ export class AddonModAssignSubmissionListPage implements OnInit, OnDestroy { * @param submission The submission to load. */ loadSubmission(submission: AddonModAssignSubmissionForList): void { - /* if (this.selectedSubmissionId === submission.submitid && this.splitviewCtrl.isOn()) { + if (this.selectedSubmissionId === submission.submitid) { // Already selected. return; - }*/ + } this.selectedSubmissionId = submission.submitid; - /* this.splitviewCtrl.push('AddonModAssignSubmissionReviewPage', { - courseId: this.courseId, - moduleId: this.moduleId, - submitId: submission.submitid, - blindId: submission.blindid, - });*/ + CoreNavigator.instance.navigateToSitePath( + AddonModAssignModuleHandlerService.PAGE_NAME+'/'+this.courseId+'/'+this.moduleId+'/submission/'+submission.submitid, + { + params: { + blindId: submission.blindid, + }, + }, + ); } /** diff --git a/src/addons/mod/assign/pages/submission-review/submission-review.html b/src/addons/mod/assign/pages/submission-review/submission-review.html new file mode 100644 index 000000000..6b010c098 --- /dev/null +++ b/src/addons/mod/assign/pages/submission-review/submission-review.html @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + {{ 'core.done' | translate }} + + + + + + + + + + + + + diff --git a/src/addons/mod/assign/pages/submission-review/submission-review.ts b/src/addons/mod/assign/pages/submission-review/submission-review.ts new file mode 100644 index 000000000..bca89ffa0 --- /dev/null +++ b/src/addons/mod/assign/pages/submission-review/submission-review.ts @@ -0,0 +1,184 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, OnInit, ViewChild } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { CoreCourse } from '@features/course/services/course'; +import { IonRefresher } from '@ionic/angular'; +import { CoreNavigator } from '@services/navigator'; +import { CoreScreen } from '@services/screen'; +import { CoreDomUtils } from '@services/utils/dom'; +import { AddonModAssignSubmissionComponent } from '../../components/submission/submission'; +import { AddonModAssign, AddonModAssignAssign } from '../../services/assign'; + +/** + * Page that displays a submission. + */ +@Component({ + selector: 'page-addon-mod-assign-submission-review', + templateUrl: 'submission-review.html', +}) +export class AddonModAssignSubmissionReviewPage implements OnInit { + + @ViewChild(AddonModAssignSubmissionComponent) submissionComponent?: AddonModAssignSubmissionComponent; + + title = ''; // Title to display. + moduleId!: number; // Module ID the submission belongs to. + courseId!: number; // Course ID the assignment belongs to. + submitId!: number; // User that did the submission. + blindId?: number; // Blinded user ID (if it's blinded). + loaded = false; // Whether data has been loaded. + canSaveGrades = false; // Whether the user can save grades. + + protected assign?: AddonModAssignAssign; // The assignment the submission belongs to. + protected blindMarking = false; // Whether it uses blind marking. + protected forceLeave = false; // To allow leaving the page without checking for changes. + + + constructor( + protected route: ActivatedRoute, + ) { } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.route.queryParams.subscribe((params) => { + this.moduleId = CoreNavigator.instance.getRouteNumberParam('cmId')!; + this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId')!; + this.submitId = CoreNavigator.instance.getRouteNumberParam('submitId') || 0; + this.blindId = CoreNavigator.instance.getRouteNumberParam('blindId', params); + + this.fetchSubmission().finally(() => { + this.loaded = true; + }); + }); + } + + /** + * Check if we can leave the page or not. + * + * @return Resolved if we can leave it, rejected if not. + */ + ionViewCanLeave(): boolean | Promise { + if (!this.submissionComponent || this.forceLeave) { + return true; + } + + // Check if data has changed. + return this.submissionComponent.canLeave(); + } + + /** + * User entered the page. + */ + ionViewDidEnter(): void { + this.submissionComponent?.ionViewDidEnter(); + } + + /** + * User left the page. + */ + ionViewDidLeave(): void { + this.submissionComponent?.ionViewDidLeave(); + } + + /** + * Get the submission. + * + * @return Promise resolved when done. + */ + protected async fetchSubmission(): Promise { + this.assign = await AddonModAssign.instance.getAssignment(this.courseId, this.moduleId); + this.title = this.assign.name; + + this.blindMarking = !!this.assign.blindmarking && !this.assign.revealidentities; + + const gradeInfo = await CoreCourse.instance.getModuleBasicGradeInfo(this.moduleId); + if (!gradeInfo) { + return; + } + + // Grades can be saved if simple grading. + if (gradeInfo.advancedgrading && gradeInfo.advancedgrading[0] && + typeof gradeInfo.advancedgrading[0].method != 'undefined') { + + const method = gradeInfo.advancedgrading[0].method || 'simple'; + this.canSaveGrades = method == 'simple'; + } else { + this.canSaveGrades = true; + } + } + + /** + * Refresh all the data. + * + * @return Promise resolved when done. + */ + protected async refreshAllData(): Promise { + const promises: Promise[] = []; + + promises.push(AddonModAssign.instance.invalidateAssignmentData(this.courseId)); + if (this.assign) { + promises.push(AddonModAssign.instance.invalidateSubmissionData(this.assign.id)); + promises.push(AddonModAssign.instance.invalidateAssignmentUserMappingsData(this.assign.id)); + promises.push(AddonModAssign.instance.invalidateSubmissionStatusData( + this.assign.id, + this.submitId, + undefined, + this.blindMarking, + )); + } + + try { + await Promise.all(promises); + } finally { + this.submissionComponent && this.submissionComponent.invalidateAndRefresh(true); + + await this.fetchSubmission(); + } + } + + /** + * Refresh the data. + * + * @param refresher Refresher. + */ + refreshSubmission(refresher?: CustomEvent): void { + this.refreshAllData().finally(() => { + refresher?.detail.complete(); + }); + } + + /** + * Submit a grade and feedback. + */ + async submitGrade(): Promise { + if (!this.submissionComponent) { + return; + } + + try { + await this.submissionComponent.submitGrade(); + // Grade submitted, leave the view if not in tablet. + if (!CoreScreen.instance.isTablet) { + this.forceLeave = true; + CoreNavigator.instance.back(); + } + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.error', true); + } + } + +} diff --git a/src/addons/mod/assign/services/assign-helper.ts b/src/addons/mod/assign/services/assign-helper.ts index 6db331096..0e797419d 100644 --- a/src/addons/mod/assign/services/assign-helper.ts +++ b/src/addons/mod/assign/services/assign-helper.ts @@ -49,7 +49,7 @@ export class AddonModAssignHelperProvider { * @param submission Submission. * @return Whether it can be edited offline. */ - async canEditSubmissionOffline(assign: AddonModAssignAssign, submission: AddonModAssignSubmission): Promise { + async canEditSubmissionOffline(assign: AddonModAssignAssign, submission?: AddonModAssignSubmission): Promise { if (!submission) { return false; } @@ -85,7 +85,15 @@ export class AddonModAssignHelperProvider { * @param submission Submission to clear the data for. * @param inputData Data entered in the submission form. */ - clearSubmissionPluginTmpData(assign: AddonModAssignAssign, submission: AddonModAssignSubmission, inputData: any): void { + clearSubmissionPluginTmpData( + assign: AddonModAssignAssign, + submission: AddonModAssignSubmission | undefined, + inputData: Record, + ): void { + if (!submission) { + return; + } + submission.plugins?.forEach((plugin) => { AddonModAssignSubmissionDelegate.instance.clearTmpData(assign, submission, plugin, inputData); }); @@ -356,7 +364,7 @@ export class AddonModAssignHelperProvider { async getSubmissionSizeForEdit( assign: AddonModAssignAssign, submission: AddonModAssignSubmission, - inputData: any, + inputData: Record, ): Promise { let totalSize = 0; @@ -492,27 +500,28 @@ export class AddonModAssignHelperProvider { */ async hasFeedbackDataChanged( assign: AddonModAssignAssign, - submission: AddonModAssignSubmission | AddonModAssignSubmissionFormatted, + submission: AddonModAssignSubmission | AddonModAssignSubmissionFormatted | undefined, feedback: AddonModAssignSubmissionFeedback, userId: number, ): Promise { + if (!submission || !feedback.plugins) { + return false; + } let hasChanged = false; - const promises = feedback.plugins - ? feedback.plugins.map((plugin) => - this.prepareFeedbackPluginData(assign.id, userId, feedback).then(async (inputData) => { - const changed = await CoreUtils.instance.ignoreErrors( - AddonModAssignFeedbackDelegate.instance.hasPluginDataChanged(assign, submission, plugin, inputData, userId), - false, - ); - if (changed) { - hasChanged = true; - } + const promises = feedback.plugins.map((plugin) => + this.prepareFeedbackPluginData(assign.id, userId, feedback).then(async (inputData) => { + const changed = await CoreUtils.instance.ignoreErrors( + AddonModAssignFeedbackDelegate.instance.hasPluginDataChanged(assign, submission, plugin, inputData, userId), + false, + ); + if (changed) { + hasChanged = true; + } - return; - })) - : []; + return; + })); await CoreUtils.instance.allPromises(promises); @@ -529,9 +538,13 @@ export class AddonModAssignHelperProvider { */ async hasSubmissionDataChanged( assign: AddonModAssignAssign, - submission: AddonModAssignSubmission, - inputData: any, + submission: AddonModAssignSubmission | undefined, + inputData: Record, ): Promise { + if (!submission) { + return false; + } + let hasChanged = false; const promises = submission.plugins @@ -544,7 +557,7 @@ export class AddonModAssignHelperProvider { return; }).catch(() => { - // Ignore errors. + // Ignore errors. })) : []; @@ -591,23 +604,25 @@ export class AddonModAssignHelperProvider { */ async prepareSubmissionPluginData( assign: AddonModAssignAssign, - submission: AddonModAssignSubmission, - inputData: any, + submission: AddonModAssignSubmission | undefined, + inputData: Record, offline = false, - ): Promise { + ): Promise { - const pluginData = {}; - const promises = submission.plugins - ? submission.plugins.map((plugin) => - AddonModAssignSubmissionDelegate.instance.preparePluginSubmissionData( - assign, - submission, - plugin, - inputData, - pluginData, - offline, - )) - : []; + if (!submission || !submission.plugins) { + return {}; + } + + const pluginData: AddonModAssignSavePluginData = {}; + const promises = submission.plugins.map((plugin) => + AddonModAssignSubmissionDelegate.instance.preparePluginSubmissionData( + assign, + submission, + plugin, + inputData, + pluginData, + offline, + )); await Promise.all(promises); diff --git a/src/addons/mod/assign/services/assign.ts b/src/addons/mod/assign/services/assign.ts index d89407a5a..b1d63013b 100644 --- a/src/addons/mod/assign/services/assign.ts +++ b/src/addons/mod/assign/services/assign.ts @@ -30,6 +30,7 @@ import { CoreUtils } from '@services/utils/utils'; import { AddonModAssignOffline } from './assign-offline'; import { AddonModAssignSubmissionDelegate } from './submission-delegate'; import { CoreComments } from '@features/comments/services/comments'; +import { AddonModAssignSubmissionFormatted } from './assign-helper'; const ROOT_CACHE_KEY = 'mmaModAssign:'; @@ -1011,7 +1012,7 @@ export class AddonModAssignProvider { * @param assignId Assignment ID. * @return Promise resolved with boolean: whether it needs to be graded or not. */ - async needsSubmissionToBeGraded(submission: any, assignId: number): Promise { + async needsSubmissionToBeGraded(submission: AddonModAssignSubmissionFormatted, assignId: number): Promise { if (!submission.gradingstatus) { // This should not happen, but it's better to show rather than not showing any of the submissions. return true; @@ -1864,11 +1865,12 @@ export type AddonModAssignSubmittedForGradingEventData = { userId: number; }; +/** + * Data sent by SUBMISSION_SAVED_EVENT event. + */ +export type AddonModAssignSubmissionSavedEventData = AddonModAssignSubmittedForGradingEventData; + /** * Data sent by GRADED_EVENT event. */ -export type AddonModAssignGradedEventData = { - assignmentId: number; - submissionId: number; - userId: number; -}; +export type AddonModAssignGradedEventData = AddonModAssignSubmittedForGradingEventData; diff --git a/src/addons/mod/assign/submission/file/component/file.ts b/src/addons/mod/assign/submission/file/component/file.ts index 2dff6e98a..c6e545e11 100644 --- a/src/addons/mod/assign/submission/file/component/file.ts +++ b/src/addons/mod/assign/submission/file/component/file.ts @@ -41,7 +41,7 @@ export class AddonModAssignSubmissionFileComponent extends AddonModAssignSubmiss /** * Component being initialized. */ - async nOnInit(): Promise { + async ngOnInit(): Promise { // Get the offline data. const filesData = await CoreUtils.instance.ignoreErrors( AddonModAssignOffline.instance.getSubmission(this.assign.id), diff --git a/src/addons/mod/assign/submission/onlinetext/component/addon-mod-assign-submission-onlinetext.html b/src/addons/mod/assign/submission/onlinetext/component/addon-mod-assign-submission-onlinetext.html index cc3c5c014..4883118eb 100644 --- a/src/addons/mod/assign/submission/onlinetext/component/addon-mod-assign-submission-onlinetext.html +++ b/src/addons/mod/assign/submission/onlinetext/component/addon-mod-assign-submission-onlinetext.html @@ -24,12 +24,11 @@
- - - - + + + diff --git a/src/addons/mod/assign/submission/onlinetext/component/onlinetext.ts b/src/addons/mod/assign/submission/onlinetext/component/onlinetext.ts index b8a5b38ac..0a152e314 100644 --- a/src/addons/mod/assign/submission/onlinetext/component/onlinetext.ts +++ b/src/addons/mod/assign/submission/onlinetext/component/onlinetext.ts @@ -56,7 +56,7 @@ export class AddonModAssignSubmissionOnlineTextComponent extends AddonModAssignS /** * Component being initialized. */ - async nOnInit(): Promise { + async ngOnInit(): Promise { // Get the text. Check if we have anything offline. const offlineData = await CoreUtils.instance.ignoreErrors( AddonModAssignOffline.instance.getSubmission(this.assign.id), diff --git a/src/core/services/cron.ts b/src/core/services/cron.ts index 2ccc6e868..b8eea95cb 100644 --- a/src/core/services/cron.ts +++ b/src/core/services/cron.ts @@ -267,7 +267,7 @@ export class CoreCronDelegateService { * @return True if handler uses network or not defined, false otherwise. */ protected handlerUsesNetwork(name: string): boolean { - if (!this.handlers[name] || this.handlers[name].usesNetwork) { + if (!this.handlers[name] || !this.handlers[name].usesNetwork) { // Invalid, return default. return true; } diff --git a/src/core/singletons/events.ts b/src/core/singletons/events.ts index 91d5a072d..030b21ce7 100644 --- a/src/core/singletons/events.ts +++ b/src/core/singletons/events.ts @@ -298,3 +298,10 @@ export type CoreEventSectionStatusChangedData = CoreEventSiteData & { courseId: number; sectionId?: number; }; + +/** + * Data passed to ACTIVITY_DATA_SENT event. + */ +export type CoreEventActivityDataSentData = CoreEventSiteData & { + module: string; +}; From 5c56bf0635e8c2bcc150d03d27465575a6f3725d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Mon, 22 Feb 2021 15:58:20 +0100 Subject: [PATCH 11/17] MOBILE-3636 core: Use start to watch splitview outlet on list manager --- src/addons/badges/pages/user-badges/user-badges.ts | 3 +-- src/core/classes/page-items-list-manager.ts | 6 +++++- src/core/features/grades/pages/course/course.page.ts | 3 +-- src/core/features/grades/pages/courses/courses.ts | 3 +-- src/core/features/settings/pages/index/index.ts | 3 +-- src/core/features/user/pages/participants/participants.ts | 3 +-- 6 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/addons/badges/pages/user-badges/user-badges.ts b/src/addons/badges/pages/user-badges/user-badges.ts index 7144fec28..5114df908 100644 --- a/src/addons/badges/pages/user-badges/user-badges.ts +++ b/src/addons/badges/pages/user-badges/user-badges.ts @@ -51,8 +51,7 @@ export class AddonBadgesUserBadgesPage implements AfterViewInit, OnDestroy { async ngAfterViewInit(): Promise { await this.fetchInitialBadges(); - this.badges.watchSplitViewOutlet(this.splitView); - this.badges.start(); + this.badges.start(this.splitView); } /** diff --git a/src/core/classes/page-items-list-manager.ts b/src/core/classes/page-items-list-manager.ts index 1c226eaa6..0810ede23 100644 --- a/src/core/classes/page-items-list-manager.ts +++ b/src/core/classes/page-items-list-manager.ts @@ -55,8 +55,12 @@ export abstract class CorePageItemsListManager { /** * Process page started operations. + * + * @param splitView Split view component. */ - async start(): Promise { + async start(splitView: CoreSplitViewComponent): Promise { + this.watchSplitViewOutlet(splitView); + // Calculate current selected item. const route = CoreNavigator.instance.getCurrentRoute({ pageComponent: this.pageComponent }); if (route !== null && route.firstChild) { diff --git a/src/core/features/grades/pages/course/course.page.ts b/src/core/features/grades/pages/course/course.page.ts index b0cb64d33..df99882d6 100644 --- a/src/core/features/grades/pages/course/course.page.ts +++ b/src/core/features/grades/pages/course/course.page.ts @@ -63,8 +63,7 @@ export class CoreGradesCoursePage implements AfterViewInit, OnDestroy { async ngAfterViewInit(): Promise { await this.fetchInitialGrades(); - this.grades.watchSplitViewOutlet(this.splitView); - this.grades.start(); + this.grades.start(this.splitView); } /** diff --git a/src/core/features/grades/pages/courses/courses.ts b/src/core/features/grades/pages/courses/courses.ts index 013614f43..76827f588 100644 --- a/src/core/features/grades/pages/courses/courses.ts +++ b/src/core/features/grades/pages/courses/courses.ts @@ -42,8 +42,7 @@ export class CoreGradesCoursesPage implements OnDestroy, AfterViewInit { async ngAfterViewInit(): Promise { await this.fetchInitialCourses(); - this.courses.watchSplitViewOutlet(this.splitView); - this.courses.start(); + this.courses.start(this.splitView); } /** diff --git a/src/core/features/settings/pages/index/index.ts b/src/core/features/settings/pages/index/index.ts index d373ad9a2..00e452db5 100644 --- a/src/core/features/settings/pages/index/index.ts +++ b/src/core/features/settings/pages/index/index.ts @@ -33,8 +33,7 @@ export class CoreSettingsIndexPage implements AfterViewInit, OnDestroy { */ ngAfterViewInit(): void { this.sections.setItems(CoreSettingsConstants.SECTIONS); - this.sections.watchSplitViewOutlet(this.splitView); - this.sections.start(); + this.sections.start(this.splitView); } /** diff --git a/src/core/features/user/pages/participants/participants.ts b/src/core/features/user/pages/participants/participants.ts index 7ec5932b1..ee568a417 100644 --- a/src/core/features/user/pages/participants/participants.ts +++ b/src/core/features/user/pages/participants/participants.ts @@ -62,8 +62,7 @@ export class CoreUserParticipantsPage implements OnInit, AfterViewInit, OnDestro async ngAfterViewInit(): Promise { await this.fetchInitialParticipants(); - this.participants.watchSplitViewOutlet(this.splitView); - this.participants.start(); + this.participants.start(this.splitView); } /** From d6169879fdf200ca4ab265a2306a78910356b597 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Mon, 22 Feb 2021 15:58:52 +0100 Subject: [PATCH 12/17] MOBILE-3636 assign: Use list manager on submission list --- .../submission-list/submission-list.html | 12 +- .../submission-list/submission-list.page.ts | 114 ++++++++++-------- 2 files changed, 67 insertions(+), 59 deletions(-) diff --git a/src/addons/mod/assign/pages/submission-list/submission-list.html b/src/addons/mod/assign/pages/submission-list/submission-list.html index 4968d47b5..e88b89eac 100644 --- a/src/addons/mod/assign/pages/submission-list/submission-list.html +++ b/src/addons/mod/assign/pages/submission-list/submission-list.html @@ -14,11 +14,11 @@ - + - - + @@ -38,9 +38,9 @@
- - + +

{{submission.userfullname}}

diff --git a/src/addons/mod/assign/pages/submission-list/submission-list.page.ts b/src/addons/mod/assign/pages/submission-list/submission-list.page.ts index 84ab9e0ac..2e3e3e435 100644 --- a/src/addons/mod/assign/pages/submission-list/submission-list.page.ts +++ b/src/addons/mod/assign/pages/submission-list/submission-list.page.ts @@ -12,17 +12,19 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, OnInit, OnDestroy } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; +import { Component, OnDestroy, AfterViewInit, ViewChild } from '@angular/core'; +import { ActivatedRoute, ActivatedRouteSnapshot, Params } from '@angular/router'; +import { CorePageItemsListManager } from '@classes/page-items-list-manager'; +import { CoreSplitViewComponent } from '@components/split-view/split-view'; import { IonRefresher } from '@ionic/angular'; import { CoreGroupInfo, CoreGroups } from '@services/groups'; import { CoreNavigator } from '@services/navigator'; -import { CoreScreen } from '@services/screen'; import { CoreSites } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreUtils } from '@services/utils/utils'; import { Translate } from '@singletons'; import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { CoreObject } from '@singletons/object'; import { AddonModAssignAssign, AddonModAssignSubmission, @@ -38,7 +40,6 @@ import { AddonModAssignManualSyncData, AddonModAssignAutoSyncData, } from '../../services/assign-sync'; -import { AddonModAssignModuleHandlerService } from '../../services/handlers/module'; /** * Page that displays a list of submissions of an assignment. @@ -47,16 +48,15 @@ import { AddonModAssignModuleHandlerService } from '../../services/handlers/modu selector: 'page-addon-mod-assign-submission-list', templateUrl: 'submission-list.html', }) -export class AddonModAssignSubmissionListPage implements OnInit, OnDestroy { +export class AddonModAssignSubmissionListPage implements AfterViewInit, OnDestroy { - // @ViewChild(CoreSplitViewComponent) splitviewCtrl: CoreSplitViewComponent; + @ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent; title = ''; // Title to display. assign?: AddonModAssignAssign; // Assignment. - submissions: AddonModAssignSubmissionForList[] = []; // List of submissions + submissions: AddonModAssignSubmissionListManager; // List of submissions loaded = false; // Whether data has been loaded. haveAllParticipants = true; // Whether all participants have been loaded. - selectedSubmissionId?: number; // Selected submission ID. groupId = 0; // Group ID to show. courseId!: number; // Course ID the assignment belongs to. moduleId!: number; // Module ID the submission belongs to. @@ -78,6 +78,8 @@ export class AddonModAssignSubmissionListPage implements OnInit, OnDestroy { constructor( protected route: ActivatedRoute, ) { + this.submissions = new AddonModAssignSubmissionListManager(AddonModAssignSubmissionListPage); + // Update data if some grade changes. this.gradedObserver = CoreEvents.on( AddonModAssignProvider.GRADED_EVENT, @@ -121,31 +123,24 @@ export class AddonModAssignSubmissionListPage implements OnInit, OnDestroy { /** * Component being initialized. */ - ngOnInit(): void { + ngAfterViewInit(): void { this.moduleId = CoreNavigator.instance.getRouteNumberParam('cmId')!; this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId')!; + this.groupId = CoreNavigator.instance.getRouteNumberParam('groupId') || 0; + this.selectedStatus = CoreNavigator.instance.getRouteParam('status'); - this.route.queryParams.subscribe((params) => { - this.groupId = CoreNavigator.instance.getRouteNumberParam('groupId', params) || 0; - this.selectedStatus = CoreNavigator.instance.getRouteParam('status', params); - - if (this.selectedStatus) { - if (this.selectedStatus == AddonModAssignProvider.NEED_GRADING) { - this.title = Translate.instance.instant('addon.mod_assign.numberofsubmissionsneedgrading'); - } else { - this.title = Translate.instance.instant('addon.mod_assign.submissionstatus_' + this.selectedStatus); - } + if (this.selectedStatus) { + if (this.selectedStatus == AddonModAssignProvider.NEED_GRADING) { + this.title = Translate.instance.instant('addon.mod_assign.numberofsubmissionsneedgrading'); } else { - this.title = Translate.instance.instant('addon.mod_assign.numberofparticipants'); + this.title = Translate.instance.instant('addon.mod_assign.submissionstatus_' + this.selectedStatus); } - this.fetchAssignment(true).finally(() => { - if (!this.selectedSubmissionId && CoreScreen.instance.isTablet && this.submissions.length > 0) { - // Take first and load it. - this.loadSubmission(this.submissions[0]); - } - - this.loaded = true; - }); + } else { + this.title = Translate.instance.instant('addon.mod_assign.numberofparticipants'); + } + this.fetchAssignment(true).finally(() => { + this.loaded = true; + this.submissions.start(this.splitView); }); } @@ -215,7 +210,7 @@ export class AddonModAssignSubmissionListPage implements OnInit, OnDestroy { if (!CoreSites.instance.getCurrentSite()?.wsAvailable('mod_assign_list_participants')) { // Submissions are not displayed in Moodle 3.1 without the local plugin, see MOBILE-2968. this.haveAllParticipants = false; - this.submissions = []; + this.submissions.resetItems(); return; } @@ -303,30 +298,7 @@ export class AddonModAssignSubmissionListPage implements OnInit, OnDestroy { await Promise.all(promises); - this.submissions = showSubmissions; - } - - /** - * Load a certain submission. - * - * @param submission The submission to load. - */ - loadSubmission(submission: AddonModAssignSubmissionForList): void { - if (this.selectedSubmissionId === submission.submitid) { - // Already selected. - return; - } - - this.selectedSubmissionId = submission.submitid; - - CoreNavigator.instance.navigateToSitePath( - AddonModAssignModuleHandlerService.PAGE_NAME+'/'+this.courseId+'/'+this.moduleId+'/submission/'+submission.submitid, - { - params: { - blindId: submission.blindid, - }, - }, - ); + this.submissions.setItems(showSubmissions); } /** @@ -370,6 +342,42 @@ export class AddonModAssignSubmissionListPage implements OnInit, OnDestroy { ngOnDestroy(): void { this.gradedObserver?.off(); this.syncObserver?.off(); + this.submissions.destroy(); + } + +} + + +/** + * Helper class to manage submissions. + */ +class AddonModAssignSubmissionListManager extends CorePageItemsListManager { + + constructor(pageComponent: unknown) { + super(pageComponent); + } + + /** + * @inheritdoc + */ + protected getItemPath(submission: AddonModAssignSubmissionForList): string { + return String(submission.submitid); + } + + /** + * @inheritdoc + */ + protected getItemQueryParams(submission: AddonModAssignSubmissionForList): Params { + return CoreObject.withoutEmpty({ + blindId: submission.blindid, + }); + } + + /** + * @inheritdoc + */ + protected getSelectedItemPath(route: ActivatedRouteSnapshot): string | null { + return route.params.submitId ?? null; } } From f57abbd102c797374c3fc9569683384d875f2427 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Mon, 22 Feb 2021 16:53:40 +0100 Subject: [PATCH 13/17] MOBILE-3636 assign: Fix feedback modules --- src/addons/mod/assign/assign.module.ts | 2 ++ .../components/feedback-plugin/feedback-plugin.ts | 6 +++--- .../assign/feedback/comments/comments.module.ts | 3 +-- .../assign/feedback/comments/services/handler.ts | 14 ++++++++------ 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/addons/mod/assign/assign.module.ts b/src/addons/mod/assign/assign.module.ts index fbf319570..fdbf18fd4 100644 --- a/src/addons/mod/assign/assign.module.ts +++ b/src/addons/mod/assign/assign.module.ts @@ -22,6 +22,7 @@ import { CorePushNotificationsDelegate } from '@features/pushnotifications/servi import { CoreCronDelegate } from '@services/cron'; import { CORE_SITE_SCHEMAS } from '@services/sites'; import { AddonModAssignComponentsModule } from './components/components.module'; +import { AddonModAssignFeedbackModule } from './feedback/feedback.module'; import { OFFLINE_SITE_SCHEMA } from './services/database/assign'; import { AddonModAssignIndexLinkHandler } from './services/handlers/index-link'; import { AddonModAssignListLinkHandler } from './services/handlers/list-link'; @@ -43,6 +44,7 @@ const routes: Routes = [ CoreMainMenuTabRoutingModule.forChild(routes), AddonModAssignComponentsModule, AddonModAssignSubmissionModule, + AddonModAssignFeedbackModule, ], providers: [ { diff --git a/src/addons/mod/assign/components/feedback-plugin/feedback-plugin.ts b/src/addons/mod/assign/components/feedback-plugin/feedback-plugin.ts index b84b54808..96f5be1ab 100644 --- a/src/addons/mod/assign/components/feedback-plugin/feedback-plugin.ts +++ b/src/addons/mod/assign/components/feedback-plugin/feedback-plugin.ts @@ -17,7 +17,7 @@ import { CoreError } from '@classes/errors/error'; import { CoreDynamicComponent } from '@components/dynamic-component/dynamic-component'; import { CoreWSExternalFile } from '@services/ws'; import { ModalController } from '@singletons'; -import { AddonModAssignFeedbackCommentsPluginData } from '../../feedback/comments/services/handler'; +import { AddonModAssignFeedbackCommentsTextData } from '../../feedback/comments/services/handler'; import { AddonModAssignAssign, AddonModAssignSubmission, @@ -104,7 +104,7 @@ export class AddonModAssignFeedbackPluginComponent implements OnInit { * * @return Promise resolved with the input data, rejected if cancelled. */ - editFeedback(): Promise { + editFeedback(): Promise { if (!this.canEdit) { throw new CoreError('Cannot edit feedback'); } @@ -115,7 +115,7 @@ export class AddonModAssignFeedbackPluginComponent implements OnInit { } protected async showEditFeedbackModal( - resolve: (value: AddonModAssignFeedbackCommentsPluginData | PromiseLike) => void, + resolve: (value: AddonModAssignFeedbackCommentsTextData | PromiseLike) => void, reject: () => void, ): Promise < void> { // Create the navigation modal. diff --git a/src/addons/mod/assign/feedback/comments/comments.module.ts b/src/addons/mod/assign/feedback/comments/comments.module.ts index f0f79618b..4581d686e 100644 --- a/src/addons/mod/assign/feedback/comments/comments.module.ts +++ b/src/addons/mod/assign/feedback/comments/comments.module.ts @@ -18,7 +18,6 @@ import { AddonModAssignFeedbackCommentsComponent } from './component/comments'; import { CoreSharedModule } from '@/core/shared.module'; import { CoreEditorComponentsModule } from '@features/editor/components/components.module'; import { AddonModAssignFeedbackDelegate } from '../../services/feedback-delegate'; -import { AddonModAssignSubmissionFileHandler } from '../../submission/file/services/handler'; @NgModule({ declarations: [ @@ -35,7 +34,7 @@ import { AddonModAssignSubmissionFileHandler } from '../../submission/file/servi multi: true, deps: [], useFactory: () => () => { - AddonModAssignFeedbackDelegate.instance.registerHandler(AddonModAssignSubmissionFileHandler.instance); + AddonModAssignFeedbackDelegate.instance.registerHandler(AddonModAssignFeedbackCommentsHandler.instance); }, }, ], diff --git a/src/addons/mod/assign/feedback/comments/services/handler.ts b/src/addons/mod/assign/feedback/comments/services/handler.ts index 58c98ff34..22852c3b5 100644 --- a/src/addons/mod/assign/feedback/comments/services/handler.ts +++ b/src/addons/mod/assign/feedback/comments/services/handler.ts @@ -49,14 +49,11 @@ export class AddonModAssignFeedbackCommentsHandlerService implements AddonModAss * @param inputData Data entered in the feedback edit form. * @return Text to submit. */ - getTextFromInputData(plugin: AddonModAssignPlugin, inputData: AddonModAssignFeedbackCommentsPluginData): string { + getTextFromInputData(plugin: AddonModAssignPlugin, inputData: AddonModAssignFeedbackCommentsTextData): string { const files = plugin.fileareas && plugin.fileareas[0] ? plugin.fileareas[0].files : []; - let text = ''; // The input data can have a string or an object with text and format. Get the text. - if (inputData.assignfeedbackcomments_editor && inputData.assignfeedbackcomments_editor.text) { - text = inputData.assignfeedbackcomments_editor.text; - } + const text = inputData.assignfeedbackcomments_editor || ''; return CoreTextUtils.instance.restorePluginfileUrls(text, files || []); } @@ -147,7 +144,7 @@ export class AddonModAssignFeedbackCommentsHandlerService implements AddonModAss assign: AddonModAssignAssign, submission: AddonModAssignSubmission, plugin: AddonModAssignPlugin, - inputData: AddonModAssignFeedbackCommentsPluginData, + inputData: AddonModAssignFeedbackCommentsTextData, userId: number, ): Promise { // Get it from plugin or offline. @@ -254,6 +251,11 @@ export class AddonModAssignFeedbackCommentsHandlerService implements AddonModAss } export const AddonModAssignFeedbackCommentsHandler = makeSingleton(AddonModAssignFeedbackCommentsHandlerService); +export type AddonModAssignFeedbackCommentsTextData = { + // The text for this submission. + assignfeedbackcomments_editor: string; // eslint-disable-line @typescript-eslint/naming-convention +}; + export type AddonModAssignFeedbackCommentsDraftData = { text: string; // The text for this feedback. format: number; // The format for this feedback. From d8009dac99db8b4219d508386dedda2b9d40d831 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Tue, 23 Feb 2021 08:53:24 +0100 Subject: [PATCH 14/17] MOBILE-3636 core: Improvements on some theme styles --- .../course-list-item/course-list-item.scss | 35 +----- .../course-progress/course-progress.scss | 33 +---- src/theme/{ => components}/format-text.scss | 0 src/theme/globals.mixins.ionic.scss | 92 ++++++++++++++ src/theme/globals.scss | 4 +- src/theme/globals.variables.scss | 90 ++++++++++++++ src/theme/theme.base.scss | 111 +++++------------ src/theme/theme.dark.scss | 16 +-- src/theme/theme.light.scss | 115 +++++------------- src/theme/theme.scss | 4 +- 10 files changed, 263 insertions(+), 237 deletions(-) rename src/theme/{ => components}/format-text.scss (100%) diff --git a/src/core/features/courses/components/course-list-item/course-list-item.scss b/src/core/features/courses/components/course-list-item/course-list-item.scss index dc76218e8..5d2625e0e 100644 --- a/src/core/features/courses/components/course-list-item/course-list-item.scss +++ b/src/core/features/courses/components/course-list-item/course-list-item.scss @@ -1,3 +1,5 @@ +@import "~theme/globals"; + :host { .course-icon { color: white; @@ -10,35 +12,10 @@ transition: all 50ms ease-in-out; } - ion-icon[course-color="0"] { - color: var(--core-course-color-0); - } - ion-icon[course-color="1"] { - color: var(--core-course-color-1); - } - ion-icon[course-color="2"] { - color: var(--core-course-color-2); - } - ion-icon[course-color="3"] { - color: var(--core-course-color-3); - } - ion-icon[course-color="4"] { - color: var(--core-course-color-4); - } - ion-icon[course-color="5"] { - color: var(--core-course-color-5); - } - ion-icon[course-color="6"] { - color: var(--core-course-color-6); - } - ion-icon[course-color="7"] { - color: var(--core-course-color-7); - } - ion-icon[course-color="8"] { - color: var(--core-course-color-8); - } - ion-icon[course-color="9"] { - color: var(--core-course-color-9); + @for $i from 0 to length($core-course-image-background) { + ion-icon[course-color="#{$i}"] { + color: nth($core-course-image-background, $i + 1); + } } ion-avatar { diff --git a/src/core/features/courses/components/course-progress/course-progress.scss b/src/core/features/courses/components/course-progress/course-progress.scss index f73f02035..4d59d8515 100644 --- a/src/core/features/courses/components/course-progress/course-progress.scss +++ b/src/core/features/courses/components/course-progress/course-progress.scss @@ -7,35 +7,10 @@ align-self: stretch; height: calc(100% - 20px); - &[course-color="0"] .core-course-thumb { - background: var(--core-course-color-0); - } - &[course-color="1"] .core-course-thumb { - background: var(--core-course-color-1); - } - &[course-color="2"] .core-course-thumb { - background: var(--core-course-color-2); - } - &[course-color="3"] .core-course-thumb { - background: var(--core-course-color-3); - } - &[course-color="4"] .core-course-thumb { - background: var(--core-course-color-4); - } - &[course-color="5"] .core-course-thumb { - background: var(--core-course-color-5); - } - &[course-color="6"] .core-course-thumb { - background: var(--core-course-color-6); - } - &[course-color="7"] .core-course-thumb { - background: var(--core-course-color-7); - } - &[course-color="8"] .core-course-thumb { - background: var(--core-course-color-8); - } - &[course-color="9"] .core-course-thumb { - background: var(--core-course-color-9); + @for $i from 0 to length($core-course-image-background) { + &[course-color="#{$i}"] .core-course-thumb { + background: nth($core-course-image-background, $i + 1); + } } .core-course-thumb { diff --git a/src/theme/format-text.scss b/src/theme/components/format-text.scss similarity index 100% rename from src/theme/format-text.scss rename to src/theme/components/format-text.scss diff --git a/src/theme/globals.mixins.ionic.scss b/src/theme/globals.mixins.ionic.scss index 73678af2a..56a82a054 100644 --- a/src/theme/globals.mixins.ionic.scss +++ b/src/theme/globals.mixins.ionic.scss @@ -6,6 +6,98 @@ * https://github.com/ionic-team/ionic-framework/blob/master/core/src/themes/ionic.mixins.scss */ + +// Gets the active color's css variable from a variation. Alpha is optional. +// -------------------------------------------------------------------------------------------- +// Example usage: +// current-color(base) => var(--ion-color-base) +// current-color(contrast, 0.1) => rgba(var(--ion-color-contrast-rgb), 0.1) +// -------------------------------------------------------------------------------------------- +@function current-color($variation, $alpha: null) { + @if $alpha == null { + @return var(--ion-color-#{$variation}); + } @else { + @return rgba(var(--ion-color-#{$variation}-rgb), #{$alpha}); + } +} + +// Gets the specific color's css variable from the name and variation. Alpha/rgb are optional. +// -------------------------------------------------------------------------------------------- +// Example usage: +// ion-color(primary, base) => var(--ion-color-primary, #3880ff) +// ion-color(secondary, contrast) => var(--ion-color-secondary-contrast) +// ion-color(primary, base, 0.5) => rgba(var(--ion-color-primary-rgb, 56, 128, 255), 0.5) +// -------------------------------------------------------------------------------------------- +@function ion-color($name, $variation, $alpha: null, $rgb: null) { + $values: map-get($colors, $name); + $value: map-get($values, $variation); + $variable: --ion-color-#{$name}-#{$variation}; + + @if ($variation == base) { + $variable: --ion-color-#{$name}; + } + + @if ($alpha) { + $value: color-to-rgb-list($value); + @return rgba(var(#{$variable}-rgb, $value), $alpha); + } + @if ($rgb) { + $value: color-to-rgb-list($value); + $variable: #{$variable}-rgb; + } + + @return var(#{$variable}, $value); +} + +// Mixes a color with black to create its shade. +// -------------------------------------------------------------------------------------------- +@function get-color-shade($color) { + @return mix(#000, $color, 12%); +} + +// Mixes a color with white to create its tint. +// -------------------------------------------------------------------------------------------- +@function get-color-tint($color) { + @return mix(#fff, $color, 10%); +} + +// Converts a color to a comma separated rgb. +// -------------------------------------------------------------------------------------------- +@function color-to-rgb-list($color) { + @return #{red($color)},#{green($color)},#{blue($color)}; +} + + + // Ionic Colors +// -------------------------------------------------- +// Generates the color classes and variables based on the +// colors map + +@mixin generate-color($color-name) { + $value: map-get($colors, $color-name); + + $base: map-get($value, base); + $contrast: map-get($value, contrast); + $shade: map-get($value, shade); + $tint: map-get($value, tint); + + --ion-color-#{$color-name}: #{$base}; + --ion-color-#{$color-name}-rgb: #{color-to-rgb-list($base)}; + --ion-color-#{$color-name}-contrast: #{$contrast}; + --ion-color-#{$color-name}-contrast-rgb: #{color-to-rgb-list($contrast)}; + --ion-color-#{$color-name}-shade: #{$shade}; + --ion-color-#{$color-name}-tint: #{$tint}; + + .ion-color-#{$color-name} { + --ion-color: #{$base}; + --ion-color-rgb: #{color-to-rgb-list($base)}; + --ion-color-contrast: #{$contrast}; + --ion-color-contrast-rgb: #{color-to-rgb-list($contrast)}; + --ion-color-shade: #{$shade}; + --ion-color-tint: #{$tint}; + } +} + @mixin input-cover() { @include position(0, null, null, 0); @include margin(0); diff --git a/src/theme/globals.scss b/src/theme/globals.scss index 4d3cba282..9a1812999 100644 --- a/src/theme/globals.scss +++ b/src/theme/globals.scss @@ -4,7 +4,7 @@ * Place here the different files you should import to use global variables. */ -@import "./globals.custom.scss"; -@import "./globals.variables.scss"; @import "./globals.mixins.scss"; @import "./globals.mixins.ionic.scss"; +@import "./globals.custom.scss"; +@import "./globals.variables.scss"; diff --git a/src/theme/globals.variables.scss b/src/theme/globals.variables.scss index 0ecc0f2cd..07bf2248e 100644 --- a/src/theme/globals.variables.scss +++ b/src/theme/globals.variables.scss @@ -51,6 +51,96 @@ $subdued-text-color: $gray-darker !default; $core-online-color: #5cb85c !default; +$primary: $core-color !default; +$secondary: $blue !default; +$tertiary: $turquoise !default; +$danger: $red !default; +$warning: $yellow !default; +$success: $green !default; +$info: $blue !default; +$light: $gray-lighter !default; +$medium: $gray-light !default; +$dark: $black !default; + +$colors: ( + primary: ( + base: $primary, + contrast: #fff, + shade: get-color-shade($primary), + tint: get-color-tint($primary) + ), + secondary: ( + base: $secondary, + contrast: #fff, + shade: get-color-shade($secondary), + tint: get-color-tint($secondary) + ), + tertiary: ( + base: $tertiary, + contrast: #fff, + shade: get-color-shade($tertiary), + tint: get-color-tint($tertiary) + ), + success: ( + base: $success, + contrast: #fff, + shade: get-color-shade($success), + tint: get-color-tint($success) + ), + warning: ( + base: $warning, + contrast: #000, + shade: get-color-shade($warning), + tint: get-color-tint($warning) + ), + danger: ( + base: $danger, + contrast: #fff, + shade: get-color-shade($danger), + tint: get-color-tint($danger) + ), + info: ( + base: $info, + contrast: #fff, + shade: get-color-shade($info), + tint: get-color-tint($info) + ), + light: ( + base: $light, + contrast: #000, + shade: get-color-shade($light), + tint: get-color-tint($light) + ), + medium: ( + base: $medium, + contrast: #fff, + shade: get-color-shade($medium), + tint: get-color-tint($medium) + ), + dark: ( + base: $dark, + contrast: #fff, + shade: get-color-shade($dark), + tint: get-color-tint($dark) + ) +) !default; + +// Just swipe light and dark. +$colors-dark: ( + primary: map-get($colors, primary), + secondary: map-get($colors, secondary), + tertiary: map-get($colors, tertiary), + success: map-get($colors, success), + warning: map-get($colors, warning), + danger: map-get($colors, danger), + info: map-get($colors, info), + light: map-get($colors, dark), + medium: map-get($colors, medium), + dark: map-get($colors, light), +) !default; + +$core-course-image-background: #81ecec, #74b9ff, #a29bfe, #dfe6e9, #00b894, #0984e3, #b2bec3, #fdcb6e, #fd79a8, #6c5ce7 !default; + /* * Layout Breakpoints * diff --git a/src/theme/theme.base.scss b/src/theme/theme.base.scss index be6ee775b..36065c7ea 100644 --- a/src/theme/theme.base.scss +++ b/src/theme/theme.base.scss @@ -204,34 +204,6 @@ ion-toolbar { } } -.item.core-primary-item, -.item.core-info-item, -.item.core-secondary-item, -.item.core-tertiary-item, -.item.core-success-item, -.item.core-warning-item, -.item.core-danger-item { - --inner-border-width: 0 0 3px 0; -} -.item.core-primary-item { - --border-color: var(--ion-color-primary); -} -.item.core-info-item, -.item.core-secondary-item { - --border-color: var(--ion-color-secondary); -} -.item.core-tertiary-item { - --border-color: var(--ion-color-tertiary); -} -.item.core-success-item { - --border-color: var(--ion-color-success); -} -.item.core-warning-item { - --border-color: var(--ion-color-warning); -} -.item.core-danger-item { - --border-color: var(--ion-color-danger); -} .item-dimmed { opacity: 0.7; --background: var(--gray-lighter); @@ -243,58 +215,36 @@ ion-toolbar { } // Card styles +@each $color-name, $value in $colors { + $value: map-get($colors, $color-name); -// Message cards. -ion-card.core-primary-card, -ion-card.core-info-card, -ion-card.core-secondary-card, -ion-card.core-tertiary-card, -ion-card.core-success-card, -ion-card.core-warning-card, -ion-card.core-danger-card { - border-bottom: 3px solid transparent; - ion-item::part(native) { - --inner-border-width: 0; + $base: map-get($value, base); + $contrast: map-get($value, contrast); + $shade: map-get($value, shade); + $tint: map-get($value, tint); + + // Message cards. + ion-card.core-#{$color-name}-card { + border-bottom: 3px solid $base; + + ion-item::part(native) { + --inner-border-width: 0; + } + ion-label { + white-space: normal !important; + } + ion-icon { + color: $base; + } } - ion-label { - white-space: normal !important; - } -} -ion-card.core-primary-card { - border-bottom-color: var(--ion-color-primary); - ion-icon { - color: var(--ion-color-primary); - } -} -ion-card.core-info-card, -ion-card.core-secondary-card { - border-bottom-color: var(--ion-color-secondary); - ion-icon { - color: var(--ion-color-secondary); - } -} -ion-card.core-tertiary-card { - border-bottom-color: var(--ion-color-tertiary); - ion-icon { - color: var(--ion-color-tertiary); - } -} -ion-card.core-success-card { - border-bottom-color: var(--ion-color-success); - ion-icon { - color: var(--ion-color-success); - } -} -ion-card.core-warning-card { - border-bottom-color: var(--ion-color-warning); - ion-icon { - color: var(--ion-color-warning); - } -} -ion-card.core-danger-card { - border-bottom-color: var(--ion-color-danger); - ion-icon { - color: var(--ion-color-danger); + + .item.core-#{$color-name}-item { + --inner-border-width: 0 0 3px 0; + --border-width: 0; + border-bottom: 3px solid $base !important; + ion-icon { + color: $base; + } } } @@ -338,9 +288,10 @@ img[alt] { // Activity modules .core-module-icon { + --size: 24px; width: auto; - max-width: 24px; - max-height: 24px; + max-width: var(--size); + max-height: var(--size); } ion-item img.core-module-icon[slot="start"] { diff --git a/src/theme/theme.dark.scss b/src/theme/theme.dark.scss index 837fa8eb0..6bf965ee9 100644 --- a/src/theme/theme.dark.scss +++ b/src/theme/theme.dark.scss @@ -37,19 +37,9 @@ --light: var(--black); --dark: var(--gray-lighter); - --ion-color-light: var(--light); - --ion-color-light-rgb: 58,58,58; - --ion-color-light-contrast: #ffffff; - --ion-color-light-contrast-rgb: 255,255,255; - --ion-color-light-shade: #333333; - --ion-color-light-tint: #4e4e4e; - - --ion-color-dark: var(--dark); - --ion-color-dark-rgb: 245,245,245; - --ion-color-dark-contrast: #000000; - --ion-color-dark-contrast-rgb: 0,0,0; - --ion-color-dark-shade: #d8d8d8; - --ion-color-dark-tint: #f6f6f6; + @each $color-name, $value in $colors-dark { + @include generate-color($color-name); + } --ion-tab-bar-background: #1f1f1f; diff --git a/src/theme/theme.light.scss b/src/theme/theme.light.scss index 770cc7f96..36015bda4 100644 --- a/src/theme/theme.light.scss +++ b/src/theme/theme.light.scss @@ -17,95 +17,53 @@ --white: #{$white}; --blue: #{$blue}; - --blue-dark: #{$blue-dark}; --blue-light: #{$blue-light}; + --blue-dark: #{$blue-dark}; + --turquoise: #{$turquoise}; + --turquoise-light: #{$turquoise-light}; + --turquoise-dark: #{$turquoise-dark}; + --green: #{$green}; + --green-light: #{$green-light}; + --green-dark: #{$green-dark}; + --red: #{$red}; + --red-light: #{$red-light}; + --red-dark: #{$red-dark}; + --orange: #{$orange}; + --orange-light: #{$orange-light}; + --yellow: #{$yellow}; + --yellow-light: #{$yellow-light}; + --yellow-dark: #{$yellow-dark}; + --purple: #{$purple}; - --core-color: #{$core-color}; + --core-color: #{$core-color}; + --core-color-light: #{$core-color-light}; + --core-color-dark: #{$core-color-dark}; + --core-online-color: #{$core-online-color}; // Named Color Variables - --primary: var(--core-color); - --secondary: var(--blue); - --tertiary: var(--turquoise); - --success: var(--green); - --danger: var(--red); - --warning: var(--yellow); + --primary: var(--primary); + --secondary: var(--secondary); + --tertiary: var(--tertiary); + --success: var(--success); + --danger: var(--danger); + --warning: var(--warning); + --info: var(--info); --light: var(--gray-lighter); --dark: var(--black); --medium: var(--gray-light); - --light: var(--gray-lighter); - --primary: var(--core-color); - --ion-color-primary: var(--primary); - --ion-color-primary-rgb: 249,128,18; - --ion-color-primary-contrast: #ffffff; - --ion-color-primary-contrast-rgb: 255,255,255; - --ion-color-primary-shade: #db7110; - --ion-color-primary-tint: #fa8d2a; + @each $color-name, $value in $colors { + @include generate-color($color-name); + } - --ion-color-secondary: var(--secondary); - --ion-color-secondary-rgb: 0,100,210; - --ion-color-secondary-contrast: #ffffff; - --ion-color-secondary-contrast-rgb: 255,255,255; - --ion-color-secondary-shade: #0058b9; - --ion-color-secondary-tint: #1a74d7; - - --ion-color-tertiary: var(--tertiary); - --ion-color-tertiary-rgb: 0,121,130; - --ion-color-tertiary-contrast: #ffffff; - --ion-color-tertiary-contrast-rgb: 255,255,255; - --ion-color-tertiary-shade: #006a72; - --ion-color-tertiary-tint: #1a868f; - - --ion-color-success: var(--success); - --ion-color-success-rgb: 94,129,0; - --ion-color-success-contrast: #ffffff; - --ion-color-success-contrast-rgb: 255,255,255; - --ion-color-success-shade: #537200; - --ion-color-success-tint: #6e8e1a; - - --ion-color-warning: var(--warning); - --ion-color-warning-rgb: 251,173,26; - --ion-color-warning-contrast: #000000; - --ion-color-warning-contrast-rgb: 0,0,0; - --ion-color-warning-shade: #dd9817; - --ion-color-warning-tint: #fbb531; - - --ion-color-danger: var(--danger); - --ion-color-danger-rgb: 203,61,77; - --ion-color-danger-contrast: #ffffff; - --ion-color-danger-contrast-rgb: 255,255,255; - --ion-color-danger-shade: #b33644; - --ion-color-danger-tint: #d0505f; - - --ion-color-dark: var(--dark); - --ion-color-dark-rgb: 58,58,58; - --ion-color-dark-contrast: #ffffff; - --ion-color-dark-contrast-rgb: 255,255,255; - --ion-color-dark-shade: #333333; - --ion-color-dark-tint: #4e4e4e; - - --ion-color-medium: var(--medium); - --ion-color-medium-rgb: 158,158,158; - --ion-color-medium-contrast: #000000; - --ion-color-medium-contrast-rgb: 0,0,0; - --ion-color-medium-shade: #8b8b8b; - --ion-color-medium-tint: #a8a8a8; - - --ion-color-light: var(--light); - --ion-color-light-rgb: 245,245,245; - --ion-color-light-contrast: #000000; - --ion-color-light-contrast-rgb: 0,0,0; - --ion-color-light-shade: #d8d8d8; - --ion-color-light-tint: #f6f6f6; - - --ion-text-color: var($text-color); + --ion-text-color: #{$text-color}; --ion-text-color-rgb: 58,58,58; --ion-card-color: var(--ion-text-color); @@ -129,6 +87,7 @@ --background: var(--ion-statusbar-background); ion-button { --ion-toolbar-color: transparent; + --color: var(--custom-toolbar-color, var(--ion-color-primary-contrast)); } ion-spinner { @@ -198,16 +157,6 @@ --core-login-background: var(--custom-login-background, var(--white)); --core-login-text-color: var(--custom-login-text-color, var(--black)); - --core-course-color-0: var(--custom-course-color-0, #81ecec); - --core-course-color-1: var(--custom-course-color-1, #74b9ff); - --core-course-color-2: var(--custom-course-color-2, #a29bfe); - --core-course-color-3: var(--custom-course-color-3, #dfe6e9); - --core-course-color-4: var(--custom-course-color-4, #00b894); - --core-course-color-5: var(--custom-course-color-5, #0984e3); - --core-course-color-6: var(--custom-course-color-6, #b2bec3); - --core-course-color-7: var(--custom-course-color-7, #fdcb6e); - --core-course-color-8: var(--custom-course-color-9, #fd79a8); - --core-course-color-9: var(--custom-course-color-90, #6c5ce7); --core-star-color: var(--custom-star-color, var(--core-color)); --core-large-avatar-size: var(--custom-large-avatar-size, 90px); diff --git a/src/theme/theme.scss b/src/theme/theme.scss index 7c6c324ca..bccad48f6 100644 --- a/src/theme/theme.scss +++ b/src/theme/theme.scss @@ -17,7 +17,9 @@ @import "./theme.dark.scss"; @import "./theme.custom.scss"; @import "./theme.base.scss"; -@import "./format-text.scss"; + +/* Components */ +@import "./components/format-text.scss"; /* Core CSS required for Ionic components to work properly */ @import "~@ionic/angular/css/core.css"; From a3ef52a0405cbfc144e6f02dfde7dccc158182b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Tue, 23 Feb 2021 09:24:32 +0100 Subject: [PATCH 15/17] MOBILE-3636 assign: Add styles on rubrics --- .../components/submission/submission.scss | 259 +----------------- .../components/submission/submission.ts | 1 + src/core/directives/fa-icon.ts | 1 + src/theme/components/rubrics.scss | 241 ++++++++++++++++ src/theme/theme.base.scss | 4 + src/theme/theme.scss | 1 + 6 files changed, 261 insertions(+), 246 deletions(-) create mode 100644 src/theme/components/rubrics.scss diff --git a/src/addons/mod/assign/components/submission/submission.scss b/src/addons/mod/assign/components/submission/submission.scss index dee6a5037..3c8a199a0 100644 --- a/src/addons/mod/assign/components/submission/submission.scss +++ b/src/addons/mod/assign/components/submission/submission.scss @@ -1,18 +1,21 @@ -:host { +:host ::ng-deep { div.latesubmission, div.overdue { - // @extend .core-danger-item; + border-bottom: 3px solid var(--danger) !important; + ion-icon { + color: var(--danger); + } } div.earlysubmission { - // @extend .core-success-item; + border-bottom: 3px solid var(--success) !important; + ion-icon { + color: var(--success); + } } div.submissioneditable p { - color: $red; - @include darkmode() { - color: $red-light; - } + color: var(--red); } .core-grading-summary .advancedgrade { @@ -20,244 +23,8 @@ } } -core-format-text { - - .gradingform_rubric_editform .status { - font-weight: normal; - text-transform: uppercase; - font-size: 60%; - padding: 0.25em; - border: 1px solid $gray-light; - } - - .gradingform_rubric_editform .status.ready { - background-color: $green-light; - border-color: $green; - } - - .gradingform_rubric_editform .status.draft { - background-color: $yellow-light; - border-color: $yellow; - } - - .gradingform_rubric { - overflow: auto; - padding-bottom: 1.5em; - max-width: 720px; - position: relative; - margin: 0 auto; - tbody { - background: $white; - color: $text-color; - } - } - - // Do not display remark column. - .gradingform_rubric .criterion .remark { - display: none; - } - - .gradingform_rubric.editor .criterion .controls, - .gradingform_rubric .criterion .description, - .gradingform_rubric .criterion .levels, - .gradingform_rubric.editor .criterion .addlevel, - .gradingform_rubric .criterion .remark, - .gradingform_rubric .criterion .levels .level { - vertical-align: top; - } - - .gradingform_rubric.editor .criterion .controls, - .gradingform_rubric .criterion .description, - .gradingform_rubric.editor .criterion .addlevel, - .gradingform_rubric .criterion .remark, - .gradingform_rubric .criterion .levels .level { - padding: 3px; - } - - .gradingform_rubric .criteria { - height: 100%; - } - - .gradingform_rubric .criterion { - border: 1px solid $gray; - overflow: hidden; - } - - .gradingform_rubric .criterion.even { - background: $gray-lighter; - } - - .gradingform_rubric .criterion .description { - width: 150px; - font-weight: bold; - } - - .gradingform_rubric .criterion .levels table { - width: 100%; - height: 100%; - } - - .gradingform_rubric .criterion .levels, - .gradingform_rubric .criterion .levels table, - .gradingform_rubric .criterion .levels table tbody { - padding: 0; - margin: 0; - } - - .gradingform_rubric .criterion .levels .level { - border-left: 1px solid $gray; - max-width: 150px; - } - - .gradingform_rubric .criterion .levels .level .level-wrapper { - position: relative; - } - - .gradingform_rubric .criterion .levels .level.last { - border-right: 1px solid $gray; - } - - .gradingform_rubric .plainvalue.empty { - font-style: italic; - color: $gray-dark; - } - - .gradingform_rubric.editor .criterion .levels .level .delete { - position: absolute; - right: 0; - } - - .gradingform_rubric .criterion .levels .level .score { - font-style: italic; - color: $green; - font-weight: bold; - margin-top: 5px; - white-space: nowrap; - } - - .gradingform_rubric .criterion .levels .level .score .scorevalue { - padding-right: 5px; - } - - /* Make invisible the buttons 'Move up' for the first criterion and - 'Move down' for the last, because those buttons will make no change */ - .gradingform_rubric.editor .criterion.first .controls .moveup input, - .gradingform_rubric.editor .criterion.last .controls .movedown input { - display: none; - } - - /* evaluation */ - .gradingform_rubric .criterion .levels .level.currentchecked { - background: #fff0f0; - } - - .gradingform_rubric .criterion .levels .level.checked { - background: $green-light; - border: 1px solid $gray-darker; - } - - .gradingform_rubric .options .optionsheading { - font-weight: bold; - font-size: 1.1em; - padding-bottom: 5px; - } - - .gradingform_rubric .options .option { - padding-bottom: 2px; - } - - .gradingform_rubric .options .option label { - margin-left: 5px; - } - - .gradingform_rubric .options .option .value { - margin-left: 5px; - font-weight: bold; - } - - .gradingform_rubric .criterion .levels.error { - border: 1px solid $red; - } - - .gradingform_rubric .criterion .description.error, - .gradingform_rubric .criterion .levels .level .definition.error, - .gradingform_rubric .criterion .levels .level .score.error { - background: $gray-lighter; - } - - .gradingform_rubric-regrade { - padding: 10px; - background: $gray-lighter; - border: 1px solid $red-light; - margin-bottom: 10px; - } - - .gradingform_rubric-restored { - padding: 10px; - background: $yellow-light; - border: 1px solid $yellow; - margin-bottom: 10px; - } - - .gradingform_rubric-error { - color: $red; - font-weight: bold; - } - - /* special classes for elements created by rubriceditor.js */ - .gradingform_rubric.editor .hiddenelement { - display: none; - } - - .gradingform_rubric.editor .pseudotablink { - background-color: transparent; - border: 0 solid; - height: 1px; - width: 1px; - color: transparent; - padding: 0; - margin: 0; - position: relative; - float: right; - } - - .gradingform_rubric { - padding-bottom: 0; - max-width: none; - } - - .gradingform_rubric .criterion .description { - font-weight: 500; - min-width: 150px; - } - - .gradingform_rubric .criterion .levels { - background-color: $white; - } - - .gradingform_rubric .criterion, - .gradingform_rubric .criterion.even { - background-color: transparent; - } - - .gradingform_rubric.evaluate .criterion .levels .level:hover { - background-color: $green-light; - } - - .gradingform_rubric .criterion .levels .level.checked { - background-color: $green-light; - border: none; - border-left: 1px solid $gray; - } - - .gradingform_rubric .criterion .levels .level .score { - color: $green; - font-weight: 500; - font-style: normal; - margin-top: 20px; - } - - .gradingform_rubric .criterion .remark textarea { - margin-bottom: 0; +:host-context(body.dark) ::ng-deep { + div.submissioneditable p { + color: var(--red-light); } } diff --git a/src/addons/mod/assign/components/submission/submission.ts b/src/addons/mod/assign/components/submission/submission.ts index 962bb5e05..3a2c906c6 100644 --- a/src/addons/mod/assign/components/submission/submission.ts +++ b/src/addons/mod/assign/components/submission/submission.ts @@ -63,6 +63,7 @@ import { AddonModAssignModuleHandlerService } from '../../services/handlers/modu @Component({ selector: 'addon-mod-assign-submission', templateUrl: 'addon-mod-assign-submission.html', + styleUrls: ['submission.scss'], }) export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy { diff --git a/src/core/directives/fa-icon.ts b/src/core/directives/fa-icon.ts index 29b3f5eee..381b36aab 100644 --- a/src/core/directives/fa-icon.ts +++ b/src/core/directives/fa-icon.ts @@ -71,6 +71,7 @@ export class CoreFaIconDirective implements OnChanges { if (library != 'ionic') { const src = `assets/fonts/font-awesome/${library}/${iconName}.svg`; this.element.setAttribute('src', src); + this.element.classList.add('faicon'); if (CoreConstants.BUILD.isDevelopment || CoreConstants.BUILD.isTesting) { try { diff --git a/src/theme/components/rubrics.scss b/src/theme/components/rubrics.scss new file mode 100644 index 000000000..329ef82d9 --- /dev/null +++ b/src/theme/components/rubrics.scss @@ -0,0 +1,241 @@ +core-format-text { + + .gradingform_rubric_editform .status { + font-weight: normal; + text-transform: uppercase; + font-size: 60%; + padding: 0.25em; + border: 1px solid var(--gray-light); + } + + .gradingform_rubric_editform .status.ready { + background-color: var(--green-light); + border-color: var(--green); + } + + .gradingform_rubric_editform .status.draft { + background-color: var(--yellow-light); + border-color: var(--yellow); + } + + .gradingform_rubric { + overflow: auto; + padding-bottom: 1.5em; + max-width: 720px; + position: relative; + margin: 0 auto; + tbody { + background-color: var(--white); + color: var(--text-color); + } + } + + // Do not display remark column. + .gradingform_rubric .criterion .remark { + display: none; + } + + .gradingform_rubric.editor .criterion .controls, + .gradingform_rubric .criterion .description, + .gradingform_rubric .criterion .levels, + .gradingform_rubric.editor .criterion .addlevel, + .gradingform_rubric .criterion .remark, + .gradingform_rubric .criterion .levels .level { + vertical-align: top; + } + + .gradingform_rubric.editor .criterion .controls, + .gradingform_rubric .criterion .description, + .gradingform_rubric.editor .criterion .addlevel, + .gradingform_rubric .criterion .remark, + .gradingform_rubric .criterion .levels .level { + padding: 3px; + } + + .gradingform_rubric .criteria { + height: 100%; + } + + .gradingform_rubric .criterion { + border: 1px solid var(--gray); + overflow: hidden; + } + + .gradingform_rubric .criterion.even { + background-color: var(--gray-lighter); + } + + .gradingform_rubric .criterion .description { + width: 150px; + font-weight: bold; + } + + .gradingform_rubric .criterion .levels table { + width: 100%; + height: 100%; + } + + .gradingform_rubric .criterion .levels, + .gradingform_rubric .criterion .levels table, + .gradingform_rubric .criterion .levels table tbody { + padding: 0; + margin: 0; + } + + .gradingform_rubric .criterion .levels .level { + border-left: 1px solid var(--gray); + max-width: 150px; + } + + .gradingform_rubric .criterion .levels .level .level-wrapper { + position: relative; + } + + .gradingform_rubric .criterion .levels .level.last { + border-right: 1px solid var(--gray); + } + + .gradingform_rubric .plainvalue.empty { + font-style: italic; + color: var(--gray-dark); + } + + .gradingform_rubric.editor .criterion .levels .level .delete { + position: absolute; + right: 0; + } + + .gradingform_rubric .criterion .levels .level .score { + font-style: italic; + color: var(--green); + font-weight: bold; + margin-top: 5px; + white-space: nowrap; + } + + .gradingform_rubric .criterion .levels .level .score .scorevalue { + padding-right: 5px; + } + + /* Make invisible the buttons 'Move up' for the first criterion and + 'Move down' for the last, because those buttons will make no change */ + .gradingform_rubric.editor .criterion.first .controls .moveup input, + .gradingform_rubric.editor .criterion.last .controls .movedown input { + display: none; + } + + /* evaluation */ + .gradingform_rubric .criterion .levels .level.currentchecked { + background-color: #fff0f0; + } + + .gradingform_rubric .criterion .levels .level.checked { + background-color: var(--green-light); + border: 1px solid var(--gray-darker); + } + + .gradingform_rubric .options .optionsheading { + font-weight: bold; + font-size: 1.1em; + padding-bottom: 5px; + } + + .gradingform_rubric .options .option { + padding-bottom: 2px; + } + + .gradingform_rubric .options .option label { + margin-left: 5px; + } + + .gradingform_rubric .options .option .value { + margin-left: 5px; + font-weight: bold; + } + + .gradingform_rubric .criterion .levels.error { + border: 1px solid var(--red); + } + + .gradingform_rubric .criterion .description.error, + .gradingform_rubric .criterion .levels .level .definition.error, + .gradingform_rubric .criterion .levels .level .score.error { + background-color: var(--gray-lighter); + } + + .gradingform_rubric-regrade { + padding: 10px; + background-color: var(--gray-lighter); + border: 1px solid var(--red-light); + margin-bottom: 10px; + } + + .gradingform_rubric-restored { + padding: 10px; + background-color: var(--yellow-light); + border: 1px solid var(--yellow); + margin-bottom: 10px; + } + + .gradingform_rubric-error { + color: var(--red); + font-weight: bold; + } + + /* special classes for elements created by rubriceditor.js */ + .gradingform_rubric.editor .hiddenelement { + display: none; + } + + .gradingform_rubric.editor .pseudotablink { + background-color: transparent; + border: 0 solid; + height: 1px; + width: 1px; + color: transparent; + padding: 0; + margin: 0; + position: relative; + float: right; + } + + .gradingform_rubric { + padding-bottom: 0; + max-width: none; + } + + .gradingform_rubric .criterion .description { + font-weight: 500; + min-width: 150px; + } + + .gradingform_rubric .criterion .levels { + background-color: var(--white); + } + + .gradingform_rubric .criterion, + .gradingform_rubric .criterion.even { + background-color: transparent; + } + + .gradingform_rubric.evaluate .criterion .levels .level:hover { + background-color: var(--green-light); + } + + .gradingform_rubric .criterion .levels .level.checked { + background-color: var(--green-light); + border: none; + border-left: 1px solid var(--gray); + } + + .gradingform_rubric .criterion .levels .level .score { + color: var(--green); + font-weight: 500; + font-style: normal; + margin-top: 20px; + } + + .gradingform_rubric .criterion .remark textarea { + margin-bottom: 0; + } +} diff --git a/src/theme/theme.base.scss b/src/theme/theme.base.scss index 36065c7ea..90d132f77 100644 --- a/src/theme/theme.base.scss +++ b/src/theme/theme.base.scss @@ -81,6 +81,10 @@ ion-icon { } } +ion-button.button-small ion-icon.faicon[slot] { + font-size: 1.5em !important; +} + [dir=rtl] ion-icon.icon-flip-rtl { transform: scaleX(-1); } diff --git a/src/theme/theme.scss b/src/theme/theme.scss index bccad48f6..9b82fd2d8 100644 --- a/src/theme/theme.scss +++ b/src/theme/theme.scss @@ -20,6 +20,7 @@ /* Components */ @import "./components/format-text.scss"; +@import "./components/rubrics.scss"; /* Core CSS required for Ionic components to work properly */ @import "~@ionic/angular/css/core.css"; From 4cf2a40ca7860021fa34321de3f9e9a356321ddf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Tue, 23 Feb 2021 15:55:02 +0100 Subject: [PATCH 16/17] MOBILE-3636 messages: Fix messages sync cron --- src/addons/messages/messages.module.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/addons/messages/messages.module.ts b/src/addons/messages/messages.module.ts index 37480435d..c3759dca7 100644 --- a/src/addons/messages/messages.module.ts +++ b/src/addons/messages/messages.module.ts @@ -34,6 +34,7 @@ import { CoreUserDelegate } from '@features/user/services/user-delegate'; import { AddonMessagesSendMessageUserHandler } from './services/handlers/user-send-message'; import { Network, NgZone } from '@singletons'; import { AddonMessagesSync } from './services/messages-sync'; +import { AddonMessagesSyncCronHandler } from './services/handlers/sync-cron'; const mainMenuChildrenRoutes: Routes = [ { @@ -61,7 +62,7 @@ const mainMenuChildrenRoutes: Routes = [ // Register handlers. CoreMainMenuDelegate.instance.registerHandler(AddonMessagesMainMenuHandler.instance); CoreCronDelegate.instance.register(AddonMessagesMainMenuHandler.instance); - CoreCronDelegate.instance.register(AddonMessagesPushClickHandler.instance); + CoreCronDelegate.instance.register(AddonMessagesSyncCronHandler.instance); CoreSettingsDelegate.instance.registerHandler(AddonMessagesSettingsHandler.instance); CoreContentLinksDelegate.instance.registerHandler(AddonMessagesIndexLinkHandler.instance); CoreContentLinksDelegate.instance.registerHandler(AddonMessagesDiscussionLinkHandler.instance); From cf7f4b495155607822b8664c03362f053e4dd29b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Wed, 24 Feb 2021 09:30:00 +0100 Subject: [PATCH 17/17] MOBILE-3636 assign: Minor fixes --- .../mod/assign/components/components.module.ts | 8 -------- .../feedback-plugin/feedback-plugin.ts | 15 +++------------ .../addon-mod-assign-submission.html | 3 ++- .../feedback/comments/comments.module.ts | 1 - .../assign/feedback/editpdf/editpdf.module.ts | 1 - .../mod/assign/feedback/file/file.module.ts | 1 - src/addons/mod/assign/pages/edit/edit.ts | 18 +++--------------- .../mod/assign/services/assign-helper.ts | 3 +-- src/addons/mod/assign/services/assign.ts | 7 ++++--- .../mod/assign/services/database/assign.ts | 3 ++- .../submission/comments/comments.module.ts | 1 - .../mod/assign/submission/file/file.module.ts | 1 - .../submission/onlinetext/onlinetext.module.ts | 1 - src/core/classes/ion-loading.ts | 2 +- 14 files changed, 16 insertions(+), 49 deletions(-) diff --git a/src/addons/mod/assign/components/components.module.ts b/src/addons/mod/assign/components/components.module.ts index 233a7c3aa..d215e9d79 100644 --- a/src/addons/mod/assign/components/components.module.ts +++ b/src/addons/mod/assign/components/components.module.ts @@ -13,10 +13,6 @@ // limitations under the License. import { NgModule } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { FormsModule } from '@angular/forms'; -import { IonicModule } from '@ionic/angular'; -import { TranslateModule } from '@ngx-translate/core'; import { CoreSharedModule } from '@/core/shared.module'; import { CoreCourseComponentsModule } from '@features/course/components/components.module'; @@ -35,10 +31,6 @@ import { AddonModAssignEditFeedbackModalComponent } from './edit-feedback-modal/ AddonModAssignEditFeedbackModalComponent, ], imports: [ - CommonModule, - IonicModule, - TranslateModule.forChild(), - FormsModule, CoreSharedModule, CoreCourseComponentsModule, ], diff --git a/src/addons/mod/assign/components/feedback-plugin/feedback-plugin.ts b/src/addons/mod/assign/components/feedback-plugin/feedback-plugin.ts index 96f5be1ab..650074442 100644 --- a/src/addons/mod/assign/components/feedback-plugin/feedback-plugin.ts +++ b/src/addons/mod/assign/components/feedback-plugin/feedback-plugin.ts @@ -104,20 +104,11 @@ export class AddonModAssignFeedbackPluginComponent implements OnInit { * * @return Promise resolved with the input data, rejected if cancelled. */ - editFeedback(): Promise { + async editFeedback(): Promise { if (!this.canEdit) { throw new CoreError('Cannot edit feedback'); } - return new Promise((resolve, reject): void => { - this.showEditFeedbackModal(resolve, reject); - }); - } - - protected async showEditFeedbackModal( - resolve: (value: AddonModAssignFeedbackCommentsTextData | PromiseLike) => void, - reject: () => void, - ): Promise < void> { // Create the navigation modal. const modal = await ModalController.instance.create({ component: AddonModAssignEditFeedbackModalComponent, @@ -134,9 +125,9 @@ export class AddonModAssignFeedbackPluginComponent implements OnInit { const result = await modal.onDidDismiss(); if (typeof result.data == 'undefined') { - reject(); + throw null; // User cancelled. } else { - resolve(result.data); + return result.data; } } 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 2185a1124..93a6b796f 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 @@ -1,7 +1,8 @@ - +

{{ user!.fullname }}

diff --git a/src/addons/mod/assign/feedback/comments/comments.module.ts b/src/addons/mod/assign/feedback/comments/comments.module.ts index 4581d686e..3cf1f28a5 100644 --- a/src/addons/mod/assign/feedback/comments/comments.module.ts +++ b/src/addons/mod/assign/feedback/comments/comments.module.ts @@ -28,7 +28,6 @@ import { AddonModAssignFeedbackDelegate } from '../../services/feedback-delegate CoreEditorComponentsModule, ], providers: [ - AddonModAssignFeedbackCommentsHandler, { provide: APP_INITIALIZER, multi: true, diff --git a/src/addons/mod/assign/feedback/editpdf/editpdf.module.ts b/src/addons/mod/assign/feedback/editpdf/editpdf.module.ts index 1db4ecd21..c91596cb2 100644 --- a/src/addons/mod/assign/feedback/editpdf/editpdf.module.ts +++ b/src/addons/mod/assign/feedback/editpdf/editpdf.module.ts @@ -26,7 +26,6 @@ import { AddonModAssignFeedbackDelegate } from '../../services/feedback-delegate CoreSharedModule, ], providers: [ - AddonModAssignFeedbackEditPdfHandler, { provide: APP_INITIALIZER, multi: true, diff --git a/src/addons/mod/assign/feedback/file/file.module.ts b/src/addons/mod/assign/feedback/file/file.module.ts index c4e59e063..45bfddd28 100644 --- a/src/addons/mod/assign/feedback/file/file.module.ts +++ b/src/addons/mod/assign/feedback/file/file.module.ts @@ -26,7 +26,6 @@ import { AddonModAssignFeedbackDelegate } from '../../services/feedback-delegate CoreSharedModule, ], providers: [ - AddonModAssignFeedbackFileHandler, { provide: APP_INITIALIZER, multi: true, diff --git a/src/addons/mod/assign/pages/edit/edit.ts b/src/addons/mod/assign/pages/edit/edit.ts index 49198b4c4..0b6b1075d 100644 --- a/src/addons/mod/assign/pages/edit/edit.ts +++ b/src/addons/mod/assign/pages/edit/edit.ts @@ -15,7 +15,6 @@ import { Component, OnInit, OnDestroy, ViewChild, ElementRef } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { CoreError } from '@classes/errors/error'; -import { CoreIonLoadingElement } from '@classes/ion-loading'; import { CoreFileUploaderHelper } from '@features/fileuploader/services/fileuploader-helper'; import { CoreNavigator } from '@services/navigator'; import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; @@ -208,26 +207,15 @@ export class AddonModAssignEditPage implements OnInit, OnDestroy { * * @return Promise resolved with boolean: whether data has changed. */ - protected hasDataChanged(): Promise { + protected async hasDataChanged(): Promise { // Usually the hasSubmissionDataChanged call will be resolved inmediately, causing the modal to be shown just an instant. // We'll wait a bit before showing it to prevent this "blink". - let modal: CoreIonLoadingElement; - let showModal = true; - - setTimeout(async () => { - if (showModal) { - modal = await CoreDomUtils.instance.showModalLoading(); - } - }, 100); + const modal = await CoreDomUtils.instance.showModalLoading(); const data = this.getInputData(); return AddonModAssignHelper.instance.hasSubmissionDataChanged(this.assign!, this.userSubmission, data).finally(() => { - if (modal) { - modal.dismiss(); - } else { - showModal = false; - } + modal.dismiss(); }); } diff --git a/src/addons/mod/assign/services/assign-helper.ts b/src/addons/mod/assign/services/assign-helper.ts index 0e797419d..178408d8b 100644 --- a/src/addons/mod/assign/services/assign-helper.ts +++ b/src/addons/mod/assign/services/assign-helper.ts @@ -245,7 +245,6 @@ export class AddonModAssignHelperProvider { // If no participants returned and all groups specified, get participants by groups. const groupsInfo = await CoreGroups.instance.getActivityGroupInfo(assign.cmid, false, undefined, modOptions.siteId); - []; const participantsIndexed: {[id: number]: AddonModAssignParticipant} = {}; @@ -582,7 +581,7 @@ export class AddonModAssignHelperProvider { siteId?: string, ): Promise { - const pluginData = {}; + const pluginData: Record = {}; const promises = feedback.plugins ? feedback.plugins.map((plugin) => AddonModAssignFeedbackDelegate.instance.preparePluginFeedbackData(assignId, userId, plugin, pluginData, siteId)) diff --git a/src/addons/mod/assign/services/assign.ts b/src/addons/mod/assign/services/assign.ts index b1d63013b..3a11c8b94 100644 --- a/src/addons/mod/assign/services/assign.ts +++ b/src/addons/mod/assign/services/assign.ts @@ -31,6 +31,7 @@ import { AddonModAssignOffline } from './assign-offline'; import { AddonModAssignSubmissionDelegate } from './submission-delegate'; import { CoreComments } from '@features/comments/services/comments'; import { AddonModAssignSubmissionFormatted } from './assign-helper'; +import { CoreWSError } from '@classes/errors/wserror'; const ROOT_CACHE_KEY = 'mmaModAssign:'; @@ -1119,7 +1120,7 @@ export class AddonModAssignProvider { if (warnings.length) { // The WebService returned warnings, reject. - throw warnings[0]; + throw new CoreWSError(warnings[0]); } } @@ -1202,7 +1203,7 @@ export class AddonModAssignProvider { if (warnings.length) { // The WebService returned warnings, reject. - throw warnings[0]; + throw new CoreWSError(warnings[0]); } } @@ -1371,7 +1372,7 @@ export class AddonModAssignProvider { if (warnings.length) { // The WebService returned warnings, reject. - throw warnings[0]; + throw new CoreWSError(warnings[0]); } } diff --git a/src/addons/mod/assign/services/database/assign.ts b/src/addons/mod/assign/services/database/assign.ts index 949a1e580..7a4a608f1 100644 --- a/src/addons/mod/assign/services/database/assign.ts +++ b/src/addons/mod/assign/services/database/assign.ts @@ -16,7 +16,8 @@ import { CoreSiteSchema } from '@services/sites'; /** * Database variables for AddonModAssignOfflineProvider. - */export const SUBMISSIONS_TABLE = 'addon_mod_assign_submissions'; + */ +export const SUBMISSIONS_TABLE = 'addon_mod_assign_submissions'; export const SUBMISSIONS_GRADES_TABLE = 'addon_mod_assign_submissions_grading'; export const OFFLINE_SITE_SCHEMA: CoreSiteSchema = { name: 'AddonModAssignOfflineProvider', diff --git a/src/addons/mod/assign/submission/comments/comments.module.ts b/src/addons/mod/assign/submission/comments/comments.module.ts index bb05dbd18..91faee3aa 100644 --- a/src/addons/mod/assign/submission/comments/comments.module.ts +++ b/src/addons/mod/assign/submission/comments/comments.module.ts @@ -28,7 +28,6 @@ import { CoreCommentsComponentsModule } from '@features/comments/components/comp CoreCommentsComponentsModule, ], providers: [ - AddonModAssignSubmissionCommentsHandler, { provide: APP_INITIALIZER, multi: true, diff --git a/src/addons/mod/assign/submission/file/file.module.ts b/src/addons/mod/assign/submission/file/file.module.ts index cf97e85f7..be02d065a 100644 --- a/src/addons/mod/assign/submission/file/file.module.ts +++ b/src/addons/mod/assign/submission/file/file.module.ts @@ -26,7 +26,6 @@ import { AddonModAssignSubmissionDelegate } from '../../services/submission-dele CoreSharedModule, ], providers: [ - AddonModAssignSubmissionFileHandler, { provide: APP_INITIALIZER, multi: true, diff --git a/src/addons/mod/assign/submission/onlinetext/onlinetext.module.ts b/src/addons/mod/assign/submission/onlinetext/onlinetext.module.ts index cbcaaa5ba..77a69e767 100644 --- a/src/addons/mod/assign/submission/onlinetext/onlinetext.module.ts +++ b/src/addons/mod/assign/submission/onlinetext/onlinetext.module.ts @@ -28,7 +28,6 @@ import { AddonModAssignSubmissionDelegate } from '../../services/submission-dele CoreEditorComponentsModule, ], providers: [ - AddonModAssignSubmissionOnlineTextHandler, { provide: APP_INITIALIZER, multi: true, diff --git a/src/core/classes/ion-loading.ts b/src/core/classes/ion-loading.ts index 450e0aced..5f4dc9e2a 100644 --- a/src/core/classes/ion-loading.ts +++ b/src/core/classes/ion-loading.ts @@ -42,7 +42,7 @@ export class CoreIonLoadingElement { * Present the loading. */ async present(): Promise { - // Wait a bit before presenting the modal, to prevent it being displayed if dissmiss is called fast. + // Wait a bit before presenting the modal, to prevent it being displayed if dismiss is called fast. await CoreUtils.instance.wait(40); if (!this.isDismissed) {