// (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 { CoreLogger } from '@singletons/logger'; import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; import { CoreCourses, CoreEnrolledCourseData, CoreCourseSearchedData } from '@features/courses/services/courses'; import { CoreCourse } from '@features/course/services/course'; import { CoreGrades, CoreGradesGradeItem, CoreGradesGradeOverview, CoreGradesTable, CoreGradesTableColumn, CoreGradesTableItemNameColumn, CoreGradesTableRow, } from '@features/grades/services/grades'; import { CoreTextUtils } from '@services/utils/text'; import { CoreUrlUtils } from '@services/utils/url'; import { CoreMenuItem, CoreUtils } from '@services/utils/utils'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreNavigator } from '@services/navigator'; import { makeSingleton, Translate } from '@singletons'; import { CoreError } from '@classes/errors/error'; import { CoreCourseHelper } from '@features/course/services/course-helper'; import { GRADES_PAGE_NAME } from '../grades.module'; /** * Service that provides some features regarding grades information. */ @Injectable({ providedIn: 'root' }) export class CoreGradesHelperProvider { protected logger: CoreLogger; constructor() { this.logger = CoreLogger.getInstance('CoreGradesHelperProvider'); } /** * Formats a row from the grades table te be rendered in a page. * * @param tableRow JSON object representing row of grades table data. * @return Formatted row object. * @deprecated since app 4.0 */ protected async formatGradeRow(tableRow: CoreGradesTableRow): Promise { const row: CoreGradesFormattedRow = { rowclass: '', }; for (const name in tableRow) { const column: CoreGradesTableColumn = tableRow[name]; if (column.content === undefined || column.content === null) { continue; } let content = String(column.content); if (name == 'itemname') { await this.setRowIcon(row, content); row.link = this.getModuleLink(content); row.rowclass += column.class.indexOf('hidden') >= 0 ? ' hidden' : ''; row.rowclass += column.class.indexOf('dimmed_text') >= 0 ? ' dimmed_text' : ''; content = content.replace(/<\/span>/gi, '\n'); content = CoreTextUtils.cleanTags(content); } else { content = CoreTextUtils.replaceNewLines(content, '
'); } if (content == ' ') { content = ''; } row[name] = content.trim(); } return row; } /** * Formats a row from the grades table to be rendered in one table. * * @param tableRow JSON object representing row of grades table data. * @return Formatted row object. */ protected async formatGradeRowForTable(tableRow: CoreGradesTableRow): Promise { const row: CoreGradesFormattedTableRow = {}; for (let name in tableRow) { const column: CoreGradesTableColumn = tableRow[name]; if (column.content === undefined || column.content === null) { continue; } let content = String(column.content); if (name == 'itemname') { const itemNameColumn = column; row.id = parseInt(itemNameColumn.id.split('_')[1], 10); row.colspan = itemNameColumn.colspan; row.rowspan = tableRow.leader?.rowspan || 1; await this.setRowIcon(row, content); row.rowclass = itemNameColumn.class.indexOf('leveleven') < 0 ? 'odd' : 'even'; row.rowclass += itemNameColumn.class.indexOf('hidden') >= 0 ? ' hidden' : ''; row.rowclass += itemNameColumn.class.indexOf('dimmed_text') >= 0 ? ' dimmed_text' : ''; content = content.replace(/<\/span>/gi, '\n'); content = CoreTextUtils.cleanTags(content); name = 'gradeitem'; } else { content = CoreTextUtils.replaceNewLines(content, '
'); } if (row.itemtype !== 'category') { row.expandable = true; row.expanded = false; row.detailsid = `grade-item-${row.id}-details`; row.ariaLabel = `${row.gradeitem} (${row.grade})`; } if (content == ' ') { content = ''; } row[name] = content.trim(); } return row; } /** * Removes suffix formatted to compatibilize data from table and items. * * @param item Grade item to format. * @return Grade item formatted. */ protected formatGradeItem(item: CoreGradesGradeItem): CoreGradesFormattedItem { for (const name in item) { const index = name.indexOf('formatted'); if (index > 0) { item[name.substr(0, index)] = item[name]; } } return item; } /** * Formats the response of gradereport_user_get_grades_table to be rendered. * * @param table JSON object representing a table with data. * @return Formatted HTML table. */ async formatGradesTable(table: CoreGradesTable): Promise { const maxDepth = table.maxdepth; const formatted: CoreGradesFormattedTable = { columns: [], rows: [], }; // Columns, in order. const columns = { gradeitem: true, weight: false, grade: false, range: false, percentage: false, lettergrade: false, rank: false, average: false, feedback: false, contributiontocoursetotal: false, }; formatted.rows = await Promise.all(table.tabledata.map(row => this.formatGradeRowForTable(row))); // Get a row with some info. let normalRow = formatted.rows.find( row => row.itemtype != 'leader' && (row.grade !== undefined || row.percentage !== undefined), ); // Decide if grades or percentage is being shown on phones. if (normalRow && normalRow.grade !== undefined) { columns.grade = true; } else if (normalRow && normalRow.percentage !== undefined) { columns.percentage = true; } else { normalRow = formatted.rows.find((e) => e.itemtype != 'leader'); columns.grade = true; } for (const colName in columns) { if (normalRow && normalRow[colName] !== undefined) { formatted.columns.push({ name: colName, colspan: colName == 'gradeitem' ? maxDepth : 1, hiddenPhone: !columns[colName], }); } } return formatted; } /** * Get course data for grades since they only have courseid. * * @param grades Grades to get the data for. * @return Promise always resolved. Resolve param is the formatted grades. */ async getGradesCourseData(grades: CoreGradesGradeOverview[]): Promise { // Obtain courses from cache to prevent network requests. let coursesWereMissing = false; try { const courses = await CoreCourses.getUserCourses(undefined, undefined, CoreSitesReadingStrategy.ONLY_CACHE); const coursesMap = CoreUtils.arrayToObject(courses, 'id'); coursesWereMissing = this.addCourseData(grades, coursesMap); } catch { coursesWereMissing = true; } // If any course wasn't found, make a network request. if (coursesWereMissing) { const courses = await CoreCourses.getCoursesByField('ids', grades.map((grade) => grade.courseid).join(',')); const coursesMap = CoreUtils.arrayToObject(courses as Record[], 'id') as Record | Record; this.addCourseData(grades, coursesMap); } return (grades as Record[]) .filter(grade => 'courseFullName' in grade) as CoreGradesGradeOverviewWithCourseData[]; } /** * Adds course data to grades. * * @param grades Array of grades to populate. * @param courses HashMap of courses to read data from. * @return Boolean indicating if some courses were not found. */ protected addCourseData( grades: CoreGradesGradeOverview[], courses: Record | Record, ): boolean { let someCoursesAreMissing = false; for (const grade of grades) { if (!(grade.courseid in courses)) { someCoursesAreMissing = true; continue; } (grade as CoreGradesGradeOverviewWithCourseData).courseFullName = courses[grade.courseid].fullname; } return someCoursesAreMissing; } /** * Get an specific grade item. * * @param courseId ID of the course to get the grades from. * @param gradeId Grade ID. * @param userId ID of the user to get the grades from. If not defined use site's current user. * @param siteId Site ID. If not defined, current site. * @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down). * @return Promise to be resolved when the grades are retrieved. * @deprecated since app 4.0 */ async getGradeItem( courseId: number, gradeId: number, userId?: number, siteId?: string, ignoreCache: boolean = false, ): Promise { const grades = await CoreGrades.getCourseGradesTable(courseId, userId, siteId, ignoreCache); if (!grades) { throw new CoreError('Couldn\'t get grade item'); } return this.getGradesTableRow(grades, gradeId); } /** * Returns the label of the selected grade. * * @param grades Array with objects with value and label. * @param selectedGrade Selected grade value. * @return Selected grade label. */ getGradeLabelFromValue(grades: CoreGradesMenuItem[], selectedGrade?: number): string { selectedGrade = Number(selectedGrade); if (!grades || !selectedGrade || selectedGrade <= 0) { return ''; } const grade = grades.find((grade) => grade.value == selectedGrade); return grade ? grade.label : ''; } /** * Get the grade items for a certain module. Keep in mind that may have more than one item to include outcomes and scales. * * @param courseId ID of the course to get the grades from. * @param moduleId Module ID. * @param userId ID of the user to get the grades from. If not defined use site's current user. * @param groupId ID of the group to get the grades from. Not used for old gradebook table. * @param siteId Site ID. If not defined, current site. * @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down). * @return Promise to be resolved when the grades are retrieved. */ async getGradeModuleItems( courseId: number, moduleId: number, userId?: number, groupId?: number, siteId?: string, ignoreCache: boolean = false, ): Promise { const grades = await CoreGrades.getGradeItems(courseId, userId, groupId, siteId, ignoreCache); return grades.filter((item) => item.cmid == moduleId).map((item) => this.formatGradeItem(item)); } /** * Returns the value of the selected grade. * * @param grades Array with objects with value and label. * @param selectedGrade Selected grade label. * @return Selected grade value. */ getGradeValueFromLabel(grades: CoreMenuItem[], selectedGrade?: string): number { if (!grades || !selectedGrade) { return 0; } const grade = grades.find((grade) => grade.label == selectedGrade); return !grade || grade.value < 0 ? 0 : grade.value; } /** * Gets the link to the module for the selected grade. * * @param text HTML where the link is present. * @return URL linking to the module. */ protected getModuleLink(text: string): string | false { const el = CoreDomUtils.toDom(text)[0]; const link = el.attributes['href'] ? el.attributes['href'].value : false; if (!link || link.indexOf('/mod/') < 0) { return false; } return link; } /** * Get a row from the grades table. * * @param table JSON object representing a table with data. * @param gradeId Grade Object identifier. * @return Formatted HTML table. * @deprecated since app 4.0 */ async getGradesTableRow(table: CoreGradesTable, gradeId: number): Promise { if (table.tabledata) { const selectedRow = table.tabledata.find( (row) => row.itemname && row.itemname.id && row.itemname.id.substr(0, 3) == 'row' && parseInt(row.itemname.id.split('_')[1], 10) == gradeId, ); if (selectedRow) { return await this.formatGradeRow(selectedRow); } } return null; } /** * Get the rows related to a module from the grades table. * * @param table JSON object representing a table with data. * @param moduleId Grade Object identifier. * @return Formatted HTML table. * @deprecated since app 4.0 */ async getModuleGradesTableRows(table: CoreGradesTable, moduleId: number): Promise { if (!table.tabledata) { return []; } // Find href containing "/mod/xxx/xxx.php". const regex = /href="([^"]*\/mod\/[^"|^/]*\/[^"|^.]*\.php[^"]*)/; return await Promise.all(table.tabledata.filter((row) => { if (row.itemname && row.itemname.content) { const matches = row.itemname.content.match(regex); if (matches && matches.length) { const hrefParams = CoreUrlUtils.extractUrlParams(matches[1]); return hrefParams && parseInt(hrefParams.id) === moduleId; } } return false; }).map((row) => this.formatGradeRow(row))); } /** * Go to view grades. * * @param courseId Course ID to view. * @param userId User to view. If not defined, current user. * @param moduleId Module to view. If not defined, view all course grades. * @param siteId Site ID. If not defined, current site. * @return Promise resolved when done. */ async goToGrades( courseId: number, userId?: number, moduleId?: number, siteId?: string, ): Promise { const modal = await CoreDomUtils.showModalLoading(); const site = await CoreSites.getSite(siteId); siteId = site.id; const currentUserId = site.getUserId(); try { if (!moduleId) { throw new CoreError('Invalid moduleId'); } // Try to open the module grade directly. const items = await CoreGrades.getGradeItems(courseId, userId, undefined, siteId); // Find the item of the module. const item = Array.isArray(items) && items.find((item) => moduleId == item.cmid); if (!item) { throw new CoreError('Grade item not found.'); } // Open the item directly. const gradeId = item.id; await CoreUtils.ignoreErrors( CoreNavigator.navigateToSitePath(`/${GRADES_PAGE_NAME}/${courseId}/${gradeId}`, { siteId }), ); } catch (error) { try { // Cannot get grade items or there's no need to. if (userId && userId != currentUserId) { // View another user grades. Open the grades page directly. await CoreUtils.ignoreErrors( CoreNavigator.navigateToSitePath(`/${GRADES_PAGE_NAME}/${courseId}`, { siteId }), ); } // View own grades. Check if we already are in the course index page. if (CoreCourse.currentViewIsCourse(courseId)) { // Current view is this course, just select the grades tab. CoreCourse.selectCourseTab('CoreGrades'); return; } // Open the course with the grades tab selected. await CoreCourseHelper.getAndOpenCourse(courseId, { selectedTab: 'CoreGrades' }, siteId); } catch (error) { // Cannot get course for some reason, just open the grades page. await CoreNavigator.navigateToSitePath(`/${GRADES_PAGE_NAME}/${courseId}`, { siteId }); } } finally { modal.dismiss(); } } /** * Invalidate the grade items for a certain module. * * @param courseId ID of the course to invalidate the grades. * @param userId ID of the user to invalidate. If not defined use site's current user. * @param groupId ID of the group to invalidate. Not used for old gradebook table. * @param siteId Site ID. If not defined, current site. * @return Promise to be resolved when the grades are invalidated. */ async invalidateGradeModuleItems(courseId: number, userId?: number, groupId?: number, siteId?: string): Promise { siteId = siteId || CoreSites.getCurrentSiteId(); const site = await CoreSites.getSite(siteId); userId = userId || site.getUserId(); return CoreGrades.invalidateCourseGradesItemsData(courseId, userId, groupId, siteId); } /** * Parses the image and sets it to the row. * * @param row Formatted grade row object. * @param text HTML where the image will be rendered. * @return Row object with the image. */ protected async setRowIcon(row: T, text: string): Promise { text = text.replace('%2F', '/').replace('%2f', '/'); if (text.indexOf('/agg_mean') > -1) { row.itemtype = 'agg_mean'; row.image = 'assets/img/grades/agg_mean.svg'; row.iconAlt = Translate.instant('core.grades.aggregatemean'); } else if (text.indexOf('/agg_sum') > -1) { row.itemtype = 'agg_sum'; row.image = 'assets/img/grades/agg_sum.svg'; row.iconAlt = Translate.instant('core.grades.aggregatesum'); } else if (text.indexOf('/outcomes') > -1 || text.indexOf('fa-tasks') > -1) { row.itemtype = 'outcome'; row.icon = 'fas-chart-pie'; row.iconAlt = Translate.instant('core.grades.outcome'); } else if (text.indexOf('i/folder') > -1 || text.indexOf('fa-folder') > -1) { row.itemtype = 'category'; row.icon = 'fas-folder'; row.iconAlt = Translate.instant('core.grades.category'); } else if (text.indexOf('/manual_item') > -1 || text.indexOf('fa-square-o') > -1) { row.itemtype = 'manual'; row.icon = 'far-square'; row.iconAlt = Translate.instant('core.grades.manualitem'); } else if (text.indexOf('/calc') > -1 || text.indexOf('fa-calculator') > -1) { row.itemtype = 'calc'; row.icon = 'fas-calculator'; row.iconAlt = Translate.instant('core.grades.calculatedgrade'); } else if (text.indexOf('/mod/') > -1) { const module = text.match(/mod\/([^/]*)\//); if (module?.[1] !== undefined) { row.itemtype = 'mod'; row.itemmodule = module[1]; row.iconAlt = CoreCourse.translateModuleName(row.itemmodule) || ''; row.image = await CoreCourse.getModuleIconSrc( module[1], CoreDomUtils.convertToElement(text).querySelector('img')?.getAttribute('src') ?? undefined, ); } } else { if (row.rowspan && row.rowspan > 1) { row.itemtype = 'category'; row.icon = 'fas-cubes'; row.iconAlt = Translate.instant('core.grades.category'); } else if (text.indexOf('src=') > -1) { row.itemtype = 'unknown'; const src = text.match(/src="([^"]*)"/); row.image = src?.[1]; row.iconAlt = Translate.instant('core.unknown'); } else if (text.indexOf(' -1) { row.itemtype = 'unknown'; const src = text.match(/ { if (gradingType === undefined) { return []; } if (gradingType < 0) { if (scale) { return CoreUtils.makeMenuFromList(scale, defaultLabel, undefined, defaultValue); } if (moduleId) { const gradeInfo = await CoreCourse.getModuleBasicGradeInfo(moduleId); if (gradeInfo && gradeInfo.scale) { return CoreUtils.makeMenuFromList(gradeInfo.scale, defaultLabel, undefined, defaultValue); } } return []; } if (gradingType > 0) { const grades: CoreGradesMenuItem[] = []; if (defaultLabel) { // Key as string to avoid resorting of the object. grades.push({ label: defaultLabel, value: defaultValue, }); } for (let i = gradingType; i >= 0; i--) { grades.push({ label: i + ' / ' + gradingType, value: i, }); } return grades; } return []; } /** * Type guard to check if the param is a CoreGradesGradeItem. * * @param item Param to check. * @return Whether the param is a CoreGradesGradeItem. */ isGradeItem(item: CoreGradesGradeItem | CoreGradesFormattedRow): item is CoreGradesGradeItem { return 'outcomeid' in item; } } export const CoreGradesHelper = makeSingleton(CoreGradesHelperProvider); export type CoreGradesFormattedItem = CoreGradesGradeItem & { weight?: string; // Weight. grade?: string; // The grade formatted. range?: string; // Range formatted. percentage?: string; // Percentage. lettergrade?: string; // Letter grade. average?: string; // Grade average. }; export type CoreGradesFormattedRowCommonData = { icon?: string; rowclass?: string; itemtype?: string; image?: string; itemmodule?: string; iconAlt?: string; rowspan?: number; weight?: string; // Weight column. grade?: string; // Grade column. range?: string;// Range column. percentage?: string; // Percentage column. lettergrade?: string; // Lettergrade column. rank?: string; // Rank column. average?: string; // Average column. feedback?: string; // Feedback column. contributiontocoursetotal?: string; // Contributiontocoursetotal column. }; export type CoreGradesFormattedRow = CoreGradesFormattedRowCommonData & { link?: string | false; itemname?: string; // The item returned data. }; export type CoreGradesFormattedTable = { columns: CoreGradesFormattedTableColumn[]; rows: CoreGradesFormattedTableRow[]; }; export type CoreGradesFormattedTableRow = CoreGradesFormattedRowCommonData & { id?: number; detailsid?: string; colspan?: number; gradeitem?: string; // The item returned data. ariaLabel?: string; expandable?: boolean; expanded?: boolean; }; export type CoreGradesFormattedTableColumn = { name: string; colspan: number; hiddenPhone: boolean; }; /** * Grade overview with course data added by CoreGradesHelperProvider#addCourseData method. */ export type CoreGradesGradeOverviewWithCourseData = CoreGradesGradeOverview & { courseFullName: string; }; /** * Grade menu item created by CoreGradesHelperProvider#makeGradesMenu method. */ export type CoreGradesMenuItem = { label: string; value: string | number; };