2022-01-19 10:39:26 +01:00

746 lines
26 KiB
TypeScript

// (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<CoreGradesFormattedRow> {
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, '<br>');
}
if (content == '&nbsp;') {
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<CoreGradesFormattedTableRow> {
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 = <CoreGradesTableItemNameColumn> 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, '<br>');
}
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 == '&nbsp;') {
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<CoreGradesFormattedTable> {
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<CoreGradesGradeOverviewWithCourseData[]> {
// 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<string, unknown>[], 'id') as
Record<string, CoreEnrolledCourseData> |
Record<string, CoreCourseSearchedData>;
this.addCourseData(grades, coursesMap);
}
return (grades as Record<string, unknown>[])
.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<string, CoreEnrolledCourseData> | Record<string, CoreCourseSearchedData>,
): 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<CoreGradesFormattedRow | null> {
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<CoreGradesFormattedItem[]> {
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<CoreGradesFormattedRow | null> {
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<CoreGradesFormattedRow[]> {
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<void> {
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<void> {
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<T extends CoreGradesFormattedRowCommonData>(row: T, text: string): Promise<T> {
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('<i ') > -1) {
row.itemtype = 'unknown';
const src = text.match(/<i class="(?:[^"]*?\s)?(fa-[a-z0-9-]+)/);
row.icon = src ? src[1] : '';
row.iconAlt = Translate.instant('core.unknown');
}
}
return row;
}
/**
* Creates an array that represents all the current grades that can be chosen using the given grading type.
* Negative numbers are scales, zero is no grade, and positive numbers are maximum grades.
*
* Taken from make_grades_menu on moodlelib.php
*
* @param gradingType If positive, max grade you can provide. If negative, scale Id.
* @param moduleId Module ID. Used to retrieve the scale items when they are not passed as parameter.
* If the user does not have permision to manage the activity an empty list is returned.
* @param defaultLabel Element that will become default option, if not defined, it won't be added.
* @param defaultValue Element that will become default option value. Default ''.
* @param scale Scale csv list String. If not provided, it will take it from the module grade info.
* @return Array with objects with value and label to create a propper HTML select.
*/
async makeGradesMenu(
gradingType?: number,
moduleId?: number,
defaultLabel: string = '',
defaultValue: string | number = '',
scale?: string,
): Promise<CoreGradesMenuItem[]> {
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;
};