From b25c5a675f0a326dfd3a569da77573dd96cd7fb7 Mon Sep 17 00:00:00 2001 From: Peter Staab Date: Mon, 16 May 2022 06:26:35 -0400 Subject: [PATCH 01/35] WIP: change Course model to handle fields --- src/common/models/courses.ts | 85 ++++++++++++------------- tests/unit-tests/courses.spec.ts | 106 +++++++++++++++---------------- 2 files changed, 92 insertions(+), 99 deletions(-) diff --git a/src/common/models/courses.ts b/src/common/models/courses.ts index 39fddfc4..fd680b83 100644 --- a/src/common/models/courses.ts +++ b/src/common/models/courses.ts @@ -1,16 +1,16 @@ import { RequiredFieldsException, Model, Dictionary, generic } from 'src/common/models'; -import { parseNonNegInt, parseBoolean, parseUsername, parseUserRole, UserRole } from './parsers'; +import { parseUserRole } from './parsers'; export interface ParseableCourse { - course_id?: number | string; + course_id?: number; course_name?: string; - visible?: string | number | boolean; + visible?: boolean; course_dates? : ParseableCourseDates; } export interface ParseableCourseDates { - start?: number | string; - end?: number | string; + start?: number; + end?: number; } class CourseDates extends Model { @@ -35,10 +35,14 @@ class CourseDates extends Model { } get start() { return this._start; } - set start(value: number | string) { this._start = parseNonNegInt(value); } + set start(value: number) { this._start = value; } get end() { return this._end; } - set end(value: number | string) { this._end = parseNonNegInt(value); } + set end(value: number) { this._end = value; } + + isValid(): boolean { + return this.start <= this.end; + } } export class Course extends Model { @@ -71,7 +75,7 @@ export class Course extends Model { if (params.course_id != undefined) this.course_id = params.course_id; if (params.course_name != undefined) this.course_name = params.course_name; if (params.visible != undefined) this.visible = params.visible; - super.checkParams(params as Dictionary); + // super.checkParams(params as Dictionary); if (params.course_dates) this.setDates(params.course_dates); } @@ -80,23 +84,21 @@ export class Course extends Model { } get course_id(): number { return this._course_id; } - set course_id(value: string | number) { - this._course_id = parseNonNegInt(value); - } + set course_id(value: number) { this._course_id = value; } get course_name() { return this._course_name; } - set course_name(value: string) { - this._course_name = value; - } + set course_name(value: string) { this._course_name = value; } get visible() { return this._visible; } - set visible(value: string | number | boolean) { - this._visible = parseBoolean(value); - } + set visible(value: boolean) { this._visible = value; } clone() { return new Course(this.toObject() as ParseableCourse); } + + isValid(): boolean { + return this.course_name.length > 0 && this.course_id >= 0 && this.course_dates.isValid(); + } } /** @@ -104,11 +106,11 @@ export class Course extends Model { */ export interface ParseableUserCourse { - course_id?: number | string; - user_id?: number | string; + course_id?: number; + user_id?: number; course_name?: string; username?: string; - visible?: number | string | boolean; + visible?: boolean; role?: string; course_dates?: ParseableCourseDates; } @@ -118,7 +120,7 @@ export class UserCourse extends Model { private _course_name = ''; private _username = ''; private _visible = true; - private _role = UserRole.unknown; + private _role = 'UNKNOWN'; private course_dates = new CourseDates(); static ALL_FIELDS = ['course_id', 'course_name', 'visible', 'course_dates', @@ -134,12 +136,6 @@ export class UserCourse extends Model { constructor(params: ParseableUserCourse = {}) { super(); - if (params.course_name == undefined) { - throw new RequiredFieldsException('course_name'); - } - if (params.username == undefined) { - throw new RequiredFieldsException('username'); - } this.set(params); } @@ -148,9 +144,9 @@ export class UserCourse extends Model { if (params.course_name != undefined) this.course_name = params.course_name; if (params.visible != undefined) this.visible = params.visible; if (params.user_id != undefined) this.user_id = params.user_id; - if (params.username != undefined) this.username = params.username; - if (params.role != undefined) this.role = params.role; - super.checkParams(params as Dictionary); + if (params.username) this.username = params.username; + if (params.role) this.role = params.role; + // super.checkParams(params as Dictionary); } setDates(date_params: ParseableCourseDates = {}) { @@ -158,31 +154,28 @@ export class UserCourse extends Model { } get course_id(): number { return this._course_id; } - set course_id(value: string | number) { - this._course_id = parseNonNegInt(value); - } + set course_id(value: number) { this._course_id = value; } get user_id() { return this._user_id; } - set user_id(value: string | number) { - this._user_id = parseNonNegInt(value); - } + set user_id(value: number) { this._user_id = value;} get username() { return this._username;} - set username(value: string) { - this._username = parseUsername(value); - } + set username(value: string) { this._username = value; } get course_name() { return this._course_name; } - set course_name(value: string) { - this._course_name = value; - } + set course_name(value: string) { this._course_name = value; } get visible() { return this._visible; } - set visible(value: string | number | boolean) { - this._visible = parseBoolean(value); - } + set visible(value: boolean) { this._visible = value; } get role() { return this._role; } - set role(value: string) { this._role = parseUserRole(value); } + set role(value: string) { this._role = value; } + isValid(): boolean { + if (this.course_id < 0) return false; + if (this.user_id < 0) return false; + if (!parseUserRole(this.role)) return false; + if (this.course_name.length === 0) return false; + return true; + } } diff --git a/tests/unit-tests/courses.spec.ts b/tests/unit-tests/courses.spec.ts index 87701154..f5a9c975 100644 --- a/tests/unit-tests/courses.spec.ts +++ b/tests/unit-tests/courses.spec.ts @@ -6,13 +6,11 @@ import { InvalidFieldsException } from 'src/common/models'; describe('Test Course Models', () => { - const default_course_dates = { start: 0, end: 0 }; - const default_course = { course_id: 0, course_name: 'Arithmetic', visible: true, - course_dates: { ...default_course_dates } + course_dates: { start: 0, end: 0 } }; describe('Creation of a Course', () => { @@ -23,6 +21,8 @@ describe('Test Course Models', () => { expect(course.toObject()).toStrictEqual(default_course); + expect(course.isValid()).toBe(true); + }); test('Check that calling all_fields() and params() is correct', () => { @@ -44,35 +44,7 @@ describe('Test Course Models', () => { }); - describe('Checking valid and invalid creation parameters.', () => { - - test('Parsing of undefined and null values', () => { - const course1 = new Course({ course_name: 'Arithmetic' }); - const course2 = new Course({ course_name: 'Arithmetic', course_id: undefined }); - expect(course1).toStrictEqual(course2); - - // the following allow to pass in non-valid parameters for testing - const params = { course_name: 'Arithmetic', course_id: null }; - const course3 = new Course(params as unknown as ParseableCourse); - expect(course1).toStrictEqual(course3); - }); - - test('Create a course with invalid params', () => { - // make a generic object and cast it as a Course - const p = { course_name: 'Arithmetic', CourseNumber: -1 } as unknown as ParseableCourse; - expect(() => { new Course(p);}) - .toThrow(InvalidFieldsException); - }); - - test('Course with invalid course_id', () => { - expect(() => { - new Course({ course_name: 'Arithmetic', course_id: -1 }); - }).toThrow(NonNegIntException); - }); - }); - describe('Updating a course', () => { - test('set fields of a course', () => { const course = new Course({ course_name: 'Arithmetic' }); course.course_id = 5; @@ -81,39 +53,67 @@ describe('Test Course Models', () => { course.course_name = 'Geometry'; expect(course.course_name).toBe('Geometry'); - course.visible = true; - expect(course.visible).toBeTruthy(); - - course.visible = 0; - expect(course.visible).toBeFalsy(); - - course.visible = 'true'; - expect(course.visible).toBeTruthy(); - - course.visible = 'false'; - expect(course.visible).toBeFalsy(); + course.visible = false; + expect(course.visible).toBe(false); + expect(course.isValid()).toBe(true); }); test('set fields of a course using the set method', () => { const course = new Course({ course_name: 'Arithmetic' }); - course.set({ course_id: 5 }); + course.set({ + course_id: 5, + course_name: 'Geometry', + visible: false + }); expect(course.course_id).toBe(5); - - course.set({ course_name: 'Geometry' }); expect(course.course_name).toBe('Geometry'); + expect(course.visible).toBe(false); + expect(course.isValid()).toBe(true); + }); + }); - course.set({ visible: true }); - expect(course.visible).toBeTruthy(); + describe('Checking the course dates', () => { + test('checking for valid course dates', () => { + const course = new Course({ + course_name: 'Arithemetic', + course_dates: {start: 100, end: 100} + }); + expect(course.course_dates.isValid()).toBe(true); + expect(course.isValid()).toBe(true); + }); + }); - course.set({ visible: 'true' }); - expect(course.visible).toBeTruthy(); + describe('Checking valid and invalid creation parameters.', () => { - course.set({ visible: 'false' }); - expect(course.visible).toBeFalsy(); + test('Parsing of undefined and null values', () => { + const course1 = new Course({ course_name: 'Arithmetic' }); + const course2 = new Course({ course_name: 'Arithmetic', course_id: undefined }); + expect(course1).toStrictEqual(course2); + + // the following allow to pass in non-valid parameters for testing + const params = { course_name: 'Arithmetic', course_id: null }; + const course3 = new Course(params as unknown as ParseableCourse); + expect(course1).toStrictEqual(course3); + }); + + test('Create a course with invalid fields', () => { + const c1 = new Course({ course_name: 'Arithmetic', course_id: -1 }); + expect(c1.isValid()).toBe(false); + + const c2 = new Course({ course_name: '', course_id: 0 }); + expect(c2.isValid()).toBe(false); + }); + + test('Create a course with invalid dates', () => { + const c1 = new Course({ + course_name: 'Arithmetic', + course_dates: { start: 100, end: 0} + }); + expect(c1.isValid()).toBe(false); - course.set({ visible: 0 }); - expect(course.visible).toBeFalsy(); }); }); + + }); From e0de746fa21b9cce0ee5a86b471a4550710b4414 Mon Sep 17 00:00:00 2001 From: Peter Staab Date: Thu, 19 May 2022 18:33:33 -0400 Subject: [PATCH 02/35] WIP: updating courses and generic problem sets on improvements. --- src/common/models/courses.ts | 17 ++-- src/common/models/parsers.ts | 56 ++++++------ src/common/models/problem_sets.ts | 40 ++++---- tests/unit-tests/courses.spec.ts | 127 ++++++++++++++++++++++++-- tests/unit-tests/problem_sets.spec.ts | 73 +++++++++++++++ 5 files changed, 254 insertions(+), 59 deletions(-) create mode 100644 tests/unit-tests/problem_sets.spec.ts diff --git a/src/common/models/courses.ts b/src/common/models/courses.ts index fd680b83..415cf1a4 100644 --- a/src/common/models/courses.ts +++ b/src/common/models/courses.ts @@ -1,5 +1,5 @@ import { RequiredFieldsException, Model, Dictionary, generic } from 'src/common/models'; -import { parseUserRole } from './parsers'; +import { isNonNegInt, parseUserRole, isValidUserRole, isValidUsername } from './parsers'; export interface ParseableCourse { course_id?: number; @@ -97,7 +97,7 @@ export class Course extends Model { } isValid(): boolean { - return this.course_name.length > 0 && this.course_id >= 0 && this.course_dates.isValid(); + return this.course_name.length > 0 && isNonNegInt(this.course_id) && this.course_dates.isValid(); } } @@ -169,13 +169,14 @@ export class UserCourse extends Model { set visible(value: boolean) { this._visible = value; } get role() { return this._role; } - set role(value: string) { this._role = value; } + set role(value: string) { this._role = value.toUpperCase(); } + + clone(): UserCourse { + return new UserCourse(this.toObject()); + } isValid(): boolean { - if (this.course_id < 0) return false; - if (this.user_id < 0) return false; - if (!parseUserRole(this.role)) return false; - if (this.course_name.length === 0) return false; - return true; + return isNonNegInt(this.course_id) && isNonNegInt(this.user_id) && isValidUsername(this.username) + && isValidUserRole(this.role) && this.course_name.length > 0 && this.course_dates.isValid(); } } diff --git a/src/common/models/parsers.ts b/src/common/models/parsers.ts index 8f363a5d..90beb804 100644 --- a/src/common/models/parsers.ts +++ b/src/common/models/parsers.ts @@ -3,6 +3,8 @@ * for all of webwork3. */ +import { isValid } from 'ipaddr.js'; + /** * ParseError is a general Error class for any parsing errors. */ @@ -109,41 +111,40 @@ export class UserRoleException extends ParseError { } } -// Parsing functions +// Parsing Regular Expressions + +export const non_neg_int_re = /^\s*(\d+)\s*$/; +export const non_neg_decimal_re = /(^\s*(\d+)(\.\d*)?\s*$)|(^\s*\.\d+\s*$)/; +export const mail_re = /^[\w.]+@([a-zA-Z_.]+)+\.[a-zA-Z]{2,9}$/; +export const username_re = /^[_a-zA-Z]([a-zA-Z._0-9])+$/; + +// Checking functions + +export const isNonNegInt = (v: number | string) => non_neg_int_re.test(`${v}`); +export const isNonNegDecimal = (v: number | string) => non_neg_decimal_re.test(`${v}`); +export const isValidUsername = (v: string) => username_re.test(v) || mail_re.test(v); +export const isValidEmail = (v: string) => mail_re.test(v); + +// Parsing functionis export function parseNonNegInt(val: string | number) { - if (/^\s*(\d+)\s*$/.test(`${val}`)) { - return parseInt(`${val}`); - } else { - throw new NonNegIntException(`The value ${val} is not a non-negative integer`); - } + if (isNonNegInt(val)) return parseInt(`${val}`); + throw new NonNegIntException(`The value ${val} is not a non-negative integer`); } export function parseNonNegDecimal(val: string | number) { - if (/(^\s*(\d+)(\.\d*)?\s*$)|(^\s*\.\d+\s*$)/.test(`${val}`)) { - return parseFloat(`${val}`); - } else { - throw new NonNegDecimalException(`The value ${val} is not a non-negative decimal`); - } + if (isNonNegDecimal(val)) return parseFloat(`${val}`); + throw new NonNegDecimalException(`The value ${val} is not a non-negative decimal`); } -export const mailRE = /^[\w.]+@([a-zA-Z_.]+)+\.[a-zA-Z]{2,9}$/; -export const usernameRE = /^[_a-zA-Z]([a-zA-Z._0-9])+$/; - -export function parseUsername(val: string | undefined) { - if (typeof val === 'string' && (val === '' || mailRE.test(`${val ?? ''}`) || usernameRE.test(`${val}`))) { - return val; - } else { - throw new UsernameParseException(`The value '${val?.toString() ?? ''}' is not a value username`); - } +export function parseUsername(val: string) { + if (isValidUsername(val)) return val; + throw new UsernameParseException(`The value '${val?.toString() ?? ''}' is not a value username`); } -export function parseEmail(val: string | undefined) { - if (typeof val === 'string' && mailRE.test(`${val}`)) { - return val; - } else { - throw new EmailParseException(`The value '${val?.toString() ?? ''}' is not a value email`); - } +export function parseEmail(val: string) { + if (isValidEmail(val)) return val; + throw new EmailParseException(`The value '${val?.toString() ?? ''}' is not a value email`); } const booleanRE = /^([01])|(true)|(false)$/; @@ -177,6 +178,9 @@ export enum UserRole { unknown = 'UNKNOWN' } +const user_roles = ['admin', 'instructor','ta','student','unknown']; +export const isValidUserRole = (v: string) => user_roles.includes(v.toLowerCase()); + export function parseUserRole(role: string): UserRole { if (role.toLocaleLowerCase() === 'admin') return UserRole.admin; if (role.toLocaleLowerCase() === 'instructor') return UserRole.instructor; diff --git a/src/common/models/problem_sets.ts b/src/common/models/problem_sets.ts index 33ed00d1..2bcf1624 100644 --- a/src/common/models/problem_sets.ts +++ b/src/common/models/problem_sets.ts @@ -1,6 +1,5 @@ /* These are Problem Set interfaces */ -import { logger } from 'src/boot/logger'; import { Model } from '.'; import { parseBoolean, ParseError, parseNonNegInt } from './parsers'; @@ -11,6 +10,10 @@ export enum ProblemSetType { UNKNOWN = 'UNKNOWN' } +const set_types = ['hw','quiz','review','unknown'] + +export const isValidProblemSetType = (value: string) => set_types.includes(value); + /** * This takes in a general problem set and returns the specific subclassed ProblemSet * @param problem ParseableProblemSet @@ -36,11 +39,11 @@ export type ProblemSetDates = HomeworkSetDates | QuizDates | ReviewSetDates; /* Problem Set (HomeworkSet, Quiz, ReviewSet ) classes */ export interface ParseableProblemSet { - set_id?: string | number; + set_id?: number; set_name?: string; - course_id?: string | number; + course_id?: number; set_type?: string; - set_visible?: string | number | boolean; + set_visible?: boolean; set_params?: ParseableProblemSetParams; set_dates?: ParseableProblemSetDates; } @@ -49,7 +52,7 @@ export class ProblemSet extends Model { private _set_id = 0; private _set_visible = false; private _course_id = 0; - protected _set_type: ProblemSetType = ProblemSetType.UNKNOWN; + protected _set_type = 'UNKNOWN'; private _set_name = ''; constructor(params: ParseableProblemSet = {}) { @@ -68,7 +71,7 @@ export class ProblemSet extends Model { } set(params: ParseableProblemSet) { - if (params.set_id) this.set_id = params.set_id; + if (params.set_id !== undefined) this.set_id = params.set_id; if (params.set_visible !== undefined) this.set_visible = params.set_visible; if (params.course_id != undefined) this.course_id = params.course_id; if (params.set_type) this.set_type = params.set_type; @@ -76,36 +79,35 @@ export class ProblemSet extends Model { } public get set_id(): number { return this._set_id;} - public set set_id(value: number | string) { this._set_id = parseNonNegInt(value);} + public set set_id(value: number) { this._set_id = value; } public get course_id(): number { return this._course_id;} - public set course_id(value: number | string) { this._course_id = parseNonNegInt(value);} + public set course_id(value: number) { this._course_id = value;} public get set_visible() : boolean { return this._set_visible;} - public set set_visible(value: number | string | boolean) { this._set_visible = parseBoolean(value);} + public set set_visible(value: boolean) { this._set_visible = value;} - public get set_type(): ProblemSetType { return this._set_type; } - public set set_type(value: string) { - if (value === 'HW') { this._set_type = ProblemSetType.HW;} - else if (value === 'QUIZ') { this._set_type = ProblemSetType.QUIZ;} - else if (value === 'REVIEW') { this._set_type = ProblemSetType.REVIEW_SET;} - else { this._set_type = ProblemSetType.UNKNOWN; } - } + public get set_type(): string { return this._set_type; } + public set set_type(value: string) { this._set_type = value; } public get set_name() : string { return this._set_name;} public set set_name(value: string) { this._set_name = value;} public get set_params(): ProblemSetParams { - throw 'The subclass must override set_params();'; + throw 'The subclass must override set_params()'; } public get set_dates(): ProblemSetDates { - throw 'The subclass must override set_dates();'; + throw 'The subclass must override set_dates()'; } hasValidDates() { throw 'The hasValidDates() method must be overridden.'; } + + isValid() { + throw 'The isValid() method must be overridden.'; + } } export interface ParseableQuizDates { @@ -525,6 +527,6 @@ export function convertSet(old_set: ProblemSet, new_set_type: ProblemSetType) { throw new ParseError('ProblemSetType', `convertSet does not support conversion to ${new_set_type || 'EMPTY'}`); } - if (!new_set.hasValidDates()) logger.error('[problem_sets/convertSet] corrupt dates in conversion of set, TSNH?'); + // if (!new_set.hasValidDates()) logger.error('[problem_sets/convertSet] corrupt dates in conversion of set, TSNH?'); return new_set; } diff --git a/tests/unit-tests/courses.spec.ts b/tests/unit-tests/courses.spec.ts index f5a9c975..fa2b1aef 100644 --- a/tests/unit-tests/courses.spec.ts +++ b/tests/unit-tests/courses.spec.ts @@ -1,8 +1,4 @@ -// tests parsing and handling of users - -import { Course, ParseableCourse } from 'src/common/models/courses'; -import { NonNegIntException } from 'src/common/models/parsers'; -import { InvalidFieldsException } from 'src/common/models'; +import { Course, ParseableCourse, UserCourse } from 'src/common/models/courses'; describe('Test Course Models', () => { @@ -111,9 +107,128 @@ describe('Test Course Models', () => { course_dates: { start: 100, end: 0} }); expect(c1.isValid()).toBe(false); - }); }); + describe('Creating a UserCourse', () => { + const default_user_course = { + course_id: 0, + user_id: 0, + course_name: '', + username: '', + visible: true, + role: 'UNKNOWN', + course_dates : { start: 0, end: 0 } + }; + + describe('Creating a User Course', () => { + test('Create a Valid Course', () => { + const user_course = new UserCourse(); + expect(user_course).toBeInstanceOf(UserCourse); + expect(user_course.toObject()).toStrictEqual(default_user_course); + // The user course is not valid because the course name and username are empty strings + expect(user_course.isValid()).toBe(false); + }); + + test('Check that calling all_fields() and params() is correct', () => { + const user_course_fields = ['course_id', 'user_id', 'course_name', 'username', + 'visible', 'role', 'course_dates']; + const user_course = new UserCourse(); + expect(user_course.all_field_names.sort()).toStrictEqual(user_course_fields.sort()); + expect(UserCourse.ALL_FIELDS.sort()).toStrictEqual(user_course_fields.sort()); + expect(user_course.param_fields).toStrictEqual(['course_dates']); + }); + + test('Check that cloning works', () => { + const user_course = new UserCourse(); + expect(user_course.clone().toObject()).toStrictEqual(default_user_course); + expect(user_course.clone()).toBeInstanceOf(UserCourse); + }); + }); + + describe('Updating a UserCourse', () => { + test('set fields of a user course', () => { + const user_course = new UserCourse({ course_name: 'Arithmetic' }); + user_course.course_id = 5; + expect(user_course.course_id).toBe(5); + + user_course.course_name = 'Geometry'; + expect(user_course.course_name).toBe('Geometry'); + + user_course.visible = false; + expect(user_course.visible).toBe(false); + + user_course.username = 'homer'; + expect(user_course.username).toBe('homer'); + + user_course.role = 'student'; + expect(user_course.role).toBe('STUDENT'); + + expect(user_course.isValid()).toBe(true); + }); + }); + + describe('Checking valid and invalid creation parameters.', () => { + test('Parsing of undefined and null values', () => { + const course1 = new UserCourse({ course_name: 'Arithmetic' }); + const course2 = new UserCourse({ + course_name: 'Arithmetic', + user_id: undefined, + course_id: undefined + }); + expect(course1).toStrictEqual(course2); + + // the following allow to pass in non-valid parameters for testing + const params = { course_name: 'Arithmetic', course_id: null }; + const course3 = new UserCourse(params as unknown as ParseableCourse); + expect(course1).toStrictEqual(course3); + }); + test('Create a course with invalid fields', () => { + const c1 = new UserCourse({ course_name: 'Arithmetic', username: 'homer' }); + expect(c1.isValid()).toBe(true); + + c1.course_name = ''; + expect(c1.isValid()).toBe(false); + + c1.set({course_name: 'Arithmetic', user_id: -1}); + expect(c1.isValid()).toBe(false); + + c1.set({user_id: 10, course_id: -1}); + expect(c1.isValid()).toBe(false); + + c1.course_id = 10; + expect(c1.isValid()).toBe(true); + + c1.role ='wizard'; + expect(c1.isValid()).toBe(false); + + c1.role ='ta'; + expect(c1.isValid()).toBe(true); + + c1.username = ''; + expect(c1.isValid()).toBe(false); + + c1.username = 'invalid user'; + expect(c1.isValid()).toBe(false); + + c1.username = 'homer@msn.com'; + expect(c1.isValid()).toBe(true); + + }); + + test('Create a user course with invalid dates', () => { + const c1 = new UserCourse({ + course_name: 'Arithmetic', + username: 'homer', + course_dates: { start: 100, end: 200} + }); + expect(c1.isValid()).toBe(true); + + c1.setDates({start: 100, end: 0}); + expect(c1.isValid()).toBe(false); + }); + + }); + }); }); diff --git a/tests/unit-tests/problem_sets.spec.ts b/tests/unit-tests/problem_sets.spec.ts new file mode 100644 index 00000000..ba338ebb --- /dev/null +++ b/tests/unit-tests/problem_sets.spec.ts @@ -0,0 +1,73 @@ +import { ProblemSet } from 'src/common/models/problem_sets'; + +describe('Test generic ProblemSets', () => { + const default_problem_set = { + set_id: 0, + set_name: 'set #1', + course_id: 0, + set_type: 'UNKNOWN', + set_params: {}, + set_dates: {} + } + + describe('Creation of a ProblemSet', () => { + test('Create a valid ProblemSet', () => { + const set = new ProblemSet(); + expect(set).toBeInstanceOf(ProblemSet); + }); + + test('Ensure that there are overrides', () => { + const set = new ProblemSet(); + expect(() => {set.set_params;}).toThrowError('The subclass must override set_params()'); + expect(() => {set.set_dates; }).toThrowError('The subclass must override set_dates()'); + expect(() => {set.hasValidDates(); }).toThrowError('The hasValidDates() method must be overridden.'); + expect(() => {set.isValid(); }).toThrowError('The isValid() method must be overridden.'); + expect(() => {set.clone(); }).toThrowError('The clone method must be overridden in a subclass.'); + }); + }); + + + describe('Check setting generic fields', () => { + test('Check that all fields can be set directly', () => { + const set = new ProblemSet(); + set.set_id = 5; + expect(set.set_id).toBe(5); + + set.course_id = 10; + expect(set.course_id).toBe(10); + + set.set_visible = true; + expect(set.set_visible).toBe(true); + + set.set_name = 'Set #1'; + expect(set.set_name).toBe('Set #1'); + }); + + test('Check that calling all_fields() and params() is correct', () => { + const set_fields = ['set_id', 'set_visible', 'course_id', 'set_type', + 'set_name', 'set_params', 'set_dates']; + const problem_set = new ProblemSet(); + + expect(problem_set.all_field_names.sort()).toStrictEqual(set_fields.sort()); + expect(problem_set.param_fields.sort()).toStrictEqual(['set_dates', 'set_params']); + expect(ProblemSet.ALL_FIELDS.sort()).toStrictEqual(set_fields.sort()); + + }); + + + test('Check that all fields can be set using the set() method', () => { + const set = new ProblemSet(); + set.set({set_id: 5}); + expect(set.set_id).toBe(5); + + set.set({course_id: 10}); + expect(set.course_id).toBe(10); + + set.set({set_visible: true}); + expect(set.set_visible).toBe(true); + + set.set({set_name: 'Set #1'}); + expect(set.set_name).toBe('Set #1'); + }); + }); +}); From b7ff6fb8c07f53e821a1ca686ed488a408b7d193 Mon Sep 17 00:00:00 2001 From: Peter Staab Date: Fri, 20 May 2022 07:04:22 -0400 Subject: [PATCH 03/35] WIP: more work on switching models. --- src/common/models/courses.ts | 2 +- src/common/models/parsers.ts | 6 +- src/common/models/problem_sets.ts | 5 +- src/common/models/users.ts | 228 ++++++++-------- tests/unit-tests/course_users.spec.ts | 363 +++++++++++++++----------- tests/unit-tests/courses.spec.ts | 21 +- tests/unit-tests/problem_sets.spec.ts | 20 +- tests/unit-tests/users.spec.ts | 205 ++++++--------- 8 files changed, 411 insertions(+), 439 deletions(-) diff --git a/src/common/models/courses.ts b/src/common/models/courses.ts index 415cf1a4..436624ed 100644 --- a/src/common/models/courses.ts +++ b/src/common/models/courses.ts @@ -1,5 +1,5 @@ import { RequiredFieldsException, Model, Dictionary, generic } from 'src/common/models'; -import { isNonNegInt, parseUserRole, isValidUserRole, isValidUsername } from './parsers'; +import { isNonNegInt, isValidUserRole, isValidUsername } from './parsers'; export interface ParseableCourse { course_id?: number; diff --git a/src/common/models/parsers.ts b/src/common/models/parsers.ts index 90beb804..f7e193b7 100644 --- a/src/common/models/parsers.ts +++ b/src/common/models/parsers.ts @@ -3,8 +3,6 @@ * for all of webwork3. */ -import { isValid } from 'ipaddr.js'; - /** * ParseError is a general Error class for any parsing errors. */ @@ -178,7 +176,7 @@ export enum UserRole { unknown = 'UNKNOWN' } -const user_roles = ['admin', 'instructor','ta','student','unknown']; +const user_roles = ['admin', 'instructor', 'ta', 'student', 'unknown']; export const isValidUserRole = (v: string) => user_roles.includes(v.toLowerCase()); export function parseUserRole(role: string): UserRole { @@ -186,7 +184,7 @@ export function parseUserRole(role: string): UserRole { if (role.toLocaleLowerCase() === 'instructor') return UserRole.instructor; if (role.toLocaleLowerCase() === 'ta') return UserRole.ta; if (role.toLocaleLowerCase() === 'student') return UserRole.student; - throw new UserRoleException(`The value '${role}' is not a valid role.`); + return UserRole.unknown; } export function parseString(_value: string | number | boolean) { diff --git a/src/common/models/problem_sets.ts b/src/common/models/problem_sets.ts index 2bcf1624..683571be 100644 --- a/src/common/models/problem_sets.ts +++ b/src/common/models/problem_sets.ts @@ -10,7 +10,7 @@ export enum ProblemSetType { UNKNOWN = 'UNKNOWN' } -const set_types = ['hw','quiz','review','unknown'] +const set_types = ['hw', 'quiz', 'review', 'unknown']; export const isValidProblemSetType = (value: string) => set_types.includes(value); @@ -527,6 +527,7 @@ export function convertSet(old_set: ProblemSet, new_set_type: ProblemSetType) { throw new ParseError('ProblemSetType', `convertSet does not support conversion to ${new_set_type || 'EMPTY'}`); } - // if (!new_set.hasValidDates()) logger.error('[problem_sets/convertSet] corrupt dates in conversion of set, TSNH?'); + // if (!new_set.hasValidDates()) + // logger.error('[problem_sets/convertSet] corrupt dates in conversion of set, TSNH?'); return new_set; } diff --git a/src/common/models/users.ts b/src/common/models/users.ts index 13715e1c..f29c66d6 100644 --- a/src/common/models/users.ts +++ b/src/common/models/users.ts @@ -1,16 +1,15 @@ - -import { parseNonNegInt, parseUsername, parseBoolean, parseEmail, parseUserRole, - UserRole } from 'src/common/models/parsers'; -import { RequiredFieldsException, Model } from 'src/common/models'; +import { isNonNegInt, isValidUsername, isValidEmail, parseNonNegInt, parseUsername, + parseBoolean, parseEmail, parseUserRole, UserRole } from 'src/common/models/parsers'; +import { Model } from 'src/common/models'; export interface ParseableUser { - user_id?: number | string; + user_id?: number; username?: string; email?: string; first_name?: string; last_name?: string; - is_admin?: string | number | boolean; - student_id?: string | number; + is_admin?: boolean; + student_id?: string; } /** @@ -20,10 +19,10 @@ export class User extends Model { private _user_id = 0; private _username = ''; private _is_admin = false; - private _email?: string; - private _first_name?: string; - private _last_name?: string; - private _student_id?: string; + private _email = ''; + private _first_name = ''; + private _last_name = ''; + private _student_id = ''; static ALL_FIELDS = ['user_id', 'username', 'is_admin', 'email', 'first_name', 'last_name', 'student_id']; @@ -33,16 +32,13 @@ export class User extends Model { constructor(params: ParseableUser = {}) { super(); - if (params.username == undefined) { - throw new RequiredFieldsException('username'); - } this.set(params); } set(params: ParseableUser) { if (params.username) this.username = params.username; - if (params.user_id) this.user_id = params.user_id; - if (params.is_admin) this.is_admin = params.is_admin; + if (params.user_id != undefined) this.user_id = params.user_id; + if (params.is_admin != undefined) this.is_admin = params.is_admin; if (params.email) this.email = params.email; if (params.first_name) this.first_name = params.first_name; if (params.last_name) this.last_name = params.last_name; @@ -51,52 +47,44 @@ export class User extends Model { // otherwise, typescript identifies this as number | string get user_id(): number { return this._user_id; } - set user_id(value: number | string) { - this._user_id = parseNonNegInt(value); - } + set user_id(value: number) { this._user_id = value; } get username() { return this._username; } - set username(value: string) { - this._username = parseUsername(value); - } + set username(value: string) { this._username = value; } get is_admin() { return this._is_admin; } - set is_admin(value: string | number | boolean) { - this._is_admin = parseBoolean(value); - } + set is_admin(value: boolean) { this._is_admin = value; } - get email(): string | undefined { return this._email; } - set email(value: string | undefined) { - if (value != undefined) this._email = parseEmail(value); - } + get email(): string { return this._email; } + set email(value: string) { this._email = value; } - get first_name(): string | undefined { return this._first_name; } - set first_name(value: string | undefined) { - if (value != undefined) this._first_name = value; - } + get first_name(): string { return this._first_name; } + set first_name(value: string) { this._first_name = value; } - get last_name(): string | undefined { return this._last_name; } - set last_name(value: string | undefined) { - if (value != undefined) this._last_name = value; - } + get last_name(): string { return this._last_name; } + set last_name(value: string) { this._last_name = value; } - get student_id(): string | undefined { return this._student_id; } - set student_id(value: string | number | undefined) { - if (value != undefined) this._student_id = `${value}`; - } + get student_id(): string { return this._student_id; } + set student_id(value: string) { this._student_id = value; } clone() { return new User(this.toObject() as ParseableUser); } + + // The email can be the empty string or valid email address. + isValid(): boolean { + return isNonNegInt(this.user_id) && isValidUsername(this.username) && + (this.email === '' || isValidEmail(this.email)); + } } export interface ParseableDBCourseUser { - course_user_id?: number | string; - user_id?: number | string; - course_id?: number | string; + course_user_id?: number; + user_id?: number; + course_id?: number; role?: string; - section?: string | number; - recitation?: string | number; + section?: string; + recitation?: string; } /** @@ -131,53 +119,52 @@ export class DBCourseUser extends Model { } get course_user_id(): number { return this._course_user_id; } - set course_user_id(value: string | number) { - this._course_user_id = parseNonNegInt(value); - } - - get course_id(): number | undefined { return this._course_id; } - set course_id(value: string | number | undefined) { - if (value != undefined) this._course_id = parseNonNegInt(value); - } + set course_user_id(value: number) { this._course_user_id = value; } - get user_id() { return this._user_id; } - set user_id(value: number | string | undefined) { - if (value != undefined) this._user_id = parseNonNegInt(value); - } + get course_id(): number { return this._course_id; } + set course_id(value: number) { this._course_id = value; } - get role(): string | undefined { return this._role; } - set role(value: string | undefined) { - if (value != undefined) this._role = parseUserRole(value); + get user_id(): number { return this._user_id; } + set user_id(value: number) { this._user_id = value; } + + get role(): UserRole { return this._role; } + set role(value: UserRole | string) { + if (typeof value === 'string') { + this._role = parseUserRole(value); + } else { + this._role = value; + } } get section(): string | undefined { return this._section; } - set section(value: string | number | undefined) { - if (value != undefined) this._section = `${value}`; - } + set section(value: string | undefined) { if (value != undefined) this._section = value; } get recitation(): string | undefined { return this._recitation; } - set recitation(value: string | number | undefined) { - if (value != undefined) this._recitation = `${value}`; - } + set recitation(value: string | undefined) { if (value != undefined) this._recitation = value; } clone() { return new DBCourseUser(this.toObject() as ParseableDBCourseUser); } + + isValid(): boolean { + return isNonNegInt(this.user_id) && isNonNegInt(this.course_user_id) && + isNonNegInt(this.course_id); + } } export interface ParseableCourseUser { - course_user_id?: number | string; - user_id?: number | string; - course_id?: number | string; + course_user_id?: number; + user_id?: number; + course_id?: number; username?: string; email?: string; first_name?: string; last_name?: string; - is_admin?: boolean | number | string; + is_admin?: boolean; student_id?: string; - role?: string; - section?: string | number; - recitation?: string | number; + role?: string | UserRole; + section?: string; + recitation?: string; } /** @@ -191,12 +178,12 @@ export class CourseUser extends Model { private _course_id = 0; private _user_id = 0; private _is_admin = false; - private _username?: string; - private _email?: string; - private _first_name?: string; - private _last_name?: string; - private _student_id?: string; - private _role?: UserRole; + private _username = ''; + private _email = ''; + private _first_name = ''; + private _last_name = ''; + private _student_id = ''; + private _role = UserRole.unknown; private _section?: string; private _recitation?: string; @@ -211,10 +198,10 @@ export class CourseUser extends Model { this.set(params); } set(params: ParseableCourseUser) { - if (params.course_user_id) this.course_user_id = params.course_user_id; - if (params.course_id) this.course_id = params.course_id; - if (params.user_id) this.user_id = params.user_id; - if (params.is_admin) this.is_admin = params.is_admin; + if (params.course_user_id != undefined) this.course_user_id = params.course_user_id; + if (params.course_id != undefined) this.course_id = params.course_id; + if (params.user_id != undefined) this.user_id = params.user_id; + if (params.is_admin != undefined) this.is_admin = params.is_admin; if (params.username) this.username = params.username; if (params.email) this.email = params.email; if (params.first_name) this.first_name = params.first_name; @@ -226,66 +213,55 @@ export class CourseUser extends Model { } get course_user_id(): number { return this._course_user_id; } - set course_user_id(value: string | number) { - this._course_user_id = parseNonNegInt(value); - } + set course_user_id(value: number) { this._course_user_id = value; } get course_id(): number { return this._course_id; } - set course_id(value: string | number) { - this._course_id = parseNonNegInt(value); - } + set course_id(value: number) { this._course_id = value; } get user_id(): number { return this._user_id; } - set user_id(value: number | string) { - this._user_id = parseNonNegInt(value); - } + set user_id(value: number) { this._user_id = value; } - get username() { return this._username; } - set username(value: string | undefined) { - if (value != undefined) this._username = parseUsername(value); - } + get username(): string { return this._username; } + set username(value: string) { this._username = value; } - get is_admin() { return this._is_admin; } - set is_admin(value: string | number | boolean | undefined) { - if (value != undefined) this._is_admin = parseBoolean(value); - } + get is_admin(): boolean { return this._is_admin; } + set is_admin(value: boolean) { this._is_admin = value; } - get email(): string | undefined { return this._email; } - set email(value: string | undefined) { - if (value != undefined) this._email = parseEmail(value); - } + get email(): string { return this._email; } + set email(value: string) { this._email = value; } - get role(): string | undefined { return this._role; } - set role(value: string | undefined) { - if (value != undefined) this._role = parseUserRole(value); + get role(): UserRole { return this._role; } + set role(value: UserRole | string) { + if (typeof value === 'string') { + this._role = parseUserRole(value); + } else { + this._role = value; + } } get section(): string | undefined { return this._section; } - set section(value: string | number | undefined) { - if (value != undefined) this._section = `${value}`; - } + set section(value: string | undefined) { if (value != undefined) this._section = value; } get recitation(): string | undefined { return this._recitation; } - set recitation(value: string | number | undefined) { - if (value != undefined) this._recitation = `${value}`; - } + set recitation(value: string | undefined) { if (value != undefined) this._recitation = value; } - get first_name(): string | undefined { return this._first_name; } - set first_name(value: string | undefined) { - if (value != undefined) this._first_name = value; - } + get first_name(): string { return this._first_name; } + set first_name(value: string) { this._first_name = value; } - get last_name(): string | undefined { return this._last_name; } - set last_name(value: string | undefined) { - if (value != undefined) this._last_name = value; - } + get last_name(): string { return this._last_name; } + set last_name(value: string) { this._last_name = value; } - get student_id(): string | undefined { return this._student_id; } - set student_id(value: string | number | undefined) { - if (value != undefined) this._student_id = `${value}`; - } + get student_id(): string { return this._student_id; } + set student_id(value: string) { this._student_id = value; } clone() { return new CourseUser(this.toObject() as ParseableCourseUser); } + + // The email can be the empty string or valid email address. + isValid(): boolean { + return isNonNegInt(this.user_id) && isNonNegInt(this.course_user_id) && + isNonNegInt(this.course_id) && isValidUsername(this.username) && + (this.email === '' || isValidEmail(this.email)); + } } diff --git a/tests/unit-tests/course_users.spec.ts b/tests/unit-tests/course_users.spec.ts index 30b95d65..9c89358a 100644 --- a/tests/unit-tests/course_users.spec.ts +++ b/tests/unit-tests/course_users.spec.ts @@ -7,118 +7,121 @@ // tests parsing and handling of merged users import { EmailParseException, NonNegIntException, UsernameParseException, - UserRoleException } from 'src/common/models/parsers'; + UserRoleException, UserRole } from 'src/common/models/parsers'; import { CourseUser, DBCourseUser, ParseableDBCourseUser } from 'src/common/models/users'; describe('Testing Database and client-side Course Users', () => { - - const default_db_course_user: ParseableDBCourseUser = { - course_user_id: 0, - user_id: 0, - course_id: 0, - role: 'UNKNOWN' - }; - - describe('Testing Database course users', () => { - - test('Create a new database course user', () => { + describe('Creating a DBCourseUser', () => { + const default_db_course_user = { + course_user_id: 0, + course_id: 0, + user_id: 0, + role: UserRole.unknown + }; + test('Checking the creation of a dBCourseUser', () => { const db_course_user = new DBCourseUser(); expect(db_course_user).toBeInstanceOf(DBCourseUser); expect(db_course_user.toObject()).toStrictEqual(default_db_course_user); + expect(db_course_user.isValid()).toBe(true); }); test('Check that calling all_fields() and params() is correct', () => { - const course_user_fields = ['course_user_id', 'course_id', 'user_id', 'role', + const db_course_user_fields = ['course_user_id', 'course_id', 'user_id', 'role', 'section', 'recitation']; - const course_user = new DBCourseUser(); - - expect(course_user.all_field_names.sort()).toStrictEqual(course_user_fields.sort()); - expect(course_user.param_fields.sort()).toStrictEqual([]); - - expect(DBCourseUser.ALL_FIELDS.sort()).toStrictEqual(course_user_fields.sort()); + const course = new DBCourseUser(); + expect(course.all_field_names.sort()).toStrictEqual(db_course_user_fields.sort()); + expect(course.param_fields.sort()).toStrictEqual([]); + expect(DBCourseUser.ALL_FIELDS.sort()).toStrictEqual(db_course_user_fields.sort()); }); - test('Check that cloning a DBCourseUser works', () => { - const course_user = new DBCourseUser({ role: 'student' }); - const course_user2: ParseableDBCourseUser = { ...default_db_course_user }; - course_user2.role = 'STUDENT'; - expect(course_user.clone().toObject()).toStrictEqual(course_user2); - expect(course_user.clone() instanceof DBCourseUser).toBe(true); + test('Check that cloning works', () => { + const db_course_user = new DBCourseUser(); + expect(db_course_user.clone().toObject()).toStrictEqual(default_db_course_user); + expect(db_course_user.clone()).toBeInstanceOf(DBCourseUser); }); + }); - test('create DBCourseUser with invalid role', () => { - expect(() => { - new DBCourseUser({ role: 'superhero' }); - }).toThrow(UserRoleException); - }); + describe('Updating a DBCourseUser', () => { + test('Update DBCourseUser directly', () => { + const db_course_user = new DBCourseUser(); + expect(db_course_user.isValid()).toBe(true); - test('set fields of DBCourseUser', () => { - const course_user = new DBCourseUser(); + db_course_user.course_user_id = 10; + expect(db_course_user.course_user_id).toBe(10); - course_user.role = 'student'; - expect(course_user.role).toBe('STUDENT'); + db_course_user.user_id = 20; + expect(db_course_user.user_id).toBe(20); - course_user.course_id = 34; - expect(course_user.course_id).toBe(34); + db_course_user.course_id = 5; + expect(db_course_user.course_id).toBe(5); - course_user.user_id = 3; - expect(course_user.user_id).toBe(3); + db_course_user.role = UserRole.admin; + expect(db_course_user.role).toBe(UserRole.admin); - course_user.user_id = '5'; - expect(course_user.user_id).toBe(5); + db_course_user.role = 'student'; + expect(db_course_user.role).toBe(UserRole.student); + }); - course_user.section = 2; - expect(course_user.section).toBe('2'); + test('Update DBCourseUser using the set method', () => { + const db_course_user = new DBCourseUser(); + expect(db_course_user.isValid()).toBe(true); - course_user.section = '12'; - expect(course_user.section).toBe('12'); + db_course_user.set({ course_user_id: 10 }); + expect(db_course_user.course_user_id).toBe(10); - }); + db_course_user.set({ user_id: 20 }); + expect(db_course_user.user_id).toBe(20); - test('set fields of DBCourseUser using set()', () => { - const course_user = new DBCourseUser(); - course_user.set({ role: 'student' }); - expect(course_user.role).toBe('STUDENT'); + db_course_user.set({ course_id: 5 }); + expect(db_course_user.course_id).toBe(5); - course_user.set({ course_id: 34 }); - expect(course_user.course_id).toBe(34); + db_course_user.set({ role: UserRole.admin }); + expect(db_course_user.role).toBe(UserRole.admin); - course_user.set({ user_id: 3 }); - expect(course_user.user_id).toBe(3); + db_course_user.set({ role: 'student' }); + expect(db_course_user.role).toBe(UserRole.student); + }); + }); - course_user.set({ user_id: '5' }); - expect(course_user.user_id).toBe(5); + describe('Checking for valid and invalid DBCourseUsers', () => { + test('Checking for invalid course_user_id', () => { + const db_course_user = new DBCourseUser(); + expect(db_course_user.isValid()).toBe(true); - course_user.set({ section: 2 }); - expect(course_user.section).toBe('2'); + db_course_user.course_user_id = -3; + expect(db_course_user.isValid()).toBe(false); - course_user.set({ section: '12' }); - expect(course_user.section).toBe('12'); + db_course_user.course_user_id = 3.14; + expect(db_course_user.isValid()).toBe(false); - }); + db_course_user.course_user_id = 7; + expect(db_course_user.isValid()).toBe(true); - test('set invalid role', () => { - const course_user = new DBCourseUser({ role: 'student' }); - expect(() => { course_user.role = 'superhero';}).toThrow(UserRoleException); }); - test('set invalid user_id', () => { - const course_user = new DBCourseUser(); - expect(() => { course_user.user_id = -1; }).toThrow(NonNegIntException); - expect(() => { course_user.user_id = '-1'; }).toThrow(NonNegIntException); - }); + test('Checking for invalid user_id', () => { + const db_course_user = new DBCourseUser(); + db_course_user.user_id = -5; + expect(db_course_user.isValid()).toBe(false); + + db_course_user.user_id = 1.34; + expect(db_course_user.isValid()).toBe(false); - test('set invalid course_id', () => { - const course_user = new DBCourseUser(); - expect(() => { course_user.course_id = -1;}).toThrow(NonNegIntException); - expect(() => { course_user.course_id = '-1'; }).toThrow(NonNegIntException); + db_course_user.user_id = 5; + expect(db_course_user.isValid()).toBe(true); }); - test('set invalid course_user_id', () => { - const course_user = new DBCourseUser(); - expect(() => { course_user.course_user_id = -1; }).toThrow(NonNegIntException); - expect(() => { course_user.course_user_id = '-1'; }).toThrow(NonNegIntException); + test('Checking for invalid course_id', () => { + const db_course_user = new DBCourseUser(); + db_course_user.course_id = -9; + expect(db_course_user.isValid()).toBe(false); + + db_course_user.course_id = 1.39; + expect(db_course_user.isValid()).toBe(false); + + db_course_user.course_id = 9; + expect(db_course_user.isValid()).toBe(true); }); }); @@ -128,15 +131,19 @@ describe('Testing Database and client-side Course Users', () => { course_user_id: 0, user_id: 0, course_id: 0, - is_admin: false + is_admin: false, + username: '', + email: '', + first_name: '', + last_name: '', + role: 'UNKNOWN', + student_id: '' }; test('Create a Valid CourseUser', () => { - const course_user1 = new CourseUser(); - - expect(course_user1).toBeInstanceOf(CourseUser); - expect(course_user1.toObject()).toStrictEqual(default_course_user); - + const course_user = new CourseUser(); + expect(course_user).toBeInstanceOf(CourseUser); + expect(course_user.toObject()).toStrictEqual(default_course_user); }); test('Check that calling all_fields() and params() is correct', () => { @@ -153,101 +160,139 @@ describe('Testing Database and client-side Course Users', () => { test('Check that cloning a merged user works', () => { const course_user = new CourseUser(); expect(course_user.clone().toObject()).toStrictEqual(default_course_user); - expect(course_user.clone() instanceof CourseUser).toBe(true); - }); + expect(course_user.clone()).toBeInstanceOf(CourseUser); - test('Invalid user_id', () => { - expect(() => { - new CourseUser({ username: 'test', user_id: -1 }); - }).toThrow(NonNegIntException); - expect(() => { - new CourseUser({ username: 'test', user_id: '-1' }); - }).toThrow(NonNegIntException); - expect(() => { - new CourseUser({ username: 'test', user_id: 'one' }); - }).toThrow(NonNegIntException); + // The default user is not valid (The username is the empty string.) + expect(course_user.isValid()).toBe(false); }); - test('Invalid username', () => { - expect(() => { - new CourseUser({ username: '@test' }); - }).toThrow(UsernameParseException); - expect(() => { - new CourseUser({ username: '123test' }); - }).toThrow(UsernameParseException); - expect(() => { - new CourseUser({ username: 'user name' }); - }).toThrow(UsernameParseException); - }); + describe('Setting fields of a CourseUser', () => { + test('Set CourseUser fields directly', () => { + const course_user = new CourseUser(); - test('Invalid email', () => { - expect(() => { - new CourseUser({ username: 'test', email: 'bad email' }); - }).toThrow(EmailParseException); - expect(() => { - new CourseUser({ username: 'test', email: 'user@info@site.com' }); - }).toThrow(EmailParseException); - }); + course_user.username = 'test2'; + expect(course_user.username).toBe('test2'); - test('set invalid role', () => { - const course_user = new CourseUser({ username: 'test', role: 'student' }); - expect(() => { - course_user.set({ role: 'superhero' }); - }).toThrow(UserRoleException); - }); + course_user.email = 'test@site.com'; + expect(course_user.email).toBe('test@site.com'); - test('set fields of CourseUser', () => { - const course_user = new CourseUser({ username: 'test' }); - course_user.set({ role: 'student' }); - expect(course_user.role).toBe('STUDENT'); + course_user.user_id = 15; + expect(course_user.user_id).toBe(15); - course_user.set({ course_id: 34 }); - expect(course_user.course_id).toBe(34); + course_user.first_name = 'Homer'; + expect(course_user.first_name).toBe('Homer'); - course_user.set({ user_id: 3 }); - expect(course_user.user_id).toBe(3); + course_user.last_name = 'Simpson'; + expect(course_user.last_name).toBe('Simpson'); - course_user.set({ user_id: '5' }); - expect(course_user.user_id).toBe(5); + course_user.is_admin = true; + expect(course_user.is_admin).toBe(true); - course_user.set({ section: 2 }); - expect(course_user.section).toBe('2'); + course_user.student_id = '1234'; + expect(course_user.student_id).toBe('1234'); - course_user.set({ section: '12' }); - expect(course_user.section).toBe('12'); + course_user.course_user_id = 10; + expect(course_user.course_user_id).toBe(10); - }); + course_user.course_id = 5; + expect(course_user.course_id).toBe(5); - test('set invalid user_id', () => { - const course_user = new CourseUser({ username: 'test' }); - expect(() => { - course_user.set({ user_id: -1 }); - }).toThrow(NonNegIntException); + course_user.role = UserRole.admin; + expect(course_user.role).toBe(UserRole.admin); - expect(() => { - course_user.set({ user_id: '-1' }); - }).toThrow(NonNegIntException); + course_user.role = 'student'; + expect(course_user.role).toBe(UserRole.student); + }); - }); + test('Update CourseUser using the set method', () => { + const course_user = new CourseUser({ username: 'homer' }); + expect(course_user.isValid()).toBe(true); + + course_user.set({ course_user_id: 10 }); + expect(course_user.course_user_id).toBe(10); + + course_user.set({ user_id: 20 }); + expect(course_user.user_id).toBe(20); + + course_user.set({ course_id: 5 }); + expect(course_user.course_id).toBe(5); + + course_user.set({ role: UserRole.admin }); + expect(course_user.role).toBe(UserRole.admin); + + course_user.set({ role: 'student' }); + expect(course_user.role).toBe(UserRole.student); + + course_user.set({ username: 'test2' }); + expect(course_user.username).toBe('test2'); + + course_user.set({ email: 'test@site.com' }); + expect(course_user.email).toBe('test@site.com'); + + course_user.set({ first_name: 'Homer' }); + expect(course_user.first_name).toBe('Homer'); - test('set invalid course_id', () => { - const course_user = new CourseUser({ username: 'test' }); - expect(() => { - course_user.set({ course_id: -1 }); - }).toThrow(NonNegIntException); - expect(() => { - course_user.set({ course_id: '-1' }); - }).toThrow(NonNegIntException); + course_user.set({ last_name: 'Simpson' }); + expect(course_user.last_name).toBe('Simpson'); + + course_user.set({ is_admin: true }); + expect(course_user.is_admin).toBe(true); + + course_user.set({ student_id: '1234' }); + expect(course_user.student_id).toBe('1234'); + }); }); - test('set invalid course_user_id', () => { - const course_user = new CourseUser({ username: 'test' }); - expect(() => { - course_user.set({ course_user_id: -1 }); - }).toThrow(NonNegIntException); - expect(() => { - course_user.set({ course_user_id: '-1' }); - }).toThrow(NonNegIntException); + describe('Testing for valid and invalid users.', () => { + test('setting invalid user_id', () => { + const user = new CourseUser({ username: 'homer' }); + expect(user.isValid()).toBe(true); + + user.user_id = -15; + expect(user.isValid()).toBe(false); + + user.user_id = 1.23; + expect(user.isValid()).toBe(false); + }); + + test('setting invalid course_user_id', () => { + const course_user = new CourseUser({ username: 'homer' }); + expect(course_user.isValid()).toBe(true); + + course_user.course_user_id = -3; + expect(course_user.isValid()).toBe(false); + + course_user.course_user_id = 3.14; + expect(course_user.isValid()).toBe(false); + + course_user.course_user_id = 7; + expect(course_user.isValid()).toBe(true); + }); + + test('setting invalid course_id', () => { + const course_user = new CourseUser({ username: 'homer' }); + course_user.course_id = -9; + expect(course_user.isValid()).toBe(false); + + course_user.course_id = 1.39; + expect(course_user.isValid()).toBe(false); + + course_user.course_id = 9; + expect(course_user.isValid()).toBe(true); + }); + + test('setting invalid email', () => { + const user = new CourseUser({ username: 'test' }); + expect(user.isValid()).toBe(true); + + user.email = 'bad@email@address.com'; + expect(user.isValid()).toBe(false); + }); + + test('setting invalid username', () => { + const user = new CourseUser({ username: 'my username' }); + expect(user.isValid()).toBe(false); + }); }); }); }); diff --git a/tests/unit-tests/courses.spec.ts b/tests/unit-tests/courses.spec.ts index fa2b1aef..52672f71 100644 --- a/tests/unit-tests/courses.spec.ts +++ b/tests/unit-tests/courses.spec.ts @@ -14,11 +14,8 @@ describe('Test Course Models', () => { test('Create a Valid Course', () => { const course = new Course({ course_name: 'Arithmetic' }); expect(course).toBeInstanceOf(Course); - expect(course.toObject()).toStrictEqual(default_course); - expect(course.isValid()).toBe(true); - }); test('Check that calling all_fields() and params() is correct', () => { @@ -27,9 +24,7 @@ describe('Test Course Models', () => { expect(course.all_field_names.sort()).toStrictEqual(course_fields.sort()); expect(course.param_fields.sort()).toStrictEqual(['course_dates']); - expect(Course.ALL_FIELDS.sort()).toStrictEqual(course_fields.sort()); - }); test('Check that cloning works', () => { @@ -73,7 +68,7 @@ describe('Test Course Models', () => { test('checking for valid course dates', () => { const course = new Course({ course_name: 'Arithemetic', - course_dates: {start: 100, end: 100} + course_dates: { start: 100, end: 100 } }); expect(course.course_dates.isValid()).toBe(true); expect(course.isValid()).toBe(true); @@ -104,7 +99,7 @@ describe('Test Course Models', () => { test('Create a course with invalid dates', () => { const c1 = new Course({ course_name: 'Arithmetic', - course_dates: { start: 100, end: 0} + course_dates: { start: 100, end: 0 } }); expect(c1.isValid()).toBe(false); }); @@ -191,19 +186,19 @@ describe('Test Course Models', () => { c1.course_name = ''; expect(c1.isValid()).toBe(false); - c1.set({course_name: 'Arithmetic', user_id: -1}); + c1.set({ course_name: 'Arithmetic', user_id: -1 }); expect(c1.isValid()).toBe(false); - c1.set({user_id: 10, course_id: -1}); + c1.set({ user_id: 10, course_id: -1 }); expect(c1.isValid()).toBe(false); c1.course_id = 10; expect(c1.isValid()).toBe(true); - c1.role ='wizard'; + c1.role = 'wizard'; expect(c1.isValid()).toBe(false); - c1.role ='ta'; + c1.role = 'ta'; expect(c1.isValid()).toBe(true); c1.username = ''; @@ -221,11 +216,11 @@ describe('Test Course Models', () => { const c1 = new UserCourse({ course_name: 'Arithmetic', username: 'homer', - course_dates: { start: 100, end: 200} + course_dates: { start: 100, end: 200 } }); expect(c1.isValid()).toBe(true); - c1.setDates({start: 100, end: 0}); + c1.setDates({ start: 100, end: 0 }); expect(c1.isValid()).toBe(false); }); diff --git a/tests/unit-tests/problem_sets.spec.ts b/tests/unit-tests/problem_sets.spec.ts index ba338ebb..9af64a67 100644 --- a/tests/unit-tests/problem_sets.spec.ts +++ b/tests/unit-tests/problem_sets.spec.ts @@ -1,17 +1,9 @@ import { ProblemSet } from 'src/common/models/problem_sets'; describe('Test generic ProblemSets', () => { - const default_problem_set = { - set_id: 0, - set_name: 'set #1', - course_id: 0, - set_type: 'UNKNOWN', - set_params: {}, - set_dates: {} - } describe('Creation of a ProblemSet', () => { - test('Create a valid ProblemSet', () => { + test('Test the class of a ProblemSet', () => { const set = new ProblemSet(); expect(set).toBeInstanceOf(ProblemSet); }); @@ -26,7 +18,6 @@ describe('Test generic ProblemSets', () => { }); }); - describe('Check setting generic fields', () => { test('Check that all fields can be set directly', () => { const set = new ProblemSet(); @@ -54,19 +45,18 @@ describe('Test generic ProblemSets', () => { }); - test('Check that all fields can be set using the set() method', () => { const set = new ProblemSet(); - set.set({set_id: 5}); + set.set({ set_id: 5 }); expect(set.set_id).toBe(5); - set.set({course_id: 10}); + set.set({ course_id: 10 }); expect(set.course_id).toBe(10); - set.set({set_visible: true}); + set.set({ set_visible: true }); expect(set.set_visible).toBe(true); - set.set({set_name: 'Set #1'}); + set.set({ set_name: 'Set #1' }); expect(set.set_name).toBe('Set #1'); }); }); diff --git a/tests/unit-tests/users.spec.ts b/tests/unit-tests/users.spec.ts index e8877831..31cdb58c 100644 --- a/tests/unit-tests/users.spec.ts +++ b/tests/unit-tests/users.spec.ts @@ -1,152 +1,119 @@ // tests parsing and handling of users -import { BooleanParseException, EmailParseException, NonNegIntException, - UsernameParseException } from 'src/common/models/parsers'; -import { RequiredFieldsException } from 'src/common/models'; import { User } from 'src/common/models/users'; -const default_user = { - user_id: 0, - username: 'test', - is_admin: false, -}; +describe('Testing User and CourseUsers', () => { + const default_user = { + user_id: 0, + username: '', + is_admin: false, + email: '', + first_name: '', + last_name: '', + student_id: '' + }; -test('Create a default User', () => { - const user = new User({ username: 'test' }); - expect(user instanceof User).toBe(true); - expect(user.toObject()).toStrictEqual(default_user); + describe('Create a new User', () => { + test('Create a default User', () => { + const user = new User(); + expect(user instanceof User).toBe(true); + expect(user.toObject()).toStrictEqual(default_user); + }); -}); + test('Check that calling all_fields() and params() is correct', () => { + const user_fields = ['user_id', 'username', 'is_admin', 'email', 'first_name', + 'last_name', 'student_id']; + const user = new User(); -test('Missing Username', () => { - // missing username - expect(() => { new User();}).toThrow(RequiredFieldsException); -}); + expect(user.all_field_names.sort()).toStrictEqual(user_fields.sort()); + expect(user.param_fields.sort()).toStrictEqual([]); + expect(User.ALL_FIELDS.sort()).toStrictEqual(user_fields.sort()); + }); -test('Check that calling all_fields() and params() is correct', () => { - const user_fields = ['user_id', 'username', 'is_admin', 'email', 'first_name', - 'last_name', 'student_id']; - const user = new User({ username: 'test' }); + test('Check that cloning a User works', () => { + const user = new User(); + expect(user.clone().toObject()).toStrictEqual(default_user); + expect(user.clone()).toBeInstanceOf(User); - expect(user.all_field_names.sort()).toStrictEqual(user_fields.sort()); - expect(user.param_fields.sort()).toStrictEqual([]); + // The default user is not valid. The username cannot be the empty string. + expect(user.isValid()).toBe(false); + }); - expect(User.ALL_FIELDS.sort()).toStrictEqual(user_fields.sort()); + }); -}); + describe('Setting fields of a User', () => { + test('Set User field directly', () => { + const user = new User(); -test('Check that cloning a User works', () => { - const user = new User({ username: 'test' }); - expect(user.clone().toObject()).toStrictEqual(default_user); - expect(user.clone() instanceof User).toBe(true); -}); + user.username = 'test2'; + expect(user.username).toBe('test2'); -test('Invalid user_id', () => { - expect(() => { - new User({ username: 'test', user_id: -1 }); - }).toThrow(NonNegIntException); - expect(() => { - new User({ username: 'test', user_id: '-1' }); - }).toThrow(NonNegIntException); - expect(() => { - new User({ username: 'test', user_id: 'one' }); - }).toThrow(NonNegIntException); - expect(() => { - new User({ username: 'test', user_id: 'false' }); - }).toThrow(NonNegIntException); -}); + user.email = 'test@site.com'; + expect(user.email).toBe('test@site.com'); -test('Invalid username', () => { - expect(() => { - new User({ username: '@test' }); - }).toThrow(UsernameParseException); - expect(() => { - new User({ username: '123test' }); - }).toThrow(UsernameParseException); - expect(() => { - new User({ username: 'user name' }); - }).toThrow(UsernameParseException); -}); + user.user_id = 15; + expect(user.user_id).toBe(15); -test('Invalid email', () => { - expect(() => { - new User({ username: 'test', email: 'bad email' }); - }).toThrow(EmailParseException); - expect(() => { - new User({ username: 'test', email: 'user@info@site.com' }); - }).toThrow(EmailParseException); -}); + user.first_name = 'Homer'; + expect(user.first_name).toBe('Homer'); -test('setting user fields', () => { - const user = new User({ username: 'test' }); + user.last_name = 'Simpson'; + expect(user.last_name).toBe('Simpson'); - user.username = 'test2'; - expect(user.username).toBe('test2'); + user.is_admin = true; + expect(user.is_admin).toBe(true); - user.email = 'test@site.com'; - expect(user.email).toBe('test@site.com'); + user.student_id = '1234'; + expect(user.student_id).toBe('1234'); - user.user_id = 15; - expect(user.user_id).toBe(15); + }); - user.first_name = 'Homer'; - expect(user.first_name).toBe('Homer'); + test('set fields using set() method', () => { + const user = new User({ username: 'test' }); - user.last_name = 'Simpson'; - expect(user.last_name).toBe('Simpson'); + user.set({ username: 'test2' }); + expect(user.username).toBe('test2'); + user.set({ email: 'test@site.com' }); + expect(user.email).toBe('test@site.com'); - user.is_admin = true; - expect(user.is_admin).toBe(true); + user.set({ user_id: 15 }); + expect(user.user_id).toBe(15); - user.is_admin = 1; - expect(user.is_admin).toBe(true); + user.set({ first_name: 'Homer' }); + expect(user.first_name).toBe('Homer'); - user.is_admin = '0'; - expect(user.is_admin).toBe(false); + user.set({ last_name: 'Simpson' }); + expect(user.last_name).toBe('Simpson'); -}); + user.set({ is_admin: true }); + expect(user.is_admin).toBe(true); -test('set fields using set() method', () => { - const user = new User({ username: 'test' }); + user.set({ student_id: '1234' }); + expect(user.student_id).toBe('1234'); + }); + }); - user.set({ username: 'test2' }); - expect(user.username).toBe('test2'); - user.set({ email: 'test@site.com' }); - expect(user.email).toBe('test@site.com'); + describe('Testing for valid and invalid users.', () => { - user.set({ user_id: 15 }); - expect(user.user_id).toBe(15); + test('setting invalid email', () => { + const user = new User({ username: 'test' }); + expect(user.isValid()).toBe(true); - user.set({ first_name: 'Homer' }); - expect(user.first_name).toBe('Homer'); + user.email = 'bad@email@address.com'; + expect(user.isValid()).toBe(false); + }); - user.set({ last_name: 'Simpson' }); - expect(user.last_name).toBe('Simpson'); + test('setting invalid user_id', () => { + const user = new User({ username: 'test' }); + expect(user.isValid()).toBe(true); - user.set({ is_admin: true }); - expect(user.is_admin).toBe(true); - - user.set({ is_admin: 1 }); - expect(user.is_admin).toBe(true); - - user.set({ is_admin: '0' }); - expect(user.is_admin).toBe(false); -}); - -test('setting invalid email', () => { - const user = new User({ username: 'test' }); - expect(() => { user.email = 'bad@email@address.com'; }) - .toThrow(EmailParseException); -}); - -test('setting invalid user_id', () => { - const user = new User({ username: 'test' }); - expect(() => { user.user_id = -15; }) - .toThrow(NonNegIntException); -}); + user.user_id = -15; + expect(user.isValid()).toBe(false); + }); -test('setting invalid admin', () => { - const user = new User({ username: 'test' }); - expect(() => { user.is_admin = 'FALSE'; }) - .toThrow(BooleanParseException); + test('setting invalid username', () => { + const user = new User({ username: 'my username' }); + expect(user.isValid()).toBe(false); + }); + }); }); From fc7dd5dabc9d921c4081deaef7dd6b498b28a113 Mon Sep 17 00:00:00 2001 From: Peter Staab Date: Fri, 20 May 2022 10:43:55 -0400 Subject: [PATCH 04/35] WIP: work on getting the UI simpler with newer models. --- src/common/models/users.ts | 7 +- src/components/common/UserCourses.vue | 6 +- .../AddUsersFromFile.vue | 338 ++++++++++-------- .../AddUsersManually.vue | 78 ++-- src/stores/users.ts | 20 ++ tests/stores/users.spec.ts | 2 +- tests/unit-tests/course_users.spec.ts | 5 +- tests/unit-tests/homework_sets.spec.ts | 5 +- tests/unit-tests/quizzes.spec.ts | 5 +- tests/unit-tests/review_sets.spec.ts | 5 +- 10 files changed, 259 insertions(+), 212 deletions(-) diff --git a/src/common/models/users.ts b/src/common/models/users.ts index f29c66d6..fea8183b 100644 --- a/src/common/models/users.ts +++ b/src/common/models/users.ts @@ -1,5 +1,8 @@ -import { isNonNegInt, isValidUsername, isValidEmail, parseNonNegInt, parseUsername, - parseBoolean, parseEmail, parseUserRole, UserRole } from 'src/common/models/parsers'; +/* This file contains the definitions of a User, DBCourseUser and Course User + in terms of a model. */ + +import { isNonNegInt, isValidUsername, isValidEmail, parseUserRole, UserRole } + from 'src/common/models/parsers'; import { Model } from 'src/common/models'; export interface ParseableUser { diff --git a/src/components/common/UserCourses.vue b/src/components/common/UserCourses.vue index 86e55313..d33e4759 100644 --- a/src/components/common/UserCourses.vue +++ b/src/components/common/UserCourses.vue @@ -66,12 +66,14 @@ export default defineComponent({ name: 'UserCourses', setup() { const session = useSessionStore(); + // If this is the first page on load, then user_course is undefined. the ?? '' prevents + // an error. return { student_courses: computed(() => - session.user_courses.filter(user_course => parseUserRole(user_course.role) === 'STUDENT') + session.user_courses.filter(user_course => parseUserRole(user_course.role ?? '') === 'STUDENT') ), instructor_courses: computed(() => - session.user_courses.filter(user_course => parseUserRole(user_course.role) === 'INSTRUCTOR') + session.user_courses.filter(user_course => parseUserRole(user_course.role ?? '') === 'INSTRUCTOR') ), user: computed(() => session.user) }; diff --git a/src/components/instructor/ClasslistManagerComponents/AddUsersFromFile.vue b/src/components/instructor/ClasslistManagerComponents/AddUsersFromFile.vue index 547c52ce..8023edf7 100644 --- a/src/components/instructor/ClasslistManagerComponents/AddUsersFromFile.vue +++ b/src/components/instructor/ClasslistManagerComponents/AddUsersFromFile.vue @@ -1,100 +1,106 @@ diff --git a/src/components/instructor/ClasslistManagerComponents/AddUsersManually.vue b/src/components/instructor/ClasslistManagerComponents/AddUsersManually.vue index 1d82b032..7773ebac 100644 --- a/src/components/instructor/ClasslistManagerComponents/AddUsersManually.vue +++ b/src/components/instructor/ClasslistManagerComponents/AddUsersManually.vue @@ -10,7 +10,7 @@
- +
- +
- +
- +
- +
- +
diff --git a/src/stores/users.ts b/src/stores/users.ts index a47c4e0e..8ca2a253 100644 --- a/src/stores/users.ts +++ b/src/stores/users.ts @@ -110,6 +110,11 @@ export const useUserStore = defineStore('user', { * @returns {User} -- the updated user. */ async updateUser(user: User): Promise { + if (!user.isValid()) { + logger.error('The updated user is invalid'); + logger.error(JSON.stringify(user.toObject())); + throw 'The updated user is invalid.'; + } const response = await api.put(`users/${user.user_id}`, user.toObject()); if (response.status === 200) { const updated_user = new User(response.data as ParseableUser); @@ -142,6 +147,11 @@ export const useUserStore = defineStore('user', { * @returns {User} the added user */ async addUser(user: User): Promise { + if (!user.isValid()) { + logger.error('The updated user is invalid'); + logger.error(JSON.stringify(user.toObject())); + throw 'The added user is invalid.'; + } const response = await api.post('users', user.toObject()); if (response.status === 200) { const new_user = new User(response.data as ParseableUser); @@ -173,6 +183,11 @@ export const useUserStore = defineStore('user', { * @returns the added course user. */ async addCourseUser(course_user: CourseUser): Promise { + if (!course_user.isValid()) { + logger.error('The added course user is invalid'); + logger.error(JSON.stringify(course_user.toObject())); + throw 'The added course user is invalid'; + } // When sending, only send the DBCourseUser fields. const response = await api.post(`courses/${course_user.course_id}/users`, course_user.toObject(DBCourseUser.ALL_FIELDS)); @@ -192,6 +207,11 @@ export const useUserStore = defineStore('user', { * @returns the updated course user. */ async updateCourseUser(course_user: CourseUser): Promise { + if (!course_user.isValid()) { + logger.error('The updated course user is invalid'); + logger.error(JSON.stringify(course_user.toObject())); + throw 'The updated course user is invalid'; + } const url = `courses/${course_user.course_id || 0}/users/${course_user.user_id ?? 0}`; // When sending, only send the DBCourseUser fields. const response = await api.put(url, course_user.toObject(DBCourseUser.ALL_FIELDS)); diff --git a/tests/stores/users.spec.ts b/tests/stores/users.spec.ts index 6d078b20..dfc24f6c 100644 --- a/tests/stores/users.spec.ts +++ b/tests/stores/users.spec.ts @@ -122,7 +122,7 @@ describe('User store tests', () => { const course_user_to_update = user_store.course_users .find(user => user.user_id === user_not_in_precalc.user_id)?.clone(); if (course_user_to_update) { - course_user_to_update.recitation = 3; + course_user_to_update.recitation = '3'; const updated_user = await user_store.updateCourseUser(course_user_to_update); expect(updated_user).toStrictEqual(course_user_to_update); } diff --git a/tests/unit-tests/course_users.spec.ts b/tests/unit-tests/course_users.spec.ts index 9c89358a..45ef0bc2 100644 --- a/tests/unit-tests/course_users.spec.ts +++ b/tests/unit-tests/course_users.spec.ts @@ -6,9 +6,8 @@ // tests parsing and handling of merged users -import { EmailParseException, NonNegIntException, UsernameParseException, - UserRoleException, UserRole } from 'src/common/models/parsers'; -import { CourseUser, DBCourseUser, ParseableDBCourseUser } from 'src/common/models/users'; +import { UserRole } from 'src/common/models/parsers'; +import { CourseUser, DBCourseUser } from 'src/common/models/users'; describe('Testing Database and client-side Course Users', () => { describe('Creating a DBCourseUser', () => { diff --git a/tests/unit-tests/homework_sets.spec.ts b/tests/unit-tests/homework_sets.spec.ts index 17790754..f7f167ec 100644 --- a/tests/unit-tests/homework_sets.spec.ts +++ b/tests/unit-tests/homework_sets.spec.ts @@ -123,15 +123,12 @@ describe('Tests for Homework Sets', () => { test('Test valid Homework Set params', () => { const set2 = new HomeworkSet(); - set2.set({ set_visible: 1 }); + set2.set({ set_visible: true }); expect(set2.set_visible).toBeTruthy(); set2.set({ set_visible: false }); expect(set2.set_visible).toBeFalsy(); - set2.set({ set_visible: 'true' }); - expect(set2.set_visible).toBeTruthy(); - set2.set({ set_name: 'HW #9' }); expect(set2.set_name).toBe('HW #9'); }); diff --git a/tests/unit-tests/quizzes.spec.ts b/tests/unit-tests/quizzes.spec.ts index 5bb12bd3..a18bf959 100644 --- a/tests/unit-tests/quizzes.spec.ts +++ b/tests/unit-tests/quizzes.spec.ts @@ -126,15 +126,12 @@ describe('Testing for Quizzes', () => { test('Test valid Quiz params', () => { const quiz2 = new Quiz(); - quiz2.set({ set_visible: 1 }); + quiz2.set({ set_visible: true }); expect(quiz2.set_visible).toBeTruthy(); quiz2.set({ set_visible: false }); expect(quiz2.set_visible).toBeFalsy(); - quiz2.set({ set_visible: 'true' }); - expect(quiz2.set_visible).toBeTruthy(); - quiz2.set({ set_name: 'HW #9' }); expect(quiz2.set_name).toBe('HW #9'); }); diff --git a/tests/unit-tests/review_sets.spec.ts b/tests/unit-tests/review_sets.spec.ts index ac38defe..e6bb5d64 100644 --- a/tests/unit-tests/review_sets.spec.ts +++ b/tests/unit-tests/review_sets.spec.ts @@ -110,15 +110,12 @@ describe('Testing for Review Sets', () => { test('Test valid review_set params', () => { const review_set2 = new ReviewSet(); - review_set2.set({ set_visible: 1 }); + review_set2.set({ set_visible: true }); expect(review_set2.set_visible).toBeTruthy(); review_set2.set({ set_visible: false }); expect(review_set2.set_visible).toBeFalsy(); - review_set2.set({ set_visible: 'true' }); - expect(review_set2.set_visible).toBeTruthy(); - review_set2.set({ set_name: 'HW #9' }); expect(review_set2.set_name).toBe('HW #9'); }); From bc83691c04d954ee1c37868f744fced0c832241e Mon Sep 17 00:00:00 2001 From: Peter Staab Date: Fri, 20 May 2022 17:48:05 -0400 Subject: [PATCH 05/35] WIP: fixed loading of users by file. --- src/common/api-requests/user.ts | 17 +- src/common/models/users.ts | 15 +- src/components/admin/AddCourse.vue | 2 +- .../AddUsersFromFile.vue | 147 ++++++------ .../AddUsersManually.vue | 28 ++- tests/unit-tests/course_users.spec.ts | 213 ++++++++++-------- 6 files changed, 241 insertions(+), 181 deletions(-) diff --git a/src/common/api-requests/user.ts b/src/common/api-requests/user.ts index 1b5e7cc9..f0b8036c 100644 --- a/src/common/api-requests/user.ts +++ b/src/common/api-requests/user.ts @@ -1,23 +1,18 @@ import { api } from 'boot/axios'; -import { ParseableCourseUser, ParseableUser } from 'src/common/models/users'; +import { ParseableUser, User } from 'src/common/models/users'; import { ResponseError } from 'src/common/api-requests/interfaces'; -export async function checkIfUserExists(course_id: number, username: string) { - const response = await api.get(`courses/${course_id}/users/${username}/exists`); - if (response.status === 250) { - throw response.data as ResponseError; - } - return response.data as ParseableCourseUser; -} /** - * queries the database to determine the user. + * queries the database to determine if the user exists. * @param {string} username -- the username of the user. + * @return {User} the user if they exist. + * @throws {ResponseError} if the user doesen't exist, this is thrown. */ -export async function getUser(username: string): Promise { +export async function getUser(username: string): Promise { const response = await api.get(`users/${username}`); if (response.status === 200) { - return response.data as ParseableUser; + return new User(response.data as ParseableUser); } else { throw response.data as ResponseError; } diff --git a/src/common/models/users.ts b/src/common/models/users.ts index fea8183b..e531e8a3 100644 --- a/src/common/models/users.ts +++ b/src/common/models/users.ts @@ -3,7 +3,7 @@ import { isNonNegInt, isValidUsername, isValidEmail, parseUserRole, UserRole } from 'src/common/models/parsers'; -import { Model } from 'src/common/models'; +import { Dictionary, Model } from 'src/common/models'; export interface ParseableUser { user_id?: number; @@ -265,6 +265,17 @@ export class CourseUser extends Model { isValid(): boolean { return isNonNegInt(this.user_id) && isNonNegInt(this.course_user_id) && isNonNegInt(this.course_id) && isValidUsername(this.username) && - (this.email === '' || isValidEmail(this.email)); + this.role !== UserRole.unknown && (this.email === '' || isValidEmail(this.email)); + } + + validate(): Dictionary { + return { + course_id: isNonNegInt(this.course_id) || 'The course_id must be a non negative integer.', + course_user_id: isNonNegInt(this.course_user_id) || 'The course_user_id must be a non negative integer.', + user_id: isNonNegInt(this.user_id) || 'The user_id must be a non negative integer.', + username: isValidUsername(this.username) || 'The username must be valid.', + email: (this.email === '' || isValidEmail(this.email)) || 'The email must be valid', + role: this.role !== UserRole.unknown || 'The role is not valid.', + }; } } diff --git a/src/components/admin/AddCourse.vue b/src/components/admin/AddCourse.vue index 431334d1..0df47150 100644 --- a/src/components/admin/AddCourse.vue +++ b/src/components/admin/AddCourse.vue @@ -201,7 +201,7 @@ export default defineComponent({ await getUser(username.value) .then((_user) => { logger.debug(`[AddCourse/checkUser] Found user: ${username.value}`); - user.value = new User(_user); + user.value.set(_user.toObject()); instructor_exists.value = true; }) .catch((e) => { diff --git a/src/components/instructor/ClasslistManagerComponents/AddUsersFromFile.vue b/src/components/instructor/ClasslistManagerComponents/AddUsersFromFile.vue index 8023edf7..1ca176c6 100644 --- a/src/components/instructor/ClasslistManagerComponents/AddUsersFromFile.vue +++ b/src/components/instructor/ClasslistManagerComponents/AddUsersFromFile.vue @@ -15,7 +15,7 @@
-
+
First row is Header @@ -50,13 +50,13 @@
-
+
@@ -140,9 +140,9 @@ const settings = useSettingsStore(); const $q = useQuasar(); const file = ref(new File([], '')); // Stores all users from the file as well as parsing errors. -const merged_users = ref>([]); +const course_users = ref>([]); // Stores the selected users. -const selected = ref>([]); +const selected_users = ref>([]); const column_headers = ref>({}); // Provides a map from column number to field name. It doesn't need to be reactive. const user_param_map: Dictionary = {}; @@ -152,7 +152,7 @@ const loading = ref(false); // used to indicate parsing is occurring const first_row_header = ref(false); const header_row = ref({}); -const merged_users_to_add = ref>([]); +const course_users_to_add = ref>([]); const selected_user_error = ref(false); const users_already_in_course = ref(false); @@ -186,28 +186,28 @@ const fillHeaders = () => { }); }; -// This converts converts each selected row to a ParseableMerged User +// This converts converts each selected row to a ParseableCourseUser // based on the column headers. -const getMergedUser = (row: UserFromFile) => { +const getCourseUser = (row: UserFromFile) => { // The following pulls the keys out from the user_param_map and the the values out of row // to get the merged user. - const merged_user = Object.entries(user_param_map).reduce( + const course_user = Object.entries(user_param_map).reduce( (acc, [k, v]) => ({ ...acc, [v]: row[k as keyof UserFromFile] }), {} ) as ParseableCourseUser; - // Set the role if a common role for all users is selected. - merged_user.role = use_single_role.value ? common_role.value ?? 'UNKOWN' : 'UNKNOWN'; - return merged_user; + // Set the role if a common role for all users is selected_users. + course_user.role = use_single_role.value ? common_role.value ?? 'UNKOWN' : 'UNKNOWN'; + return course_user; }; // Parse the selected users from the file. const parseUsers = () => { // Clear Errors and reset reactive variables. loading.value = true; - merged_users_to_add.value = []; + course_users_to_add.value = []; selected_user_error.value = false; users_already_in_course.value = false; - merged_users.value + course_users.value .filter((u: UserFromFile) => u._error?.type !== 'none') .forEach((u) => { // reset the error for each selected row @@ -220,59 +220,76 @@ const parseUsers = () => { // This is needed for parsing errors. const inverse_param_map = invert(user_param_map) as Dictionary; - selected.value.forEach((params: UserFromFile) => { + selected_users.value.forEach((params: UserFromFile) => { let parse_error: ParseError | null = null; const row = parseInt(`${params?._row || -1}`); - try { - const merged_user = getMergedUser(params); - // If the user is already in the course, show a warning - const u = users.course_users.find((_u) => _u.username === merged_user.username); - if (u) { - users_already_in_course.value = true; - parse_error = { - type: 'warn', - message: - `The user with username '${merged_user.username ?? ''}'` + - ' is already enrolled in the course.', - entire_row: true, - }; - } else { - merged_users_to_add.value.push(new CourseUser(merged_user)); - } - } catch (error) { - const err = error as ParseError; - selected_user_error.value = true; - + const course_user_params = getCourseUser(params); + // If the user is already in the course, show a warning + const u = users.course_users.find((_u) => _u.username === course_user_params.username); + if (u) { + users_already_in_course.value = true; parse_error = { - type: 'error', - message: err.message, + type: 'warn', + message: + `The user with username '${course_user_params.username ?? ''}'` + + ' is already enrolled in the course.', + entire_row: true, }; + } else { + const course_user = new CourseUser(course_user_params); + console.log(course_user.toObject()); + console.log(course_user.validate()); + if (course_user.isValid()) { + course_users_to_add.value.push(course_user); + } else { + const validate = course_user.validate(); + // Find the field which didn't validate. + try { + Object.entries(validate).forEach(([k, v]) => { + if (typeof v === 'string') { + throw { + message: v, + field: k, + }; + } + }); + } catch (error) { + const err = error as ParseError; + console.log(err); + selected_user_error.value = true; + + parse_error = { + type: 'error', + message: err.message, + }; - if (err.field === '_all') { - Object.assign(parse_error, { entire_row: true }); - } else if ( - err.field && - (User.ALL_FIELDS.indexOf(err.field) >= 0 || - CourseUser.ALL_FIELDS.indexOf(err.field) >= 0) - ) { - if (inverse_param_map[err.field]) { - parse_error.col = inverse_param_map[err.field]; - } else { - parse_error.entire_row = true; + if (err.field === '_all') { + Object.assign(parse_error, { entire_row: true }); + } else if ( + err.field && + (User.ALL_FIELDS.indexOf(err.field) >= 0 || + CourseUser.ALL_FIELDS.indexOf(err.field) >= 0) + ) { + if (inverse_param_map[err.field]) { + parse_error.col = inverse_param_map[err.field]; + } else { + parse_error.entire_row = true; + } + } else if (err.field != undefined) { + // missing field + parse_error.entire_row = true; + } } - } else if (err.field != undefined) { - // missing field - parse_error.entire_row = true; } } if (parse_error) { - const row_index = merged_users.value.findIndex((u: UserFromFile) => u._row === row); + const row_index = course_users.value.findIndex((u: UserFromFile) => u._row === row); if (row_index >= 0) { // Copy the user, update and splice in. This is needed to make the load file table reactive. - const user = { ...merged_users.value[row_index] }; + const user = { ...course_users.value[row_index] }; user._error = parse_error; - merged_users.value.splice(row_index, 1, user); + course_users.value.splice(row_index, 1, user); } } }); @@ -314,7 +331,7 @@ const loadFile = () => { }); users.push(d); }); - merged_users.value = users; + course_users.value = users; } } }; @@ -322,12 +339,12 @@ const loadFile = () => { // Add the Merged Users to the course. const addMergedUsers = async () => { - for await (const user of merged_users_to_add.value) { + for await (const user of course_users_to_add.value) { user.course_id = session.course.course_id; let global_user: User | undefined; try { // Skip if username is undefined? - global_user = (await getUser(user.username ?? '')) as User; + global_user = await getUser(user.username); } catch (err) { const error = err as ResponseError; // this will occur is the user is not a global user @@ -370,7 +387,7 @@ const addMergedUsers = async () => { } }; -watch([selected, common_role], parseUsers, { deep: true }); +watch([selected_users, common_role], parseUsers, { deep: true }); watch( () => column_headers, @@ -391,22 +408,22 @@ watch( ); watch([first_row_header], () => { - selected.value = []; + selected_users.value = []; if (first_row_header.value) { - const first_row = merged_users.value.shift(); + const first_row = course_users.value.shift(); if (first_row) { header_row.value = first_row; fillHeaders(); } } else { - merged_users.value.unshift(header_row.value); + course_users.value.unshift(header_row.value); } }); const columns = computed(() => { - return merged_users.value.length === 0 + return course_users.value.length === 0 ? [] - : Object.keys(merged_users.value[0]) + : Object.keys(course_users.value[0]) .filter((v: string) => v !== '_row' && v !== '_error') .map((v) => ({ name: v, label: v, field: v })); }); diff --git a/src/components/instructor/ClasslistManagerComponents/AddUsersManually.vue b/src/components/instructor/ClasslistManagerComponents/AddUsersManually.vue index 7773ebac..abf89a5a 100644 --- a/src/components/instructor/ClasslistManagerComponents/AddUsersManually.vue +++ b/src/components/instructor/ClasslistManagerComponents/AddUsersManually.vue @@ -73,12 +73,12 @@ import { ref, computed, defineEmits } from 'vue'; import { useQuasar } from 'quasar'; import { logger } from 'boot/logger'; -import { checkIfUserExists } from 'src/common/api-requests/user'; +import { getUser } from 'src/common/api-requests/user'; import { useUserStore } from 'src/stores/users'; import { useSessionStore } from 'src/stores/session'; import { useSettingsStore } from 'src/stores/settings'; -import { CourseUser, User } from 'src/common/models/users'; +import { CourseUser, ParseableCourseUser, User } from 'src/common/models/users'; import type { ResponseError } from 'src/common/api-requests/interfaces'; import { AxiosError } from 'axios'; import { isValidEmail, isValidUsername, parseNonNegInt } from 'src/common/models/parsers'; @@ -103,16 +103,20 @@ const settings = useSettingsStore(); const checkUser = async () => { // If the user doesn't exist, the catch statement will handle this. try { - const course_id = session.course.course_id; - const user_params = await checkIfUserExists(course_id, course_user.value.username); - const user = new User(user_params); - - user_exists.value = true; - course_user.value.user_id = user.user_id; - course_user.value.username = user.username; - course_user.value.first_name = user.first_name; - course_user.value.last_name = user.last_name; - course_user.value.email = user.email; + const existing_user = await getUser(course_user.value.username); + console.log(existing_user); + course_user.value.set(existing_user.toObject() as ParseableCourseUser); + + // const course_id = session.course.course_id; + // const user_params = await checkIfUserExists(course_id, course_user.value.username); + // const user = new User(user_params); + + // user_exists.value = true; + // course_user.value.user_id = user.user_id; + // course_user.value.username = user.username; + // course_user.value.first_name = user.first_name; + // course_user.value.last_name = user.last_name; + // course_user.value.email = user.email; } catch (err) { const error = err as ResponseError; // this will occur is the user is not a global user diff --git a/tests/unit-tests/course_users.spec.ts b/tests/unit-tests/course_users.spec.ts index 45ef0bc2..f40d69d4 100644 --- a/tests/unit-tests/course_users.spec.ts +++ b/tests/unit-tests/course_users.spec.ts @@ -164,134 +164,167 @@ describe('Testing Database and client-side Course Users', () => { // The default user is not valid (The username is the empty string.) expect(course_user.isValid()).toBe(false); }); + }); + + describe('Setting fields of a CourseUser', () => { + test('Set CourseUser fields directly', () => { + const course_user = new CourseUser(); - describe('Setting fields of a CourseUser', () => { - test('Set CourseUser fields directly', () => { - const course_user = new CourseUser(); + course_user.username = 'test2'; + expect(course_user.username).toBe('test2'); - course_user.username = 'test2'; - expect(course_user.username).toBe('test2'); + course_user.email = 'test@site.com'; + expect(course_user.email).toBe('test@site.com'); - course_user.email = 'test@site.com'; - expect(course_user.email).toBe('test@site.com'); + course_user.user_id = 15; + expect(course_user.user_id).toBe(15); - course_user.user_id = 15; - expect(course_user.user_id).toBe(15); + course_user.first_name = 'Homer'; + expect(course_user.first_name).toBe('Homer'); - course_user.first_name = 'Homer'; - expect(course_user.first_name).toBe('Homer'); + course_user.last_name = 'Simpson'; + expect(course_user.last_name).toBe('Simpson'); - course_user.last_name = 'Simpson'; - expect(course_user.last_name).toBe('Simpson'); + course_user.is_admin = true; + expect(course_user.is_admin).toBe(true); - course_user.is_admin = true; - expect(course_user.is_admin).toBe(true); + course_user.student_id = '1234'; + expect(course_user.student_id).toBe('1234'); - course_user.student_id = '1234'; - expect(course_user.student_id).toBe('1234'); + course_user.course_user_id = 10; + expect(course_user.course_user_id).toBe(10); - course_user.course_user_id = 10; - expect(course_user.course_user_id).toBe(10); + course_user.course_id = 5; + expect(course_user.course_id).toBe(5); + + course_user.role = UserRole.admin; + expect(course_user.role).toBe(UserRole.admin); + + course_user.role = 'student'; + expect(course_user.role).toBe(UserRole.student); + }); - course_user.course_id = 5; - expect(course_user.course_id).toBe(5); + test('Update CourseUser using the set method', () => { + const course_user = new CourseUser({ username: 'homer' }); + expect(course_user.isValid()).toBe(true); - course_user.role = UserRole.admin; - expect(course_user.role).toBe(UserRole.admin); + course_user.set({ course_user_id: 10 }); + expect(course_user.course_user_id).toBe(10); - course_user.role = 'student'; - expect(course_user.role).toBe(UserRole.student); - }); + course_user.set({ user_id: 20 }); + expect(course_user.user_id).toBe(20); - test('Update CourseUser using the set method', () => { - const course_user = new CourseUser({ username: 'homer' }); - expect(course_user.isValid()).toBe(true); + course_user.set({ course_id: 5 }); + expect(course_user.course_id).toBe(5); - course_user.set({ course_user_id: 10 }); - expect(course_user.course_user_id).toBe(10); + course_user.set({ role: UserRole.admin }); + expect(course_user.role).toBe(UserRole.admin); - course_user.set({ user_id: 20 }); - expect(course_user.user_id).toBe(20); + course_user.set({ role: 'student' }); + expect(course_user.role).toBe(UserRole.student); - course_user.set({ course_id: 5 }); - expect(course_user.course_id).toBe(5); + course_user.set({ username: 'test2' }); + expect(course_user.username).toBe('test2'); - course_user.set({ role: UserRole.admin }); - expect(course_user.role).toBe(UserRole.admin); + course_user.set({ email: 'test@site.com' }); + expect(course_user.email).toBe('test@site.com'); - course_user.set({ role: 'student' }); - expect(course_user.role).toBe(UserRole.student); + course_user.set({ first_name: 'Homer' }); + expect(course_user.first_name).toBe('Homer'); - course_user.set({ username: 'test2' }); - expect(course_user.username).toBe('test2'); + course_user.set({ last_name: 'Simpson' }); + expect(course_user.last_name).toBe('Simpson'); - course_user.set({ email: 'test@site.com' }); - expect(course_user.email).toBe('test@site.com'); + course_user.set({ is_admin: true }); + expect(course_user.is_admin).toBe(true); - course_user.set({ first_name: 'Homer' }); - expect(course_user.first_name).toBe('Homer'); + course_user.set({ student_id: '1234' }); + expect(course_user.student_id).toBe('1234'); + }); + }); - course_user.set({ last_name: 'Simpson' }); - expect(course_user.last_name).toBe('Simpson'); + describe('Testing for valid and invalid users.', () => { + test('setting invalid user_id', () => { + const user = new CourseUser({ username: 'homer' }); + expect(user.isValid()).toBe(true); - course_user.set({ is_admin: true }); - expect(course_user.is_admin).toBe(true); + user.user_id = -15; + expect(user.isValid()).toBe(false); - course_user.set({ student_id: '1234' }); - expect(course_user.student_id).toBe('1234'); - }); + user.user_id = 1.23; + expect(user.isValid()).toBe(false); }); - describe('Testing for valid and invalid users.', () => { - test('setting invalid user_id', () => { - const user = new CourseUser({ username: 'homer' }); - expect(user.isValid()).toBe(true); + test('setting invalid course_user_id', () => { + const course_user = new CourseUser({ username: 'homer' }); + expect(course_user.isValid()).toBe(true); + + course_user.course_user_id = -3; + expect(course_user.isValid()).toBe(false); + + course_user.course_user_id = 3.14; + expect(course_user.isValid()).toBe(false); - user.user_id = -15; - expect(user.isValid()).toBe(false); + course_user.course_user_id = 7; + expect(course_user.isValid()).toBe(true); + }); - user.user_id = 1.23; - expect(user.isValid()).toBe(false); - }); + test('setting invalid course_id', () => { + const course_user = new CourseUser({ username: 'homer' }); + course_user.course_id = -9; + expect(course_user.isValid()).toBe(false); - test('setting invalid course_user_id', () => { - const course_user = new CourseUser({ username: 'homer' }); - expect(course_user.isValid()).toBe(true); + course_user.course_id = 1.39; + expect(course_user.isValid()).toBe(false); - course_user.course_user_id = -3; - expect(course_user.isValid()).toBe(false); + course_user.course_id = 9; + expect(course_user.isValid()).toBe(true); + }); - course_user.course_user_id = 3.14; - expect(course_user.isValid()).toBe(false); + test('setting invalid email', () => { + const user = new CourseUser({ username: 'test' }); + expect(user.isValid()).toBe(true); - course_user.course_user_id = 7; - expect(course_user.isValid()).toBe(true); - }); + user.email = 'bad@email@address.com'; + expect(user.isValid()).toBe(false); + }); - test('setting invalid course_id', () => { - const course_user = new CourseUser({ username: 'homer' }); - course_user.course_id = -9; - expect(course_user.isValid()).toBe(false); + test('setting invalid username', () => { + const user = new CourseUser({ username: 'my username' }); + expect(user.isValid()).toBe(false); + }); + }); - course_user.course_id = 1.39; - expect(course_user.isValid()).toBe(false); + describe('Test validation of CourseUsers', () => { + test('Test the validation of the user role', () => { + const user = new CourseUser({ username: 'homer' }); + const validate = user.validate(); + expect(validate.role).toBe('The role is not valid.'); + }); - course_user.course_id = 9; - expect(course_user.isValid()).toBe(true); - }); + test('Test the validation of the course_id', () => { + const user = new CourseUser({ username: 'homer', course_id: -1 }); + const validate = user.validate(); + expect(validate.course_id).toBe('The course_id must be a non negative integer.'); + }); - test('setting invalid email', () => { - const user = new CourseUser({ username: 'test' }); - expect(user.isValid()).toBe(true); + test('Test the validation of the user_id', () => { + const user = new CourseUser({ username: 'homer', user_id: 23.5 }); + const validate = user.validate(); + expect(validate.user_id).toBe('The user_id must be a non negative integer.'); + }); - user.email = 'bad@email@address.com'; - expect(user.isValid()).toBe(false); - }); + test('Test the validation of the course_user_id', () => { + const user = new CourseUser({ username: 'homer', course_user_id: -10 }); + const validate = user.validate(); + expect(validate.course_user_id).toBe('The course_user_id must be a non negative integer.'); + }); - test('setting invalid username', () => { - const user = new CourseUser({ username: 'my username' }); - expect(user.isValid()).toBe(false); - }); + test('Test the validation of the user role', () => { + const user = new CourseUser({ username: 'homer', role: 'programmer' }); + const validate = user.validate(); + expect(validate.role).toBe('The role is not valid.'); }); + }); }); From bd8538bdbb0367f4d4299169005b48286793c155 Mon Sep 17 00:00:00 2001 From: Peter Staab Date: Sat, 21 May 2022 06:35:23 -0400 Subject: [PATCH 06/35] WIP: cleanup --- src/common/models/problems.ts | 28 ++- .../AddUsersFromFile.vue | 207 +++++++++--------- .../AddUsersManually.vue | 12 - tests/unit-tests/set_problems.spec.ts | 4 +- 4 files changed, 126 insertions(+), 125 deletions(-) diff --git a/src/common/models/problems.ts b/src/common/models/problems.ts index cc2a815e..d05577ef 100644 --- a/src/common/models/problems.ts +++ b/src/common/models/problems.ts @@ -1,4 +1,6 @@ -import { MergeError, parseNonNegDecimal, parseNonNegInt, parseUsername } from './parsers'; +// Definition of Problems (SetProblems, LibraryProblems and UserProblems) + +import { isNonNegInt, MergeError, parseNonNegDecimal, parseNonNegInt, parseUsername } from './parsers'; import { Model, Dictionary, generic } from '.'; import { RenderParams, ParseableRenderParams } from './renderer'; import { UserSet } from './user_sets'; @@ -60,9 +62,9 @@ export class Problem extends Model { * ParseableLocationParams stores information about a library problem. */ export interface ParseableLocationParams extends Partial> { - library_id?: string | number; + library_id?: number; file_path?: string; - problem_pool_id?: string | number; + problem_pool_id?: number; } export interface ParseableLibraryProblem { @@ -91,8 +93,8 @@ class ProblemLocationParams extends Model { } public get library_id() : number | undefined { return this._library_id; } - public set library_id(val: string | number | undefined) { - if (val != undefined) this._library_id = parseNonNegInt(val); + public set library_id(val: number | undefined) { + if (val != undefined) this._library_id = val; } public get file_path() : string | undefined { return this._file_path;} @@ -100,8 +102,20 @@ class ProblemLocationParams extends Model { if (value != undefined) this._file_path = value;} public get problem_pool_id() : number | undefined { return this._problem_pool_id; } - public set problem_pool_id(val: string | number | undefined) { - if (val != undefined) this._problem_pool_id = parseNonNegInt(val); + public set problem_pool_id(val: number | undefined) { + if (val != undefined) this._problem_pool_id = val; + } + + clone(): ProblemLocationParams { + return new ProblemLocationParams(this.toObject() as ParseableLocationParams); + } + + // Ensure that the _id fields are non-negative integers and that at least one + // of the three fields are defined. + isValid() { + if (this.library_id != undefined && !isNonNegInt(this.library_id)) return false; + if (this.problem_pool_id != undefined && !isNonNegInt(this.problem_pool_id)) return false; + return this.problem_pool_id != undefined && this.library_id != undefined && this.file_path != undefined; } } diff --git a/src/components/instructor/ClasslistManagerComponents/AddUsersFromFile.vue b/src/components/instructor/ClasslistManagerComponents/AddUsersFromFile.vue index 1ca176c6..f8acfc33 100644 --- a/src/components/instructor/ClasslistManagerComponents/AddUsersFromFile.vue +++ b/src/components/instructor/ClasslistManagerComponents/AddUsersFromFile.vue @@ -1,106 +1,106 @@ diff --git a/t/mojolicious/004_course_users.t b/t/mojolicious/004_course_users.t index 46134b73..f4d2a772 100644 --- a/t/mojolicious/004_course_users.t +++ b/t/mojolicious/004_course_users.t @@ -110,8 +110,8 @@ $t->delete_ok("/webwork3/api/courses/2/users/$new_user_id")->status_is(200) ->content_type_is('application/json;charset=UTF-8')->json_is('/user_id' => $new_user_id); # Delete the added global user -$t->delete_ok("/webwork3/api/users/$new_user_id")->status_is(200) - ->content_type_is('application/json;charset=UTF-8')->json_is('/user_id' => $new_user_id); +$t->delete_ok("/webwork3/api/users/$new_user_id")->status_is(200)->content_type_is('application/json;charset=UTF-8') + ->json_is('/user_id' => $new_user_id); # Logout of the admin user account. $t->post_ok('/webwork3/api/logout')->status_is(200)->json_is('/logged_in' => 0); From e32866574a0ff3569bb83ab7a20851f3faef8275 Mon Sep 17 00:00:00 2001 From: Peter Staab Date: Fri, 17 Jun 2022 15:56:39 -0400 Subject: [PATCH 20/35] FIX: testing errors --- src/common/api-requests/user.ts | 2 +- t/mojolicious/004_course_users.t | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/common/api-requests/user.ts b/src/common/api-requests/user.ts index cc331cd9..63ab37b2 100644 --- a/src/common/api-requests/user.ts +++ b/src/common/api-requests/user.ts @@ -1,7 +1,7 @@ import { api } from 'boot/axios'; import { ParseableUser, User } from 'src/common/models/users'; -import { ResponseError } from 'src/common/api-requests/interfaces'; +import { ResponseError } from 'src/common/api-requests/errors'; /** * Gets the global user in the database given by username. This returns a user or throws a diff --git a/t/mojolicious/004_course_users.t b/t/mojolicious/004_course_users.t index 68348fac..76ec44f9 100644 --- a/t/mojolicious/004_course_users.t +++ b/t/mojolicious/004_course_users.t @@ -110,8 +110,7 @@ $t->delete_ok("/webwork3/api/courses/2/users/$new_user_id")->status_is(200) ->content_type_is('application/json;charset=UTF-8')->json_is('/user_id' => $new_user_id); # Delete the added users. -$t->delete_ok("/webwork3/api/users/$new_user_id")->status_is(200) - ->json_is('/username' => $new_user->{username}); +$t->delete_ok("/webwork3/api/users/$new_user_id")->status_is(200)->json_is('/username' => $new_user->{username}); # Logout of the admin user account. $t->post_ok('/webwork3/api/logout')->status_is(200)->json_is('/logged_in' => 0); From f6a09773b355588a23dd59602345ad7323bfdde9 Mon Sep 17 00:00:00 2001 From: Peter Staab Date: Fri, 17 Jun 2022 16:28:31 -0400 Subject: [PATCH 21/35] FIX: cleanup and using true/false in tests. FIX: cleanup and using true/false in tests. --- t/db/003_users.t | 9 +++++---- t/mojolicious/002_courses.t | 8 ++++---- t/mojolicious/003_users.t | 16 +++++++--------- t/mojolicious/004_course_users.t | 6 +++--- t/mojolicious/005_problem_sets.t | 13 +++++++------ t/mojolicious/006_quizzes.t | 28 +++++++++++++--------------- 6 files changed, 39 insertions(+), 41 deletions(-) diff --git a/t/db/003_users.t b/t/db/003_users.t index 924e34bd..54b43b9d 100644 --- a/t/db/003_users.t +++ b/t/db/003_users.t @@ -17,6 +17,7 @@ use Test::More; use Test::Exception; use Clone qw/clone/; use List::MoreUtils qw/firstval/; +use Mojo::JSON qw/true false/; use DB::Schema; use DB::TestUtils qw/loadCSV removeIDs/; @@ -43,7 +44,7 @@ for my $student (@students) { for my $key (qw/course_name recitation section params role/) { delete $student->{$key}; } - $student->{is_admin} = Mojo::JSON::false; + $student->{is_admin} = false; } # Add the admin user @@ -52,7 +53,7 @@ push( { username => 'admin', email => 'admin@google.com', - is_admin => Mojo::JSON::true, + is_admin => true, first_name => 'Andrea', last_name => 'Administrator', student_id => undef @@ -104,7 +105,7 @@ $user = { first_name => 'Clancy', email => 'wiggam@springfieldpd.gov', student_id => '', - is_admin => Mojo::JSON::false, + is_admin => false, }; my $new_user = $users_rs->addGlobalUser(params => $user); @@ -152,7 +153,7 @@ my $user2 = { first_name => 'Selma', email => 'selma@google.com', student_id => '', - is_admin => Mojo::JSON::false, + is_admin => false, }; my $added_user2 = $users_rs->addGlobalUser(params => $user2); diff --git a/t/mojolicious/002_courses.t b/t/mojolicious/002_courses.t index 1e9c7930..e3196c78 100644 --- a/t/mojolicious/002_courses.t +++ b/t/mojolicious/002_courses.t @@ -4,7 +4,7 @@ use Mojo::Base -strict; use Test::More; use Test::Mojo; -use Mojo::JSON; +use Mojo::JSON qw/true false/; BEGIN { use File::Basename qw/dirname/; @@ -51,11 +51,11 @@ my $new_course_id = $t->tx->res->json('/course_id'); $new_course->{course_id} = $new_course_id; # The default for visible is true: -$new_course->{visible} = Mojo::JSON::true; +$new_course->{visible} = true; is_deeply($new_course, $t->tx->res->json, "addCourse: courses match"); # Update the course -$new_course->{visible} = Mojo::JSON::true; +$new_course->{visible} = true; $t->put_ok("/webwork3/api/courses/$new_course_id" => json => $new_course)->status_is(200) ->json_is('/course_name' => $new_course->{course_name}); @@ -120,7 +120,7 @@ $t->get_ok('/webwork3/api/courses/1')->json_is('/course_name', 'Precalculus'); my $precalc = $t->tx->res->json; ok($precalc->{visible}, 'Testing that visible field is truthy.'); -is($precalc->{visible}, Mojo::JSON::true, 'Testing that the visible field compares to JSON::true'); +is($precalc->{visible}, true, 'Testing that the visible field compares to JSON::true'); ok(JSON::PP::is_bool($precalc->{visible}), 'Testing that the visible field is a JSON boolean'); ok(JSON::PP::is_bool($precalc->{visible}) && $precalc->{visible}, 'testing that the visible field is a JSON::true'); diff --git a/t/mojolicious/003_users.t b/t/mojolicious/003_users.t index b728ef9f..fad7b2a9 100644 --- a/t/mojolicious/003_users.t +++ b/t/mojolicious/003_users.t @@ -4,7 +4,7 @@ use Mojo::Base -strict; use Test::More; use Test::Mojo; -use Mojo::JSON; +use Mojo::JSON qw/true false/; BEGIN { use File::Basename qw/dirname/; @@ -158,18 +158,16 @@ $t->get_ok('/webwork3/api/users/1')->json_is('/username', 'admin'); my $admin_user = $t->tx->res->json; ok($admin_user->{is_admin}, 'testing that is_admin compares to 1.'); -is($admin_user->{is_admin}, Mojo::JSON::true, 'testing that is_admin compares to Mojo::JSON::true'); -ok(JSON::PP::is_bool($admin_user->{is_admin}), 'testing that is_admin is a Mojo::JSON::true or Mojo::JSON::false'); -ok(JSON::PP::is_bool($admin_user->{is_admin}) && $admin_user->{is_admin}, - 'testing that is_admin is a Mojo::JSON::true'); +is($admin_user->{is_admin}, true, 'testing that is_admin compares to true'); +ok(JSON::PP::is_bool($admin_user->{is_admin}), 'testing that is_admin is a true or false'); +ok(JSON::PP::is_bool($admin_user->{is_admin}) && $admin_user->{is_admin}, 'testing that is_admin is a true'); ok(not(JSON::PP::is_bool($admin_user->{user_id})), 'testing that $admin->{user_id} is not a JSON boolean'); ok(!$new_user_from_db->{is_admin}, 'testing new_user->{is_admin} is not truthy.'); -is($new_user_from_db->{is_admin}, Mojo::JSON::false, 'testing that new_user->{is_admin} compares to Mojo::JSON::false'); -ok(JSON::PP::is_bool($new_user_from_db->{is_admin}), - 'testing that new_user->{is_admin} is a Mojo::JSON::true or Mojo::JSON::false'); +is($new_user_from_db->{is_admin}, false, 'testing that new_user->{is_admin} compares to false'); +ok(JSON::PP::is_bool($new_user_from_db->{is_admin}), 'testing that new_user->{is_admin} is a true or false'); ok(JSON::PP::is_bool($new_user_from_db->{is_admin}) && !$new_user_from_db->{is_admin}, - 'testing that new_user->{is_admin} is a Mojo::JSON::false'); + 'testing that new_user->{is_admin} is a false'); done_testing; diff --git a/t/mojolicious/004_course_users.t b/t/mojolicious/004_course_users.t index 76ec44f9..d5821aed 100644 --- a/t/mojolicious/004_course_users.t +++ b/t/mojolicious/004_course_users.t @@ -4,7 +4,7 @@ use Mojo::Base -strict; use Test::More; use Test::Mojo; -use Mojo::JSON; +use Mojo::JSON qw/true false/; BEGIN { use File::Basename qw/dirname/; @@ -62,7 +62,7 @@ my $course_user_params = { role => 'student', course_user_params => { comment => "I love my big sister", - useMathQuill => Mojo::JSON::true + useMathQuill => true } }; @@ -130,7 +130,7 @@ $t->get_ok('/webwork3/api/courses/1/users')->status_is(200)->content_type_is('ap ok($added_user->{course_user_params}->{useMathQuill}, 'Testing that useMathQuill param is truthy.'); is($added_user->{course_user_params}->{useMathQuill}, - Mojo::JSON::true, 'Testing that the useMathQuill param compares to JSON::true'); + true, 'Testing that the useMathQuill param compares to JSON::true'); ok( JSON::PP::is_bool($added_user->{course_user_params}->{useMathQuill}), 'Testing that the useMathQuill param is a JSON boolean' diff --git a/t/mojolicious/005_problem_sets.t b/t/mojolicious/005_problem_sets.t index 4bc8a6ef..99336ba6 100644 --- a/t/mojolicious/005_problem_sets.t +++ b/t/mojolicious/005_problem_sets.t @@ -4,6 +4,7 @@ use Mojo::Base -strict; use Test::More; use Test::Mojo; +use Mojo::JSON qw/true false/; use DateTime::Format::Strptime; @@ -74,7 +75,7 @@ my $new_set_id = $t->tx->res->json('/set_id'); # Check that set_visible is a JSON boolean my $set_visible = $t->tx->res->json('/set_visible'); ok(!$set_visible, 'testing that set_visible is falsy'); -is($set_visible, Mojo::JSON::false, 'Test that set_visible compares to Mojo::JSON::false'); +is($set_visible, false, 'Test that set_visible compares to Mojo::JSON::false'); ok(JSON::PP::is_bool($set_visible), 'Test that set_visible is a JSON boolean'); ok(JSON::PP::is_bool($set_visible) && !$set_visible, 'Test that set_visible is a JSON::false'); @@ -82,13 +83,13 @@ ok(JSON::PP::is_bool($set_visible) && !$set_visible, 'Test that set_visible is a $t->put_ok( "/webwork3/api/courses/2/sets/$new_set_id" => json => { set_name => 'HW #11', - set_visible => Mojo::JSON::true + set_visible => true } )->content_type_is('application/json;charset=UTF-8')->json_is('/set_name' => 'HW #11'); $set_visible = $t->tx->res->json('/set_visible'); ok($set_visible, 'testing that set_visible is truthy'); -is($set_visible, Mojo::JSON::true, 'Test that set_visible compares to Mojo::JSON::true'); +is($set_visible, true, 'Test that set_visible compares to Mojo::JSON::true'); ok(JSON::PP::is_bool($set_visible), 'Test that set_visible is a JSON boolean'); ok(JSON::PP::is_bool($set_visible) && $set_visible, 'Test that set_visible is a JSON:: true'); @@ -114,21 +115,21 @@ $t->get_ok('/webwork3/api/courses/1/sets/1'); my $hw1 = $t->tx->res->json; my $enabled = $hw1->{set_dates}->{enable_reduced_scoring}; ok($enabled, 'testing that enabled_reduced_scoring compares to 1.'); -is($enabled, Mojo::JSON::true, 'testing that enabled_reduced_scoring compares to Mojo::JSON::true'); +is($enabled, true, 'testing that enabled_reduced_scoring compares to Mojo::JSON::true'); ok(JSON::PP::is_bool($enabled), 'testing that enabled_reduced_scoring is a Mojo::JSON::true or Mojo::JSON::false'); ok(JSON::PP::is_bool($enabled) && $enabled, 'testing that enabled_reduced_scoring is a Mojo::JSON::true'); # Check that updating a boolean parameter is working: $t->put_ok( "/webwork3/api/courses/2/sets/$new_set_id" => json => { - set_params => { hide_hint => Mojo::JSON::false } + set_params => { hide_hint => false } } )->content_type_is('application/json;charset=UTF-8'); my $hw2 = $t->tx->res->json; my $hide_hint = $hw2->{set_params}->{hide_hint}; ok(!$hide_hint, 'testing that hide_hint is falsy.'); -is($hide_hint, Mojo::JSON::false, 'testing that hide_hint compares to Mojo::JSON::false'); +is($hide_hint, false, 'testing that hide_hint compares to Mojo::JSON::false'); ok(JSON::PP::is_bool($hide_hint), 'testing that hide_hint is a Mojo::JSON::true or Mojo::JSON::false'); ok(JSON::PP::is_bool($hide_hint) && !$hide_hint, 'testing that hide_hint is a Mojo::JSON::false'); diff --git a/t/mojolicious/006_quizzes.t b/t/mojolicious/006_quizzes.t index 964941c0..660d3990 100644 --- a/t/mojolicious/006_quizzes.t +++ b/t/mojolicious/006_quizzes.t @@ -4,6 +4,7 @@ use Mojo::Base -strict; use Test::More; use Test::Mojo; +use Mojo::JSON qw/true false/; use DateTime::Format::Strptime; @@ -66,9 +67,9 @@ $t->get_ok("/webwork3/api/courses/2/sets/$quiz1->{set_id}")->content_type_is('ap $quiz1 = $t->tx->res->json; my $timed = $quiz1->{set_params}->{timed}; ok($timed, 'testing that timed compares to 1.'); -is($timed, Mojo::JSON::true, 'testing that timed compares to Mojo::JSON::true'); -ok(JSON::PP::is_bool($timed), 'testing that timed is a Mojo::JSON::true or Mojo::JSON::false'); -ok(JSON::PP::is_bool($timed) && $timed, 'testing that timed is a Mojo::JSON::true'); +is($timed, true, 'testing that timed compares to true'); +ok(JSON::PP::is_bool($timed), 'testing that timed is a true or false'); +ok(JSON::PP::is_bool($timed) && $timed, 'testing that timed is a true'); # Make a new quiz @@ -76,9 +77,9 @@ my $new_quiz_params = { set_name => 'Quiz #20', set_type => 'QUIZ', set_params => { - timed => Mojo::JSON::true, + timed => true, quiz_duration => 30, - problem_randorder => Mojo::JSON::true + problem_randorder => true }, set_dates => { open => 100, @@ -95,16 +96,16 @@ my $returned_quiz = $t->tx->res->json; my $new_quiz = $t->tx->res->json; my $problem_randorder = $new_quiz->{set_params}->{problem_randorder}; ok($problem_randorder, 'testing that problem_randorder compares to 1.'); -is($problem_randorder, Mojo::JSON::true, 'testing that problem_randorder compares to Mojo::JSON::true'); -ok(JSON::PP::is_bool($problem_randorder), 'testing that problem_randorder is a Mojo::JSON::true or Mojo::JSON::false'); -ok(JSON::PP::is_bool($problem_randorder) && $problem_randorder, 'testing that problem_randorder is a Mojo::JSON::true'); +is($problem_randorder, true, 'testing that problem_randorder compares to true'); +ok(JSON::PP::is_bool($problem_randorder), 'testing that problem_randorder is a true or false'); +ok(JSON::PP::is_bool($problem_randorder) && $problem_randorder, 'testing that problem_randorder is a true'); # Check that updating a boolean parameter is working: $t->put_ok( "/webwork3/api/courses/2/sets/$returned_quiz->{set_id}" => json => { set_params => { - problem_randorder => Mojo::JSON::false + problem_randorder => false } } )->content_type_is('application/json;charset=UTF-8'); @@ -113,12 +114,9 @@ my $updated_quiz = $t->tx->res->json; $problem_randorder = $updated_quiz->{set_params}->{problem_randorder}; ok(!$problem_randorder, 'testing that hide_hint is falsy.'); -is($problem_randorder, Mojo::JSON::false, 'testing that problem_randorder compares to Mojo::JSON::false'); -ok(JSON::PP::is_bool($problem_randorder), 'testing that problem_randorder is a Mojo::JSON::true or Mojo::JSON::false'); -ok( - JSON::PP::is_bool($problem_randorder) && !$problem_randorder, - 'testing that problem_randorder is a Mojo::JSON::false' -); +is($problem_randorder, false, 'testing that problem_randorder compares to false'); +ok(JSON::PP::is_bool($problem_randorder), 'testing that problem_randorder is a true or false'); +ok(JSON::PP::is_bool($problem_randorder) && !$problem_randorder, 'testing that problem_randorder is a false'); # delete the added quiz $t->delete_ok("/webwork3/api/courses/2/sets/$returned_quiz->{set_id}") From c02696c550442c867920e17636eeccecc4c3fff5 Mon Sep 17 00:00:00 2001 From: Peter Staab Date: Wed, 29 Jun 2022 12:28:16 -0400 Subject: [PATCH 22/35] WIP: check that enable_reduced_scoring is a boolean. --- lib/DB/Schema/Result/ProblemSet.pm | 4 +-- lib/DB/Schema/Result/ProblemSet/HWSet.pm | 11 ++++----- lib/DB/Schema/Result/ProblemSet/Quiz.pm | 2 +- lib/DB/Schema/Result/ProblemSet/ReviewSet.pm | 2 +- lib/DB/Schema/Result/UserSet.pm | 4 +-- lib/DB/WithDates.pm | 21 ++++++++++++++-- t/db/005_hwsets.t | 26 +++++++++++++++----- 7 files changed, 50 insertions(+), 20 deletions(-) diff --git a/lib/DB/Schema/Result/ProblemSet.pm b/lib/DB/Schema/Result/ProblemSet.pm index 5cb598bc..7a8b651f 100644 --- a/lib/DB/Schema/Result/ProblemSet.pm +++ b/lib/DB/Schema/Result/ProblemSet.pm @@ -108,8 +108,8 @@ __PACKAGE__->add_columns( size => 256, is_nullable => 0, default_value => '{}', - serializer_class => 'Boolean::JSON', - serializer_options => { boolean_fields => ['enable_reduced_scoring'] } + serializer_class => 'JSON', + serializer_options => { utf8 => 1 } }, # Store params as a JSON object. set_params => { diff --git a/lib/DB/Schema/Result/ProblemSet/HWSet.pm b/lib/DB/Schema/Result/ProblemSet/HWSet.pm index 690cdff3..43b08645 100644 --- a/lib/DB/Schema/Result/ProblemSet/HWSet.pm +++ b/lib/DB/Schema/Result/ProblemSet/HWSet.pm @@ -28,7 +28,7 @@ sub valid_dates ($=) { } sub optional_fields_in_dates ($=) { - return ['enable_reduced_scoring']; + return { enable_reduced_scoring => 'bool' }; } =head2 C @@ -83,11 +83,10 @@ This is a description of the homework set. sub valid_params ($=) { return { - enable_reduced_scoring => 'bool', - hide_hint => 'bool', - hardcopy_header => q{.*}, - set_header => q{.*}, - description => q{.*} + hide_hint => 'bool', + hardcopy_header => q{.*}, + set_header => q{.*}, + description => q{.*} }; } diff --git a/lib/DB/Schema/Result/ProblemSet/Quiz.pm b/lib/DB/Schema/Result/ProblemSet/Quiz.pm index ac784f23..dbf14196 100644 --- a/lib/DB/Schema/Result/ProblemSet/Quiz.pm +++ b/lib/DB/Schema/Result/ProblemSet/Quiz.pm @@ -27,7 +27,7 @@ sub valid_dates ($=) { return [ 'open', 'due', 'answer' ]; } -sub optional_fields_in_dates ($=) { return []; } +sub optional_fields_in_dates ($=) { return {}; } =head2 C diff --git a/lib/DB/Schema/Result/ProblemSet/ReviewSet.pm b/lib/DB/Schema/Result/ProblemSet/ReviewSet.pm index 40b37bbf..b3f81cf4 100644 --- a/lib/DB/Schema/Result/ProblemSet/ReviewSet.pm +++ b/lib/DB/Schema/Result/ProblemSet/ReviewSet.pm @@ -26,7 +26,7 @@ sub valid_dates ($=) { return [ 'open', 'closed' ]; } -sub optional_fields_in_dates ($=) { return []; } +sub optional_fields_in_dates ($=) { return {}; } =head2 C diff --git a/lib/DB/Schema/Result/UserSet.pm b/lib/DB/Schema/Result/UserSet.pm index b27e5f52..efb82b24 100644 --- a/lib/DB/Schema/Result/UserSet.pm +++ b/lib/DB/Schema/Result/UserSet.pm @@ -122,8 +122,8 @@ __PACKAGE__->add_columns( size => 256, is_nullable => 0, default_value => '{}', - serializer_class => 'Boolean::JSON', - serializer_options => { boolean_fields => ['enable_reduced_scoring'] } + serializer_class => 'JSON', + serializer_options => { utf8 => 1 } }, # Store params as a JSON object. set_params => { diff --git a/lib/DB/WithDates.pm b/lib/DB/WithDates.pm index 2cca1150..e04becef 100644 --- a/lib/DB/WithDates.pm +++ b/lib/DB/WithDates.pm @@ -12,7 +12,7 @@ use DB::Exception; my $valid_dates; # Arrayref of allowed/valid dates my $required_dates; # Arrayref of required dates -my $optional_fields_in_dates; # Arrayref of other non-date fields in the hash. +my $optional_fields_in_dates; # hashref of other non-date fields in the hash and the type. sub validDates ($self, $field_name) { $valid_dates = ref($self)->valid_dates; @@ -23,12 +23,13 @@ sub validDates ($self, $field_name) { $self->hasRequiredDateFields($field_name); $self->validDateFormat($field_name); $self->checkDates($field_name); + $self->validateOptionalFields($field_name); return 1; } sub validDateFields ($self, $field_name) { my @fields = keys %{ $self->get_inflated_column($field_name) }; - my @all_fields = (@$valid_dates, @$optional_fields_in_dates); + my @all_fields = (@$valid_dates, keys %$optional_fields_in_dates); # If this is not empty, there are illegal fields. my @bad_fields = array_minus(@fields, @all_fields); @@ -74,4 +75,20 @@ sub checkDates ($self, $field_name) { return 1; } +# This checks the options fields that aren't dates +sub validateOptionalFields ($self, $field_name) { + my $params_hash = $self->get_inflated_column($field_name); + # if it doesn't exist, it is valid + return 1 unless defined $params_hash; + + for my $key (keys %$optional_fields_in_dates) { + next unless defined $params_hash->{$key}; + my $re = $params_hash->{$key}; + my $valid = $re eq 'bool' ? JSON::PP::is_bool($params_hash->{$key}) : $params_hash->{$key} =~ qr/^$re$/x; + DB::Exception::InvalidParameter->throw( + message => "The parameter named $key is not valid. It has value $params_hash->{$key}") + unless $valid; + } +} + 1; diff --git a/t/db/005_hwsets.t b/t/db/005_hwsets.t index f488610d..e689fd21 100644 --- a/t/db/005_hwsets.t +++ b/t/db/005_hwsets.t @@ -269,9 +269,9 @@ throws_ok { # Check for undefined parameter fields my $new_set7 = { set_name => 'HW #11', - set_dates => { open => 100, due => 140, answer => 200 }, + set_dates => { open => 100, due => 140, answer => 200, enable_reduced_scoring => false }, set_type => 'HW', - set_params => { enable_reduced_scoring => false, not_a_valid_field => 5 } + set_params => { not_a_valid_field => 5 } }; throws_ok { $problem_set_rs->addProblemSet( @@ -289,9 +289,9 @@ throws_ok { params => { course_name => 'Precalculus', set_name => 'HW #11', - set_dates => { open => 100, due => 140, answer => 200 }, + set_dates => { open => 100, due => 140, answer => 200, enable_reduced_scoring => false }, set_type => 'HW', - set_params => { enable_reduced_scoring => false, hide_hint => 'yes' } + set_params => { hide_hint => 'yes' } } ); } @@ -303,14 +303,28 @@ throws_ok { params => { course_name => 'Precalculus', set_name => 'HW #11', - set_dates => { open => 100, due => 140, answer => 200 }, + set_dates => { open => 100, due => 140, answer => 200, enable_reduced_scoring => false }, set_type => 'HW', - set_params => { enable_reduced_scoring => 0, hide_hint => true } + set_params => { hide_hint => 0 } } ); } 'DB::Exception::InvalidParameter', 'addProblemSet: adding an non-valid boolean parameter'; +# Check to ensure true/false are passed into the enable_reduced_scoring in set_dates, not 0/1 +throws_ok { + $problem_set_rs->addProblemSet( + params => { + course_name => 'Precalculus', + set_name => 'HW #11', + set_dates => { open => 100, due => 140, answer => 200, enable_reduced_scoring => 0 }, + set_type => 'HW', + set_params => { hide_hint => 0 } + } + ); +} +'DB::Exception::InvalidParameter', 'addProblemSet: adding an non-valid boolean parameter in set_dates'; + # Update a set $new_set_params->{set_name} = "HW #8"; $new_set_params->{set_params} = { hide_hint => true }; From 5165dbe421c8684f021bb1b366e88e40181873e8 Mon Sep 17 00:00:00 2001 From: Peter Staab Date: Wed, 29 Jun 2022 13:11:44 -0400 Subject: [PATCH 23/35] FIX: perltidy/perlcritic errors --- lib/DB/Schema/ResultSet/UserSet.pm | 19 ++++++++++++++----- lib/DB/WithDates.pm | 1 + 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/lib/DB/Schema/ResultSet/UserSet.pm b/lib/DB/Schema/ResultSet/UserSet.pm index 95262c7b..349337de 100644 --- a/lib/DB/Schema/ResultSet/UserSet.pm +++ b/lib/DB/Schema/ResultSet/UserSet.pm @@ -395,14 +395,23 @@ sub addUserSet ($self, %args) { # If any of the dates are 0, then remove them. # Only check if the date_fields are 0. There is the option to store non-dates in the # set_dates field. - my $valid_date_str = $DB::Schema::Result::UserSet::set_type->{ $params->{type} } . '::valid_dates()'; - my $date_fields = eval($valid_date_str); + my $date_fields; + + # Note: although this works okay, if another subtype of a UserSet is created, this needs to be updated. + if ($params->{type} == 1) { + $date_fields = \&DB::Schema::Result::UserSet::HWSet::valid_dates; + } elsif ($params->{type} == 2) { + $date_fields = \&DB::Schema::Result::UserSet::Quiz::valid_dates; + } elsif ($params->{type} == 4) { + $date_fields = \&DB::Schema::Result::UserSet::ReviewSet::valid_dates; + } else { + die "The type $params->{type} is not valid."; + } if ($args{params}->{set_dates}) { - for my $key (@$date_fields) { + for my $key (@{ $date_fields->() }) { delete $args{params}->{set_dates}->{$key} - if defined($args{params}->{set_dates}->{$key}) - && $args{params}->{set_dates}->{$key} == 0; + if defined($args{params}->{set_dates}->{$key}) && $args{params}->{set_dates}->{$key} == 0; } } diff --git a/lib/DB/WithDates.pm b/lib/DB/WithDates.pm index e04becef..ca8a431f 100644 --- a/lib/DB/WithDates.pm +++ b/lib/DB/WithDates.pm @@ -89,6 +89,7 @@ sub validateOptionalFields ($self, $field_name) { message => "The parameter named $key is not valid. It has value $params_hash->{$key}") unless $valid; } + return 1; } 1; From f16d56d8354585cb4252910a5c735e6e7f62afb3 Mon Sep 17 00:00:00 2001 From: Peter Staab Date: Sat, 2 Jul 2022 11:22:27 -0400 Subject: [PATCH 24/35] FIX: issues after merge with main. --- src/common/models/problem_sets.ts | 20 +++++++++++--------- src/common/models/user_sets.ts | 14 +++++++++----- t/db/005_hwsets.t | 2 +- t/db/007_user_set.t | 2 +- t/db/012_set_versions.t | 2 +- tests/stores/problem_sets.spec.ts | 2 +- tests/stores/set_problems.spec.ts | 2 +- tests/stores/user_sets.spec.ts | 4 ++-- tests/unit-tests/review_sets.spec.ts | 14 +++++++------- tests/unit-tests/user_review_set.spec.ts | 4 ++-- 10 files changed, 36 insertions(+), 30 deletions(-) diff --git a/src/common/models/problem_sets.ts b/src/common/models/problem_sets.ts index f4fd9339..ea43e7d5 100644 --- a/src/common/models/problem_sets.ts +++ b/src/common/models/problem_sets.ts @@ -379,13 +379,12 @@ export class HomeworkSet extends ProblemSet { isValid(): boolean { return super.isValid() && this.set_params.isValid() && this.set_dates.isValid(); } - } // Review Set model and interfaces. export interface ParseableReviewSetParams { - can_retake?: boolean | string | number; + can_retake?: boolean; } export class ReviewSetParams extends Model { @@ -396,19 +395,22 @@ export class ReviewSetParams extends Model { this.set(params); } - set(params: ParseableReviewSetParams) { - if (params.can_retake != undefined) this.can_retake = params.can_retake; - } + static ALL_FIELDS = ['can_retake']; get all_field_names(): string[] { - return ['can_retake']; + return ReviewSetParams.ALL_FIELDS; } get param_fields(): string[] { return [];} - public get can_retake() : boolean { return this._can_retake;} - public set can_retake(value: number | string | boolean) { - this._can_retake = parseBoolean(value); + set(params: ParseableReviewSetParams) { + if (params.can_retake != undefined) this.can_retake = params.can_retake; } + + public get can_retake() : boolean { return this._can_retake;} + public set can_retake(value: boolean) { this._can_retake = value;} + + public isValid(): boolean { return true; } + public clone(): ReviewSetParams { return new ReviewSetParams(this.toObject()); } } export interface ParseableReviewSetDates { diff --git a/src/common/models/user_sets.ts b/src/common/models/user_sets.ts index 8c961fd3..855849c8 100644 --- a/src/common/models/user_sets.ts +++ b/src/common/models/user_sets.ts @@ -410,7 +410,7 @@ export class UserReviewSetDates extends Model { */ export class UserReviewSetParams extends Model { - private _test_param?: boolean; + private _can_retake?: boolean; constructor(params: ParseableReviewSetParams = {}) { super(); @@ -418,15 +418,19 @@ export class UserReviewSetParams extends Model { } set(params: ParseableReviewSetParams) { - this.test_param = params.test_param; + this.can_retake = params.can_retake; } - static ALL_FIELDS = ['test_params']; + static ALL_FIELDS = ['can_retakes']; get all_field_names(): string[] { return UserReviewSetParams.ALL_FIELDS; } get param_fields(): string[] { return [];} - public get test_param() : boolean | undefined { return this._test_param;} - public set test_param(value: boolean | undefined) { this._test_param = value; } + public get can_retake() : boolean | undefined { return this._can_retake;} + public set can_retake(value: boolean | undefined) { this._can_retake = value; } + + isValid(): boolean { + return true; + } } export class DBUserReviewSet extends DBUserSet { diff --git a/t/db/005_hwsets.t b/t/db/005_hwsets.t index fcb400db..c436efb7 100644 --- a/t/db/005_hwsets.t +++ b/t/db/005_hwsets.t @@ -68,7 +68,7 @@ my @review_sets = loadCSV( "$main::ww3_dir/t/db/sample_data/review_sets.csv", { boolean_fields => ['set_visible'], - param_boolean_fields => ['test_param'] + param_boolean_fields => ['can_retake'] } ); for my $set (@review_sets) { diff --git a/t/db/007_user_set.t b/t/db/007_user_set.t index 9ac2d480..06200497 100644 --- a/t/db/007_user_set.t +++ b/t/db/007_user_set.t @@ -72,7 +72,7 @@ my @review_sets = loadCSV( "$main::ww3_dir/t/db/sample_data/review_sets.csv", { boolean_fields => ['set_visible'], - param_boolean_fields => ['test_param'] + param_boolean_fields => ['can_retake'] } ); diff --git a/t/db/012_set_versions.t b/t/db/012_set_versions.t index d0f1a815..99b94077 100644 --- a/t/db/012_set_versions.t +++ b/t/db/012_set_versions.t @@ -64,7 +64,7 @@ my @review_sets = loadCSV( "$main::ww3_dir/t/db/sample_data/review_sets.csv", { boolean_fields => ['set_visible'], - param_boolean_fields => ['test_param'] + param_boolean_fields => ['can_retake'] } ); for my $set (@review_sets) { diff --git a/tests/stores/problem_sets.spec.ts b/tests/stores/problem_sets.spec.ts index 9fb18ad6..d10fa311 100644 --- a/tests/stores/problem_sets.spec.ts +++ b/tests/stores/problem_sets.spec.ts @@ -39,7 +39,7 @@ describe('Problem Set store tests', () => { const problem_set_config = { params: ['set_params', 'set_dates' ], boolean_fields: ['set_visible'], - param_boolean_fields: ['timed', 'enable_reduced_scoring', 'test_param'], + param_boolean_fields: ['timed', 'enable_reduced_scoring', 'can_retake'], param_non_neg_int_fields: ['quiz_duration'] }; diff --git a/tests/stores/set_problems.spec.ts b/tests/stores/set_problems.spec.ts index 7334366b..246d0311 100644 --- a/tests/stores/set_problems.spec.ts +++ b/tests/stores/set_problems.spec.ts @@ -49,7 +49,7 @@ describe('Problem Set store tests', () => { const problem_set_config = { params: ['set_params', 'set_dates' ], boolean_fields: ['set_visible'], - param_boolean_fields: ['timed', 'enable_reduced_scoring', 'test_param'], + param_boolean_fields: ['timed', 'enable_reduced_scoring', 'can_retake'], param_non_neg_int_fields: ['quiz_duration'] }; diff --git a/tests/stores/user_sets.spec.ts b/tests/stores/user_sets.spec.ts index 23874fba..deb4d81e 100644 --- a/tests/stores/user_sets.spec.ts +++ b/tests/stores/user_sets.spec.ts @@ -48,7 +48,7 @@ describe('Tests user sets and merged user sets in the problem set store', () => const problem_set_config = { params: ['set_params', 'set_dates' ], boolean_fields: ['set_visible'], - param_boolean_fields: ['timed', 'enable_reduced_scoring', 'test_param'], + param_boolean_fields: ['timed', 'enable_reduced_scoring', 'can_retake'], param_non_neg_int_fields: ['quiz_duration'] }; @@ -97,7 +97,7 @@ describe('Tests user sets and merged user sets in the problem set store', () => const user_sets_to_parse = await loadCSV('t/db/sample_data/user_sets.csv', { params: ['set_dates', 'set_params'], boolean_fields: ['set_visible'], - param_boolean_fields: ['timed', 'enable_reduced_scoring', 'test_param'], + param_boolean_fields: ['timed', 'enable_reduced_scoring', 'can_retake'], param_non_neg_int_fields: ['quiz_duration'] }); diff --git a/tests/unit-tests/review_sets.spec.ts b/tests/unit-tests/review_sets.spec.ts index 0c7a14b5..69dde9ab 100644 --- a/tests/unit-tests/review_sets.spec.ts +++ b/tests/unit-tests/review_sets.spec.ts @@ -10,7 +10,7 @@ describe('Testing for Review Sets', () => { closed: 0 }, set_params: { - test_param: false + can_retake: false }, set_id: 0, course_id: 0, @@ -46,7 +46,7 @@ describe('Testing for Review Sets', () => { set_id: 7, set_name: 'Review Set #1', set_params: { - can_retake: true, + can_retake: false }, set_visible: true, set_type: 'REVIEW' @@ -191,7 +191,7 @@ describe('Testing for Review Sets', () => { }); test('Check that calling all_fields() and params() is correct', () => { - const review_params_fields = ['test_param']; + const review_params_fields = ['can_retake']; const review_params = new ReviewSetParams(); expect(review_params.all_field_names.sort()).toStrictEqual(review_params_fields.sort()); @@ -209,14 +209,14 @@ describe('Testing for Review Sets', () => { describe('Check setting review set params', () => { test('Set review set params directly', () => { const hw_params = new ReviewSetParams(); - hw_params.test_param = true; - expect(hw_params.test_param).toBe(true); + hw_params.can_retake = true; + expect(hw_params.can_retake).toBe(true); }); test('Set homework set params using the set method', () => { const hw_params = new ReviewSetParams(); - hw_params.set({ test_param: true }); - expect(hw_params.test_param).toBe(true); + hw_params.set({ can_retake: true }); + expect(hw_params.can_retake).toBe(true); }); // No tests for validity for this currently because there is nothing diff --git a/tests/unit-tests/user_review_set.spec.ts b/tests/unit-tests/user_review_set.spec.ts index 38016189..0f143de2 100644 --- a/tests/unit-tests/user_review_set.spec.ts +++ b/tests/unit-tests/user_review_set.spec.ts @@ -18,7 +18,7 @@ describe('Testing db user Review Sets and User Review Sets', () => { course_user_id: 0, set_version: 1, set_type: 'REVIEW', - set_params: { can_retake: false }, + set_params: {}, set_dates: {} }; @@ -88,7 +88,7 @@ describe('Testing db user Review Sets and User Review Sets', () => { set_name: '', username: '', set_type: 'REVIEW', - set_params: { can_retake: false }, + set_params: {}, set_dates: { open: 0, closed: 0 } }; expect(user_review_set.toObject()).toStrictEqual(defaults); From e4a5dc9eda8473cd5b295a58e47e7b3440e2ad3c Mon Sep 17 00:00:00 2001 From: Peter Staab Date: Tue, 5 Jul 2022 06:58:33 -0400 Subject: [PATCH 25/35] FIX: cleanup after merge --- lib/DB/Schema/Result/ProblemSet.pm | 8 -------- src/common/models/courses.ts | 2 -- src/common/models/problem_sets.ts | 3 --- 3 files changed, 13 deletions(-) diff --git a/lib/DB/Schema/Result/ProblemSet.pm b/lib/DB/Schema/Result/ProblemSet.pm index 1876bdb9..3de8446c 100644 --- a/lib/DB/Schema/Result/ProblemSet.pm +++ b/lib/DB/Schema/Result/ProblemSet.pm @@ -130,14 +130,6 @@ __PACKAGE__->inflate_column( } ); -__PACKAGE__->inflate_column( - 'set_visible', - { - inflate => sub { return shift ? Mojo::JSON->true : Mojo::JSON->false; }, - deflate => sub { return shift; } - } -); - # This defines the non-abstract classes of ProblemSets. __PACKAGE__->typecast_map( diff --git a/src/common/models/courses.ts b/src/common/models/courses.ts index 77206951..37892d1a 100644 --- a/src/common/models/courses.ts +++ b/src/common/models/courses.ts @@ -75,7 +75,6 @@ export class Course extends Model { if (params.course_id != undefined) this.course_id = params.course_id; if (params.course_name != undefined) this.course_name = params.course_name; if (params.visible != undefined) this.visible = params.visible; - // super.checkParams(params as Dictionary); if (params.course_dates) this.setDates(params.course_dates); } @@ -146,7 +145,6 @@ export class UserCourse extends Model { if (params.user_id != undefined) this.user_id = params.user_id; if (params.username) this.username = params.username; if (params.role) this.role = params.role; - // super.checkParams(params as Dictionary); } setDates(date_params: ParseableCourseDates = {}) { diff --git a/src/common/models/problem_sets.ts b/src/common/models/problem_sets.ts index ea43e7d5..177adeac 100644 --- a/src/common/models/problem_sets.ts +++ b/src/common/models/problem_sets.ts @@ -532,8 +532,5 @@ export function convertSet(old_set: ProblemSet, new_set_type: ProblemSetType) { default: throw new ParseError('ProblemSetType', `convertSet does not support conversion to ${new_set_type || 'EMPTY'}`); } - - // if (!new_set.set_dates.isValid()) - // logger.error('[problem_sets/convertSet] corrupt dates in conversion of set, TSNH?'); return new_set; } From 282c1ea4bce2ac26d172478d8d199fa567b96b7c Mon Sep 17 00:00:00 2001 From: Peter Staab Date: Wed, 6 Jul 2022 16:07:53 -0400 Subject: [PATCH 26/35] FIX: unit tests for review sets. --- src/common/models/problem_sets.ts | 8 ++++---- tests/unit-tests/review_sets.spec.ts | 4 +--- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/common/models/problem_sets.ts b/src/common/models/problem_sets.ts index 177adeac..163d5dcc 100644 --- a/src/common/models/problem_sets.ts +++ b/src/common/models/problem_sets.ts @@ -388,7 +388,7 @@ export interface ParseableReviewSetParams { } export class ReviewSetParams extends Model { - private _can_retake = false; + private _can_retake?: boolean; constructor(params: ParseableReviewSetParams = {}) { super(); @@ -403,11 +403,11 @@ export class ReviewSetParams extends Model { get param_fields(): string[] { return [];} set(params: ParseableReviewSetParams) { - if (params.can_retake != undefined) this.can_retake = params.can_retake; + this.can_retake = params.can_retake; } - public get can_retake() : boolean { return this._can_retake;} - public set can_retake(value: boolean) { this._can_retake = value;} + public get can_retake() : boolean | undefined { return this._can_retake;} + public set can_retake(value: boolean | undefined) { this._can_retake = value;} public isValid(): boolean { return true; } public clone(): ReviewSetParams { return new ReviewSetParams(this.toObject()); } diff --git a/tests/unit-tests/review_sets.spec.ts b/tests/unit-tests/review_sets.spec.ts index 69dde9ab..6c32d541 100644 --- a/tests/unit-tests/review_sets.spec.ts +++ b/tests/unit-tests/review_sets.spec.ts @@ -9,9 +9,7 @@ describe('Testing for Review Sets', () => { open: 0, closed: 0 }, - set_params: { - can_retake: false - }, + set_params: {}, set_id: 0, course_id: 0, set_name: '', From 7f6baed2c35cec2c5836e244166cb2606ad5a75e Mon Sep 17 00:00:00 2001 From: Peter Staab Date: Fri, 8 Jul 2022 13:57:43 -0400 Subject: [PATCH 27/35] FIX: linting after merge. --- .eslintrc.js | 1 + lib/DB/TestUtils.pm | 8 +++++--- src/common/api-requests/user.ts | 10 +++++++++- .../ClasslistManagerComponents/AddUsersFromFile.vue | 4 ++-- t/db/build_db.pl | 3 ++- 5 files changed, 19 insertions(+), 7 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 42c8664d..03c29ce9 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -25,6 +25,7 @@ const baseRules = { 'keyword-spacing': ['error'], 'space-before-blocks': ['error', 'always'], 'arrow-spacing': ['error'], + 'template-curly-spacing': ['error', 'never'], // allow console and debugger during development only 'no-console': process.env.NODE_ENV === 'development' ? 'off' : 'error', diff --git a/lib/DB/TestUtils.pm b/lib/DB/TestUtils.pm index 9fe530cc..4dd6b11b 100644 --- a/lib/DB/TestUtils.pm +++ b/lib/DB/TestUtils.pm @@ -42,6 +42,8 @@ sub buildHash ($input, $config) { } elsif (defined($input->{$key}) && $input->{$key} =~ /^\d{4}-\d{2}-\d{2}T\d\d:\d\d:\d\dZ$/) { my $dt = $strp_datetime->parse_datetime($input->{$key}); $output->{$field}->{$subfield} = $dt->epoch; + } elsif (grep {/^$subfield$/} @{ $config->{param_boolean_fields} }) { + $output->{$field}->{$subfield} = int($input->{$key}) ? true : false if defined($input->{$key}); } } elsif (grep { $_ eq $subfield } @{ $config->{param_boolean_fields} }) { $output->{$field}->{$subfield} = int($input->{$key}) ? true : false if defined($input->{$key}); @@ -52,11 +54,11 @@ sub buildHash ($input, $config) { } else { $output->{$field}->{$subfield} = $input->{$key} if defined($input->{$key}); } - } elsif (grep {/^$key$/} @{ $config->{boolean_fields} }) { + } elsif (grep { $_ eq $key } @{ $config->{boolean_fields} }) { $output->{$key} = defined($input->{$key}) && int($input->{$key}) ? true : false; - } elsif (grep {/^$key$/} @{ $config->{non_neg_int_fields} }) { + } elsif (grep { $_ eq $key } @{ $config->{non_neg_int_fields} }) { $output->{$key} = int($input->{$key}) if defined($input->{$key}); - } elsif (grep {/^$key$/} @{ $config->{non_neg_float_fields} }) { + } elsif (grep { $_ eq $key } @{ $config->{non_neg_float_fields} }) { $output->{$key} = 0 + $input->{$key} if defined($input->{$key}); } else { $output->{$key} = $input->{$key}; diff --git a/src/common/api-requests/user.ts b/src/common/api-requests/user.ts index 63ab37b2..94e1b94b 100644 --- a/src/common/api-requests/user.ts +++ b/src/common/api-requests/user.ts @@ -1,8 +1,16 @@ import { api } from 'boot/axios'; -import { ParseableUser, User } from 'src/common/models/users'; +import { ParseableCourseUser, ParseableUser, User } from 'src/common/models/users'; import { ResponseError } from 'src/common/api-requests/errors'; +export async function checkIfUserExists(course_id: number, username: string) { + const response = await api.get(`courses/${course_id}/users/${username}/exists`); + if (response.status === 250) { + throw response.data as ResponseError; + } + return response.data as ParseableCourseUser; +} + /** * Gets the global user in the database given by username. This returns a user or throws a * ResponseError if the user is not found. diff --git a/src/components/instructor/ClasslistManagerComponents/AddUsersFromFile.vue b/src/components/instructor/ClasslistManagerComponents/AddUsersFromFile.vue index 0276dd41..9c8a5ae1 100644 --- a/src/components/instructor/ClasslistManagerComponents/AddUsersFromFile.vue +++ b/src/components/instructor/ClasslistManagerComponents/AddUsersFromFile.vue @@ -341,7 +341,7 @@ const addMergedUsers = async () => { let global_user: User | undefined; try { // Skip if username is undefined? - global_user = await getUser(user.username ?? '') as User; + global_user = await getUser(user.username ?? ''); } catch (err) { const error = err as ResponseError; // this will occur is the user is not a global user @@ -366,7 +366,7 @@ const addMergedUsers = async () => { } try { await users.addCourseUser(new CourseUser(user)); - const full_name = `${user.first_name as string} ${user.last_name as string}`; + const full_name = `${user.first_name} ${user.last_name}`; $q.notify({ message: `The user ${full_name} was successfully added to the course.`, color: 'green', diff --git a/t/db/build_db.pl b/t/db/build_db.pl index f85f6cdd..c85acafb 100755 --- a/t/db/build_db.pl +++ b/t/db/build_db.pl @@ -132,9 +132,10 @@ sub addSets { "$main::ww3_dir/t/db/sample_data/hw_sets.csv", { boolean_fields => ['set_visible'], - param_boolean_fields => [ 'enable_reduced_scoring', 'hide_hint' ] + param_boolean_fields => ['enable_reduced_scoring', 'hide_hint'] } ); + for my $set (@hw_sets) { my $course = $course_rs->find({ course_name => $set->{course_name} }); if (!defined($course)) { From f688760a9405dba80173781c6305cd80b033cad1 Mon Sep 17 00:00:00 2001 From: Peter Staab Date: Mon, 11 Jul 2022 10:18:41 -0400 Subject: [PATCH 28/35] FIX: perltidy --- t/db/build_db.pl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/t/db/build_db.pl b/t/db/build_db.pl index c85acafb..06ea4450 100755 --- a/t/db/build_db.pl +++ b/t/db/build_db.pl @@ -132,7 +132,7 @@ sub addSets { "$main::ww3_dir/t/db/sample_data/hw_sets.csv", { boolean_fields => ['set_visible'], - param_boolean_fields => ['enable_reduced_scoring', 'hide_hint'] + param_boolean_fields => [ 'enable_reduced_scoring', 'hide_hint' ] } ); From dbe2f02938ce21b8f4494340927813e4d8f4ec1d Mon Sep 17 00:00:00 2001 From: Peter Staab Date: Fri, 15 Jul 2022 14:50:17 -0400 Subject: [PATCH 29/35] FIX: add users manually wasn't working. There was related model/store issues too. --- lib/WeBWorK3.pm | 39 ++++--- lib/WeBWorK3/Controller/User.pm | 29 ++++- src/common/api-requests/user.ts | 18 ++- src/common/models/users.ts | 1 + .../AddUsersManually.vue | 105 ++++++++---------- src/stores/users.ts | 35 +++++- 6 files changed, 143 insertions(+), 84 deletions(-) diff --git a/lib/WeBWorK3.pm b/lib/WeBWorK3.pm index fd997db6..50f8e57b 100644 --- a/lib/WeBWorK3.pm +++ b/lib/WeBWorK3.pm @@ -104,29 +104,40 @@ sub userRoutes ($self) { my $user_routes = $self->routes->any('/webwork3/api/users')->requires(authenticated => 1)->to(controller => 'User'); $user_routes->get('/')->to(action => 'getGlobalUsers'); $user_routes->post('/')->to(action => 'addGlobalUser'); - $user_routes->get('/:user_id')->to(action => 'getGlobalUser'); + $user_routes->get('/:user')->to(action => 'getGlobalUser'); $user_routes->put('/:user_id')->to(action => 'updateGlobalUser'); $user_routes->delete('/:user_id')->to(action => 'deleteGlobalUser'); $user_routes->get('/:user_id/courses')->to(action => 'getUserCourses'); # Get global users for a course. $self->routes->get('/webwork3/api/courses/:course_id/global-users')->to('User#getGlobalCourseUsers'); - # This is needed to get global users as instructor permission. Need to have - # the parameter course_id. - $self->routes->any('/webwork3/api/courses/:course_id/users/:user/exists')->requires(authenticated => 1) - ->to('User#getGlobalUser'); + return; } sub courseUserRoutes ($self) { - my $course_user_routes = $self->routes->any('/webwork3/api/courses/:course_id/users')->requires(authenticated => 1) - ->to(controller => 'User'); - $course_user_routes->get('/')->to(action => 'getCourseUsers'); - $course_user_routes->post('/')->to(action => 'addCourseUser'); - $course_user_routes->get('/:user_id')->to(action => 'getCourseUser'); - $course_user_routes->put('/:user_id')->to(action => 'updateCourseUser'); - $course_user_routes->delete('/:user_id')->to(action => 'deleteCourseUser'); - $self->routes->any('/webwork3/api/courses/:course_id/courseusers')->requires(authenticated => 1) - ->to('User#getMergedCourseUsers'); + my $course_routes = + $self->routes->any('/webwork3/api/courses/:course_id')->requires(authenticated => 1)->to(controller => 'User'); + $course_routes->get('/users')->to(action => 'getCourseUsers'); + $course_routes->get('/users')->to(action => 'getCourseUsers'); + $course_routes->post('/users')->to(action => 'addCourseUser'); + $course_routes->get('/users/:user_id')->to(action => 'getCourseUser'); + $course_routes->put('/users/:user_id')->to(action => 'updateCourseUser'); + $course_routes->delete('/users/:user_id')->to(action => 'deleteCourseUser'); + + # There are some routes needed for global user crud, but the permssions require that the + # user has permissions within a course. + + $course_routes->get('/global-courseusers')->to('User#getGlobalCourseUsers'); + $course_routes->post('/global-users')->to('User#addGlobalUserFromCourse'); + $course_routes->get('/global-users/:user')->to('User#getGlobalUserFromCourse'); + $course_routes->put('/global-users/:user_id')->to('User#updateGlobalUserFromCourse'); + $course_routes->delete('/global-users/:user_id')->to('User#deleteGlobalUserFromCourse'); + + $course_routes->get('/courseusers')->to('User#getMergedCourseUsers'); + + # This is used to check if a user with given username exists. + $course_routes->get('/users/:username/exists')->to('User#checkGlobalUser'); + return; } diff --git a/lib/WeBWorK3/Controller/User.pm b/lib/WeBWorK3/Controller/User.pm index 5612c8d1..61f939b6 100644 --- a/lib/WeBWorK3/Controller/User.pm +++ b/lib/WeBWorK3/Controller/User.pm @@ -1,17 +1,32 @@ package WeBWorK3::Controller::User; use Mojo::Base 'Mojolicious::Controller', -signatures; +use Try::Tiny; + sub getGlobalUsers ($self) { my @global_users = $self->schema->resultset('User')->getAllGlobalUsers; $self->render(json => \@global_users); return; } +# Passing the username into the getGlobalUser results in a problem with permssions. This route +# should be used to pass in the username. +sub checkGlobalUser ($self) { + try { + my $user = $self->schema->resultset('User')->getGlobalUser(info => { username => $self->param('username') }); + $self->render(json => $user); + return; + } catch { + $self->render(json => {}) if ref($_) eq 'DB::Exception::UserNotFound'; + }; + return; +} + sub getGlobalUser ($self) { my $user = - $self->param('user_id') =~ /^\d+$/ - ? $self->schema->resultset('User')->getGlobalUser(info => { user_id => int($self->param('user_id')) }) - : $self->schema->resultset('User')->getGlobalUser(info => { username => $self->param('user_id') }); + $self->param('user') =~ /^\d+$/ + ? $self->schema->resultset('User')->getGlobalUser(info => { user_id => int($self->param('user')) }) + : $self->schema->resultset('User')->getGlobalUser(info => { username => $self->param('user') }); $self->render(json => $user); return; } @@ -48,6 +63,14 @@ sub getGlobalCourseUsers ($self) { return; } +# The following are needed for handling global users from instructors in a course + +sub getGlobalUsersFromCourse ($self) { $self->getGlobalUsers; return } +sub getGlobalUserFromCourse ($self) { $self->getGlobalUser; return } +sub addGlobalUserFromCourse ($self) { $self->addGlobalUser; return } +sub updateGlobalUserFromCourse ($self) { $self->updateGlobalUser; return } +sub deleteGlobalUserFromCourse ($self) { $self->deleteGlobalUser; return } + # The following subs are related to users within a given course. sub getMergedCourseUsers ($self) { diff --git a/src/common/api-requests/user.ts b/src/common/api-requests/user.ts index 94e1b94b..18fcefad 100644 --- a/src/common/api-requests/user.ts +++ b/src/common/api-requests/user.ts @@ -3,12 +3,20 @@ import { api } from 'boot/axios'; import { ParseableCourseUser, ParseableUser, User } from 'src/common/models/users'; import { ResponseError } from 'src/common/api-requests/errors'; -export async function checkIfUserExists(course_id: number, username: string) { - const response = await api.get(`courses/${course_id}/users/${username}/exists`); - if (response.status === 250) { - throw response.data as ResponseError; +/** + * Checks if a global user exists. Both the course_id and username need to be passed in + * in order to check permissions. + * + * @returns either an existing user or an empty object. + */ + +export async function checkIfUserExists(course_id: number, username: string): Promise { + try { + const response = await api.get(`courses/${course_id}/users/${username}/exists`); + return response.data as ParseableCourseUser; + } catch (_err) { + return {}; } - return response.data as ParseableCourseUser; } /** diff --git a/src/common/models/users.ts b/src/common/models/users.ts index e531e8a3..104d2622 100644 --- a/src/common/models/users.ts +++ b/src/common/models/users.ts @@ -153,6 +153,7 @@ export class DBCourseUser extends Model { return isNonNegInt(this.user_id) && isNonNegInt(this.course_user_id) && isNonNegInt(this.course_id); } + } export interface ParseableCourseUser { diff --git a/src/components/instructor/ClasslistManagerComponents/AddUsersManually.vue b/src/components/instructor/ClasslistManagerComponents/AddUsersManually.vue index a80c0a55..a5770152 100644 --- a/src/components/instructor/ClasslistManagerComponents/AddUsersManually.vue +++ b/src/components/instructor/ClasslistManagerComponents/AddUsersManually.vue @@ -15,7 +15,7 @@ @blur="checkUser" ref="username_ref" lazy-rules - :rules="[(val) => validateUsername(val)]" + :rules="[val => validateUsername(val)]" />
@@ -30,7 +30,7 @@ v-model="course_user.email" label="Email" lazy-rules - :rules="[(val) => validateEmail(val)]" + :rules="[val => validateEmail(val)]" :disable="user_exists" />
@@ -53,7 +53,7 @@ :options="roles" label="Role *" :validate="validateRole" - :rules="[(val) => validateRole(val)]" + :rules="[val => validateRole(val)]" />
@@ -73,14 +73,13 @@ import { ref, computed, defineEmits } from 'vue'; import { useQuasar } from 'quasar'; import { logger } from 'boot/logger'; -import { getUser } from 'src/common/api-requests/user'; +import { checkIfUserExists } from 'src/common/api-requests/user'; import { useUserStore } from 'src/stores/users'; import { useSessionStore } from 'src/stores/session'; import { useSettingsStore } from 'src/stores/settings'; -import { CourseUser, ParseableCourseUser, User } from 'src/common/models/users'; +import { CourseUser, User } from 'src/common/models/users'; import type { ResponseError } from 'src/common/api-requests/errors'; -import { AxiosError } from 'axios'; import { isValidEmail, isValidUsername, parseNonNegInt } from 'src/common/models/parsers'; interface QRef { @@ -101,18 +100,14 @@ const settings = useSettingsStore(); // see if the user exists already and fill in the known fields const checkUser = async () => { - // If the user doesn't exist, the catch statement will handle this. - try { - const existing_user = await getUser(course_user.value.username); - course_user.value.set(existing_user.toObject() as ParseableCourseUser); - } catch (err) { - const error = err as ResponseError; - // this will occur is the user is not a global user - if (error.exception === 'DB::Exception::UserNotFound') { - user_exists.value = false; - } else { - logger.error(error.message); - } + const existing_user = await checkIfUserExists(session.course.course_id, course_user.value.username); + if (existing_user.username) { + course_user.value.set(existing_user); + user_exists.value = true; + } else { + // make sure the other fields are emptied. This can happen if they were prefilled. + course_user.value = new CourseUser({ username: course_user.value.username }); + user_exists.value = false; } }; @@ -121,55 +116,53 @@ const roles = computed(() => (settings.getCourseSetting('roles').value as string[]).filter(v => v !== 'admin')); const addUser = async (close: boolean) => { - try { - // Check to ensure username is correct and a role is selected. + + // Check that the user fields are valid. + if (! course_user.value.isValid()) { if (username_ref.value && role_ref.value) { username_ref.value.validate(); role_ref.value.validate(); - if (username_ref.value.hasError || role_ref.value.hasError) return; } + return; + } - // if the user is not global, add them. - if (!user_exists.value) { - try { - logger.debug(`Trying to add the new global user ${course_user.value.username ?? 'UNKNOWN'}`); - const global_user = await user_store.addUser(new User(course_user.value)); - if (global_user == undefined) throw `There is an error adding the user ${course_user.value.username}`; - const msg = `The global user with username ${global_user?.username ?? 'UNKNOWN'} was created.`; - $q.notify({ message: msg, color: 'green' }); - logger.debug(msg); - course_user.value.user_id = global_user.user_id; - } catch (err) { - const error = err as ResponseError; - $q.notify({ message: error.message, color: 'red' }); - } + // if the user is not global, add them. + if (!user_exists.value) { + try { + logger.debug(`Trying to add the new global user ${course_user.value.username ?? 'UNKNOWN'}`); + const global_user = await user_store.addUser(new User(course_user.value)); + if (global_user == undefined) throw `There is an error adding the user ${course_user.value.username}`; + const msg = `The global user with username ${global_user?.username ?? 'UNKNOWN'} was created.`; + $q.notify({ message: msg, color: 'green' }); + logger.debug(msg); + course_user.value.user_id = global_user.user_id; + } catch (err) { + const error = err as ResponseError; + $q.notify({ message: error.message, color: 'red' }); } + } - course_user.value.course_id = session.course.course_id; - const user = await user_store.addCourseUser(new CourseUser(course_user.value)); - const u = user_store.findCourseUser({ user_id: parseNonNegInt(user.user_id ?? 0) }); - $q.notify({ - message: `The user with username '${u.username ?? ''}' was added successfully.`, - color: 'green' - }); - if (close) { - emit('closeDialog'); - } else { - course_user.value = new CourseUser(); - } - } catch (err) { - const error = err as AxiosError; - logger.error(error); - const data = error?.response?.data as ResponseError || { exception: '' }; - $q.notify({ - message: data.exception, - color: 'red' - }); + course_user.value.course_id = session.course.course_id; + const user = await user_store.addCourseUser(new CourseUser(course_user.value)); + + // If the user exist globally, fetch the user to be added to the store + if (user_exists.value) { + await user_store.fetchUser(user.user_id); + } + const u = user_store.findCourseUser({ user_id: parseNonNegInt(user.user_id ?? 0) }); + $q.notify({ + message: `The user with username '${u.username ?? ''}' was added successfully.`, + color: 'green' + }); + if (close) { + emit('closeDialog'); + } else { + course_user.value = new CourseUser(); } }; const validateRole = (val: string | null) => { - if (val == undefined) { + if (val == undefined || val == 'UNKNOWN') { return 'You must select a role'; } return true; diff --git a/src/stores/users.ts b/src/stores/users.ts index 651d067e..f568543f 100644 --- a/src/stores/users.ts +++ b/src/stores/users.ts @@ -88,7 +88,7 @@ export const useUserStore = defineStore('user', { * Fetch all global users in all courses and store the results. */ async fetchUsers(): Promise { - const response = await api.get('users'); + const response = await api.get('users'); if (response.status === 200) { const users_to_parse = response.data as Array; this.users = users_to_parse.map(user => new User(user)); @@ -99,13 +99,29 @@ export const useUserStore = defineStore('user', { } }, + /** + * Fetch a single global user and add to the store. + */ + async fetchUser(user_id: number): Promise { + const session_store = useSessionStore(); + const course_id = session_store.course.course_id; + const response = await api.get(`courses/${course_id}/global-users/${user_id}`); + if (response.status === 200) { + this.users.push(new User(response.data as ParseableUser)); + } else { + const error = response.data as ResponseError; + logger.error(`${error.exception}: ${error.message}`); + throw new Error(error.message); + } + }, + /** * Fetch the global users for a given course and store the results. */ // the users are stored in the same users field as the all global users // Perhaps this is a problem. async fetchGlobalCourseUsers(course_id: number): Promise { - const response = await api.get(`courses/${course_id}/global-users`); + const response = await api.get(`courses/${course_id}/global-courseusers`); if (response.status === 200) { const users = response.data as ParseableUser[]; this.users = users.map(user => new User(user)); @@ -121,7 +137,9 @@ export const useUserStore = defineStore('user', { async updateUser(user: User): Promise { if (!user.isValid()) return invalidError(user, 'The updated user is invalid'); - const response = await api.put(`users/${user.user_id}`, user.toObject()); + const session_store = useSessionStore(); + const course_id = session_store.course.course_id; + const response = await api.put(`courses/${course_id}/global-users/${user.user_id}`, user.toObject()); if (response.status === 200) { const updated_user = new User(response.data as ParseableUser); const index = this.users.findIndex(user => user.user_id === updated_user.user_id); @@ -138,7 +156,9 @@ export const useUserStore = defineStore('user', { * Deletes the given User in the database and in the store. */ async deleteUser(user: User): Promise { - const response = await api.delete(`/users/${user.user_id ?? 0}`); + const session_store = useSessionStore(); + const course_id = session_store.course.course_id; + const response = await api.delete(`courses/${course_id}/global-users/${user.user_id}`); if (response.status === 200) { const index = this.users.findIndex((u) => u.user_id === user.user_id); // splice is used so vue3 reacts to changes. @@ -153,7 +173,9 @@ export const useUserStore = defineStore('user', { async addUser(user: User): Promise { if (!user.isValid()) return invalidError(user, 'The added user is invalid.'); - const response = await api.post('users', user.toObject()); + const session_store = useSessionStore(); + const course_id = session_store.course.course_id; + const response = await api.post(`courses/${course_id}/global-users`, user.toObject()); if (response.status === 200) { const new_user = new User(response.data as ParseableUser); this.users.push(new_user); @@ -247,8 +269,9 @@ export const useUserStore = defineStore('user', { const response = await api.delete(`courses/${course_user.course_id}/users/${course_user.user_id}`); if (response.status === 200) { const index = this.db_course_users.findIndex((u) => u.course_user_id === course_user.course_user_id); + // splice is used so vue3 reacts to changes. - this.course_users.splice(index, 1); + this.db_course_users.splice(index, 1); const deleted_course_user = new DBCourseUser(response.data as ParseableCourseUser); const user = this.users.find(u => u.user_id === deleted_course_user.user_id); return new CourseUser(Object.assign({}, user?.toObject(), deleted_course_user.toObject())); From 9750fa04e206bb805cffea955b87c60b2a3a47b6 Mon Sep 17 00:00:00 2001 From: Peter Staab Date: Fri, 15 Jul 2022 15:14:28 -0400 Subject: [PATCH 30/35] FIX: linting error. --- src/stores/users.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/stores/users.ts b/src/stores/users.ts index f568543f..020dd994 100644 --- a/src/stores/users.ts +++ b/src/stores/users.ts @@ -88,7 +88,7 @@ export const useUserStore = defineStore('user', { * Fetch all global users in all courses and store the results. */ async fetchUsers(): Promise { - const response = await api.get('users'); + const response = await api.get('users'); if (response.status === 200) { const users_to_parse = response.data as Array; this.users = users_to_parse.map(user => new User(user)); @@ -102,10 +102,10 @@ export const useUserStore = defineStore('user', { /** * Fetch a single global user and add to the store. */ - async fetchUser(user_id: number): Promise { + async fetchUser(user_id: number): Promise { const session_store = useSessionStore(); - const course_id = session_store.course.course_id; - const response = await api.get(`courses/${course_id}/global-users/${user_id}`); + const course_id = session_store.course.course_id; + const response = await api.get(`courses/${course_id}/global-users/${user_id}`); if (response.status === 200) { this.users.push(new User(response.data as ParseableUser)); } else { @@ -138,8 +138,8 @@ export const useUserStore = defineStore('user', { if (!user.isValid()) return invalidError(user, 'The updated user is invalid'); const session_store = useSessionStore(); - const course_id = session_store.course.course_id; - const response = await api.put(`courses/${course_id}/global-users/${user.user_id}`, user.toObject()); + const course_id = session_store.course.course_id; + const response = await api.put(`courses/${course_id}/global-users/${user.user_id}`, user.toObject()); if (response.status === 200) { const updated_user = new User(response.data as ParseableUser); const index = this.users.findIndex(user => user.user_id === updated_user.user_id); @@ -157,8 +157,8 @@ export const useUserStore = defineStore('user', { */ async deleteUser(user: User): Promise { const session_store = useSessionStore(); - const course_id = session_store.course.course_id; - const response = await api.delete(`courses/${course_id}/global-users/${user.user_id}`); + const course_id = session_store.course.course_id; + const response = await api.delete(`courses/${course_id}/global-users/${user.user_id}`); if (response.status === 200) { const index = this.users.findIndex((u) => u.user_id === user.user_id); // splice is used so vue3 reacts to changes. @@ -174,8 +174,8 @@ export const useUserStore = defineStore('user', { if (!user.isValid()) return invalidError(user, 'The added user is invalid.'); const session_store = useSessionStore(); - const course_id = session_store.course.course_id; - const response = await api.post(`courses/${course_id}/global-users`, user.toObject()); + const course_id = session_store.course.course_id; + const response = await api.post(`courses/${course_id}/global-users`, user.toObject()); if (response.status === 200) { const new_user = new User(response.data as ParseableUser); this.users.push(new_user); From 89c5842a8c6c769c19273bc1dc3c5d173a774570 Mon Sep 17 00:00:00 2001 From: Peter Staab Date: Fri, 15 Jul 2022 15:20:14 -0400 Subject: [PATCH 31/35] FIX: make a server call only if the username is valid --- .../AddUsersManually.vue | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/components/instructor/ClasslistManagerComponents/AddUsersManually.vue b/src/components/instructor/ClasslistManagerComponents/AddUsersManually.vue index a5770152..15fcb18f 100644 --- a/src/components/instructor/ClasslistManagerComponents/AddUsersManually.vue +++ b/src/components/instructor/ClasslistManagerComponents/AddUsersManually.vue @@ -100,14 +100,17 @@ const settings = useSettingsStore(); // see if the user exists already and fill in the known fields const checkUser = async () => { - const existing_user = await checkIfUserExists(session.course.course_id, course_user.value.username); - if (existing_user.username) { - course_user.value.set(existing_user); - user_exists.value = true; - } else { - // make sure the other fields are emptied. This can happen if they were prefilled. - course_user.value = new CourseUser({ username: course_user.value.username }); - user_exists.value = false; + // check first if the username is valid. + if (validateUsername(course_user.value.username) === true) { + const existing_user = await checkIfUserExists(session.course.course_id, course_user.value.username); + if (existing_user.username) { + course_user.value.set(existing_user); + user_exists.value = true; + } else { + // make sure the other fields are emptied. This can happen if they were prefilled. + course_user.value = new CourseUser({ username: course_user.value.username }); + user_exists.value = false; + } } }; From 4c3cf09b6458c110c43fd2479e0922d52e6cc641 Mon Sep 17 00:00:00 2001 From: Peter Staab Date: Tue, 2 Aug 2022 08:38:16 -0400 Subject: [PATCH 32/35] WIP: initial checkin of some changes to handling settings. WIP: work on course_settings WIP: continued work on the course settings WIP: continued work on course_settings. TEST: mojolicious route testing for settings. WIP: continued work on course settings --- ...ourse_defaults.yml => course_settings.yml} | 285 +++--- lib/DB/Exception.pm | 4 + lib/DB/Schema/Result/Course.pm | 4 +- lib/DB/Schema/Result/CourseSetting.pm | 67 ++ lib/DB/Schema/Result/CourseSettings.pm | 128 --- lib/DB/Schema/Result/GlobalSetting.pm | 117 +++ lib/DB/Schema/ResultSet/Course.pm | 265 +++++- lib/DB/Utils.pm | 6 +- lib/WeBWorK3.pm | 10 +- lib/WeBWorK3/Controller/Settings.pm | 60 +- lib/WeBWorK3/Utils/Settings.pm | 267 ++---- package-lock.json | 96 +- package.json | 3 +- src/common/models/parsers.ts | 5 + src/common/models/settings.ts | 323 ++++++- src/components/instructor/SingleSetting.vue | 84 +- src/stores/settings.ts | 140 +-- t/db/002_course_settings.t | 315 ++++--- t/db/build_db.pl | 55 +- t/db/sample_data/course_settings.csv | 8 + t/db/sample_data/courses.csv | 12 +- t/mojolicious/015_course_settings.t | 110 +++ tests/stores/settings.spec.ts | 140 +++ tests/unit-tests/parsing.spec.ts | 161 ++-- tests/unit-tests/settings.spec.ts | 827 ++++++++++++++++++ 25 files changed, 2651 insertions(+), 841 deletions(-) rename conf/{course_defaults.yml => course_settings.yml} (72%) create mode 100644 lib/DB/Schema/Result/CourseSetting.pm delete mode 100644 lib/DB/Schema/Result/CourseSettings.pm create mode 100644 lib/DB/Schema/Result/GlobalSetting.pm create mode 100644 t/db/sample_data/course_settings.csv create mode 100644 t/mojolicious/015_course_settings.t create mode 100644 tests/stores/settings.spec.ts create mode 100644 tests/unit-tests/settings.spec.ts diff --git a/conf/course_defaults.yml b/conf/course_settings.yml similarity index 72% rename from conf/course_defaults.yml rename to conf/course_settings.yml index 06df7c77..f6d6a2fe 100644 --- a/conf/course_defaults.yml +++ b/conf/course_settings.yml @@ -5,30 +5,33 @@ # For the optional category, there are subcategories. # # For each course setting there are 4 fields -# var: the name of the variable/setting -# doc: a short description of the variable -# doc2: a longer description of the variable (optional) -# type: the type of varable (text, list, multilist, boolean, integer, decimal, time, date_time, time_duration, +# setting_name: the name of the variable/setting +# description: a short description of the variable +# category: category the setting is in (for organization on the UI) +# subcategory: subcategory the setting is in (if applicable) +# doc: a longer description of the variable (optional) +# type: the type of varable (text, list, multilist, boolean, int, decimal, time, date_time, time_duration, # timezone) +# options: an array of strings or objects with the fields value and label; used for a setting of types and multilist # these are the general course settings - - var: institution + setting_name: institution category: general - doc: Name of the institution + description: Name of the institution type: text - default: '' + default_value: '' - - var: course_description + setting_name: course_description category: general - doc: Description of the course + description: Description of the course type: text - default: '' + default_value: '' - - var: language + setting_name: language category: general - doc: Default language for the course - doc2: > + description: Default language for the course + doc: > WeBWorK currently has translations for the following languages: "English en", "French fr", and "German de" type: list @@ -54,12 +57,12 @@ #- # label: Turkish # value: tr - default: en-US # select default value here + default_value: en-US # select default value here - - var: per_problem_lang_and_dir_setting_mode + setting_name: per_problem_lang_and_dir_setting_mode category: general - doc: Mode in which the LANG and DIR settings for a single problem are determined. - doc2: > + description: Mode in which the LANG and DIR settings for a single problem are determined. + doc: > Mode in which the LANG and DIR settings for a single problem are determined. The system will set the LANGuage attribute to either a value determined from the problem, @@ -126,42 +129,41 @@ - auto:zh_hk:ltr - force:he:rtl - auto:he:rtl - default: none + default_value: none - - var: session_key_timeout + setting_name: session_key_timeout category: general - doc: Inactivity time before a user is required to login again + description: Inactivity time before a user is required to login again type: time_duration - # note the default time is in seconds - default: 15 mins + default_value: 15 mins - - var: timezone + setting_name: timezone category: general - doc: Timezone for the course + description: Timezone for the course type: timezone - default: site_default_timezone + default_value: America/New_York - - var: hardcopy_theme + setting_name: hardcopy_theme category: general - doc: Hardcopy Theme - doc2: | + description: Hardcopy Theme + doc: | There are currently two hardcopy themes to choose from: One Column and Two Columns. The Two Columns theme is the traditional hardcopy format. The One Column theme uses the full page width for each column type: list options: [ 'One Column', 'Two Column' ] - default: 'Two Column' + default_value: 'Two Column' - - var: show_course_homework_totals + setting_name: show_course_homework_totals category: general - doc: Show Total Homework Grade on Grades Page - doc2: | + description: Show Total Homework Grade on Grades Page + doc: | When this is on students will see a line on the Grades page which has their total cumulative homework score. This score includes all sets assigned to the student. type: boolean - default: true + default_value: true # this contains all optional features of webwork @@ -169,26 +171,26 @@ - category: optional subcategory: conditional_release - var: enable_conditional_release - doc: Enable Conditional Release - doc2: whether or not problem sets can have conditional release + setting_name: enable_conditional_release + description: Enable Conditional Release + doc: whether or not problem sets can have conditional release type: boolean - default: false + default_value: false # reduced scoring - - var: enable_reduced_scoring + setting_name: enable_reduced_scoring category: optional subcategory: reduced_scoring - doc: whether or not problem sets can have reducing scoring enabled. + description: whether or not problem sets can have reduced scoring enabled. type: boolean - default: false + default_value: false - - var: reducing_scoring_value + setting_name: reduced_scoring_value category: optional subcategory: reduced_scoring - doc: Value of work done in Reduced Scoring Period - doc2: > + description: Value of work done in Reduced Scoring Period + doc: > After the Reduced Scoring Date all additional work done by the student counts at a reduced rate. Here is where you set the reduced rate which must be a percentage. For example if this value is 50% and a student @@ -203,14 +205,14 @@ This works with the avg_problem_grader (which is the default grader) and the std_problem_grader (the all or nothing grader). It will work with custom graders if they are written appropriately. - type: text - default: false + type: decimal + default_value: 0.8 - - var: reduced_scoring_period + setting_name: reduced_scoring_period category: optional subcategory: reduced_scoring - doc: Default Length of Reduced Scoring Period - doc2: > + description: Default Length of Reduced Scoring Period + doc: > The Reduced Scoring Period is the default period before the due date during which all additional work done by the student counts at a reduced rate. When enabling reduced scoring for a set the reduced scoring date will be set to @@ -221,45 +223,45 @@ at 06:17pm EST. During this period all additional work done counts 50% of the original." will be displayed. type: time_duration - default: 3 days + default_value: 3 days # show me another - - var: enable_show_me_another + setting_name: enable_show_me_another category: optional subcategory: show_me_another - doc: Enable Show Me Another button - doc2: > + description: Enable Show Me Another button + doc: > Enables use of the Show Me Another button, which offers the student a newly-seeded version of the current problem, complete with solution (if it exists for that problem). type: boolean - default: false + default_value: false - - var: show_me_another_default + setting_name: show_me_another_default category: optional subcategory: show_me_another - doc: Default number of attempts before Show Me Another can be used (-1 => Never) - doc2: | + description: Default number of attempts before Show Me Another can be used (-1 => Never) + doc: | This is the default number of attempts before show me another becomes available to students. It can be set to -1 to disable show me another by default. - type: integer - default: -1 + type: int + default_value: -1 - - var: show_me_another_max_reps + setting_name: show_me_another_max_reps category: optional subcategory: show_me_another - doc: Maximum times Show me Another can be used per problem (-1 => unlimited) - doc2: | + description: Maximum times Show me Another can be used per problem (-1 => unlimited) + doc: | The Maximum number of times Show me Another can be used per problem by a student. If set to -1 then there is no limit to the number of times that Show Me Another can be used. - type: integer - default: -1 + type: int + default_value: -1 - - var: show_me_another_options + setting_name: show_me_another_options category: optional subcategory: show_me_another - doc: List of options for Show Me Another button - doc2: > + description: List of options for Show Me Another button + doc: >
  • SMAcheckAnswers: enables the Check Answers button for the new problem when Show Me Another is clicked
  • SMAshowSolutions: shows walk-through solution for the new problem @@ -273,82 +275,70 @@ version that they can not attempt or learn from.

    type: list options: ['SMAcheckAnswers','SMAshowSolutions','SMAshowCorrect','SMAshowHints'] - default: SMAcheckAnswers + default_value: SMAcheckAnswers # rerandomization - - var: enable_periodic_randomization + setting_name: enable_periodic_randomization category: optional subcategory: rerandomization - doc: Enable periodic re-randomization of problems - doc2: | + description: Enable periodic re-randomization of problems + doc: | Enables periodic re-randomization of problems after a given number of attempts. Student would have to click Request New Version to obtain new version of the problem and to continue working on the problem. type: boolean - default: false + default_value: false - - var: periodic_randomization_period + setting_name: periodic_randomization_period category: optional subcategory: rerandomization - doc: The default number of attempts between re-randomization of the problems ( 0 => never) - type: integer - default: 0 + description: The default number of attempts between re-randomization of the problems ( 0 => never) + type: int + default_value: 0 - - var: show_correct_on_randomize + setting_name: show_correct_on_randomize category: optional subcategory: rerandomization - doc: Show the correct answer to the current problem on the last attempt before a new version is requested. + description: Show the correct answer to the current problem on the last attempt before a new version is requested. type: boolean - default: false - -# Permissions Settings -- - var: roles - category: permissions - doc: A list of roles in the course - type: multilist - default: - - admin - - instructor - - TA - - student + default_value: false # Settings at the Problem Set level - - var: time_assign_due + setting_name: time_assign_due category: problem_set - doc: Default Time that the Assignment is Due - doc2: | + description: Default Time that the Assignment is Due + doc: | The time of the day that the assignment is due. This can be changed on an individual basis, but WeBWorK will use this value for default when a set is created. type: time - default: '23:59' # Note this is in 24-hour time format + default_value: '23:59' # Note this is in 24-hour time format - - var: assign_open_prior_to_due + setting_name: assign_open_prior_to_due category: problem_set - doc: Default Amount of Time (in minutes) before Due Date that the Assignment is Open - doc2: | + description: Default Amount of Time (in minutes) before Due Date that the Assignment is Open + doc: | The amount of time (in minutes) before the due date when the assignment is opened. You can change this for individual homework, but WeBWorK will use this value when a set is created. type: time_duration - default: 1 week + default_value: 1 week - - var: answers_open_after_due_date + setting_name: answers_open_after_due_date category: problem_set - doc: Default Amount of Time (in minutes) after Due Date that Answers are Open - doc2: | + description: Default Amount of Time (in minutes) after Due Date that Answers are Open + doc: | The amount of time (in minutes) after the due date that the Answers are available to student to view. You can change this for individual homework, but WeBWorK will use this value when a set is created. type: time_duration - default: 1 week + default_value: 1 week # settings on the problem level. - - var: display_mode_options + setting_name: display_mode_options category: problem - doc: List of display modes made available to students - doc2: > + description: List of display modes made available to students + doc: > When viewing a problem, users may choose different methods of rendering formulas via an options box in the left panel. Here, you can adjust what display modes are listed. @@ -365,19 +355,19 @@ not give a choice of modes (since there will only be one active). type: multilist options: ['plainText','images','MathJax'] - default: ['plainText','images','MathJax'] + default_value: ['plainText','images','MathJax'] - - var: display_mode + setting_name: display_mode category: problem - doc: The default display mode + description: The default display mode type: list options: ['plainText','images','MathJax'] - default: MathJax + default_value: MathJax - - var: num_rel_percent_tol_default + setting_name: num_rel_percent_tol_default category: problem - doc: Allowed error, as a percentage, for numerical comparisons - doc2: > + description: Allowed error, as a percentage, for numerical comparisons + doc: > When numerical answers are checked, most test if the student's answer is close enough to the programmed answer be computing the error as a percentage of the correct answer. This value controls the default for how close the student answer has to be in order to be @@ -385,12 +375,12 @@ A value such as 0.1 means 0.1 percent error is allowed. type: decimal - default: 0.1 + default_value: 0.1 - - var: answer_entry_assist + setting_name: answer_entry_assist category: problem - doc: Assist with the student answer entry process. - doc2: | + description: Assist with the student answer entry process. + doc: | MathQuill renders students answers in real-time as they type on the keyboard. MathView allows students to choose from a variety of common math structures @@ -399,55 +389,55 @@ WIRIS provides a separate workspace for students to construct their response in a WYSIWYG environment. type: list options: ['None', 'MathQuill', 'MathView', 'WIRIS'] - default: None + default_value: None # this one may not be need depending on the UI. - - var: show_evaluated_answers + setting_name: show_evaluated_answers category: problem - doc: Display the evaluated student answer - doc2: | + description: Display the evaluated student answer + doc: | Set to true to display the "Entered" column which automatically shows the evaluated student answer, e.g. 1 if student input is sin(pi/2). If this is set to false, e.g. to save space in the response area, the student can still see their evaluated answer by hovering the mouse pointer over the typeset version of their answer. type: text - default: '' + default_value: '' - - var: use_base_10_log + setting_name: use_base_10_log category: problem - doc: Use log base 10 instead of base e - doc2: Set to true for log to mean base 10 log and false for log to mean natural logarithm + description: Use log base 10 instead of base e + doc: Set to true for log to mean base 10 log and false for log to mean natural logarithm type: boolean - default: false + default_value: false # is there any reason not to default for this and drop as an option? - - var: parse_alternatives + setting_name: parse_alternatives category: problem - doc: Allow Unicode alternatives in student answers - doc2: | + description: Allow Unicode alternatives in student answers + doc: | Set to true to allow students to enter Unicode versions of some characters (like U+2212 for the minus sign) in their answers. One reason to allow this is that copying and pasting output from MathJax can introduce these characters, but it is also getting easier to enter these characters directory from the keyboard. type: boolean - default: false + default_value: false - - var: convert_full_width_characters + setting_name: convert_full_width_characters category: problem - doc: Automatically convert Full Width Unicode characters to their ASCII equivalents - doc2: | + description: Automatically convert Full Width Unicode characters to their ASCII equivalents + doc: | Set to true to have Full Width Unicode character (U+FF01 to U+FF5E) converted to their ASCII equivalents (U+0021 to U+007E) automatically in MathObjects. This may be valuable for Chinese keyboards, for example, that automatically use Full Width characters for parentheses and commas. type: boolean - default: true + default_value: true - - var: waive_explanations + setting_name: waive_explanations category: problem - doc: Skip explanation essay answer fields - doc2: | + description: Skip explanation essay answer fields + doc: | Some problems have an explanation essay answer field, typically following a simpler answer field. For example, find a certain derivative using the definition. An answer blank would be present for the derivative to be automatically checked, and then there would be a separate @@ -455,13 +445,24 @@ scored manually. With this setting, the essay explanation fields are supperessed. Instructors may use the exercise without incurring the manual grading. type: boolean - default: false + default_value: false + +# permissions level +# Note: this may be handled in a different way + +- + setting_name: roles + category: permission + description: Defined roles + type: multilist + options: ['course_admin', 'instructor', 'student'] + default_value: ['course_admin', 'instructor', 'student'] + # settings related to email - - var: test_var_for_email + setting_name: default_subject category: email - doc: "this is just for testing" - type: decimal - # options: hi - default: -23.3 + description: default email subject + type: text + default_value: 'WeBWorK information:' diff --git a/lib/DB/Exception.pm b/lib/DB/Exception.pm index 8a3da8d5..84f4d54b 100644 --- a/lib/DB/Exception.pm +++ b/lib/DB/Exception.pm @@ -15,6 +15,10 @@ use Exception::Class ( fields => ['message'], description => 'There is an invalid field type' }, + 'DB::Expection::SettingNotFound' => { + fields => ['name'], + description => 'A global setting is not found' + }, 'DB::Exception::UndefinedParameter' => { fields => ['field_names'], description => 'There is an undefined parameter' diff --git a/lib/DB/Schema/Result/Course.pm b/lib/DB/Schema/Result/Course.pm index 08a2bb83..e201683a 100644 --- a/lib/DB/Schema/Result/Course.pm +++ b/lib/DB/Schema/Result/Course.pm @@ -88,7 +88,7 @@ __PACKAGE__->has_many(problem_sets => 'DB::Schema::Result::ProblemSet', 'course_ # set up the one-to-many relationship to problem_pools __PACKAGE__->has_many(problem_pools => 'DB::Schema::Result::ProblemPool', 'course_id'); -# set up the one-to-one relationship to course settings; -__PACKAGE__->has_one(course_settings => 'DB::Schema::Result::CourseSettings', 'course_id'); +# set up the one-to-many relationship to course settings; +__PACKAGE__->has_many(course_settings => 'DB::Schema::Result::CourseSetting', 'course_id'); 1; diff --git a/lib/DB/Schema/Result/CourseSetting.pm b/lib/DB/Schema/Result/CourseSetting.pm new file mode 100644 index 00000000..b1604e22 --- /dev/null +++ b/lib/DB/Schema/Result/CourseSetting.pm @@ -0,0 +1,67 @@ +package DB::Schema::Result::CourseSetting; +use base qw/DBIx::Class::Core/; +use strict; +use warnings; + +=head1 DESCRIPTION + +This is the database schema for a CourseSetting. + +=head2 fields + +=over + +=item * + +C: database id (autoincrement integer) + +=item * + +C: database id of the course for the setting (foreign key) + +=item * + +C: database id that the given setting is related to (foreign key) + +=item * + +C: the value of the setting + +=back + +=cut + +__PACKAGE__->table('course_setting'); + +__PACKAGE__->add_columns( + course_setting_id => { + data_type => 'integer', + size => 16, + is_nullable => 0, + is_auto_increment => 1, + }, + course_id => { + data_type => 'integer', + size => 16, + is_nullable => 0, + }, + setting_id => { + data_type => 'integer', + size => 16, + is_nullable => 0, + }, + value => { + data_type => 'text', + is_nullable => 0, + }, +); + +__PACKAGE__->set_primary_key('course_setting_id'); + +__PACKAGE__->add_unique_constraint([qw/course_id setting_id/]); + +__PACKAGE__->belongs_to(course => 'DB::Schema::Result::Course', 'course_id'); + +__PACKAGE__->belongs_to(global_setting => 'DB::Schema::Result::GlobalSetting', 'setting_id'); + +1; diff --git a/lib/DB/Schema/Result/CourseSettings.pm b/lib/DB/Schema/Result/CourseSettings.pm deleted file mode 100644 index f62783b2..00000000 --- a/lib/DB/Schema/Result/CourseSettings.pm +++ /dev/null @@ -1,128 +0,0 @@ -package DB::Schema::Result::CourseSettings; -use base qw/DBIx::Class::Core/; -use strict; -use warnings; - -=head1 DESCRIPTION - -This is the database schema for a CourseSetting. - -=head2 fields - -=over - -=item * - -C: database id (autoincrement integer) - -=item * - -C: database id of the course for the setting (foreign key) - -=item * - -C: a JSON object of general settings - -=item * - -C: a JSON object of optional settings - -=item * - -C: a JSON object that stores settings on the problem set level - -=item * - -C: a JSON object that stores settings on the problem level - -=item * - -C: a JSON object that stores settings for permissions - -=item * - -C: a JSON object that stores email settings - -=back - -=cut - -our @VALID_DATES = qw/open end/; -our @REQUIRED_DATES = qw//; -our $VALID = { - institution => q{.*}, - visible => q{[01]} -}; -our $REQUIRED = { _ALL_ => ['visible'] }; - -__PACKAGE__->table('course_settings'); - -__PACKAGE__->load_components('InflateColumn::Serializer', 'Core'); - -__PACKAGE__->add_columns( - course_settings_id => { - data_type => 'integer', - size => 16, - is_nullable => 0, - is_auto_increment => 1, - }, - course_id => { - data_type => 'integer', - size => 16, - is_nullable => 0, - }, - general => { - data_type => 'text', - size => 256, - is_nullable => 0, - default_value => '{}', - serializer_class => 'JSON', - serializer_options => { utf8 => 1 } - }, - optional => { - data_type => 'text', - size => 256, - is_nullable => 0, - default_value => '{}', - serializer_class => 'JSON', - serializer_options => { utf8 => 1 } - }, - problem_set => { - data_type => 'text', - size => 256, - is_nullable => 0, - default_value => '{}', - serializer_class => 'JSON', - serializer_options => { utf8 => 1 } - }, - problem => { - data_type => 'text', - size => 256, - is_nullable => 0, - default_value => '{}', - serializer_class => 'JSON', - serializer_options => { utf8 => 1 } - }, - permissions => { - data_type => 'text', - size => 256, - is_nullable => 0, - default_value => '{}', - serializer_class => 'JSON', - serializer_options => { utf8 => 1 } - }, - email => { - data_type => 'text', - size => 256, - is_nullable => 0, - default_value => '{}', - serializer_class => 'JSON', - serializer_options => { utf8 => 1 } - } -); - -__PACKAGE__->set_primary_key('course_settings_id'); - -__PACKAGE__->belongs_to(course => 'DB::Schema::Result::Course', 'course_id'); - -1; diff --git a/lib/DB/Schema/Result/GlobalSetting.pm b/lib/DB/Schema/Result/GlobalSetting.pm new file mode 100644 index 00000000..a16146c9 --- /dev/null +++ b/lib/DB/Schema/Result/GlobalSetting.pm @@ -0,0 +1,117 @@ +package DB::Schema::Result::GlobalSetting; +use base qw/DBIx::Class::Core/; +use strict; +use warnings; + +=head1 DESCRIPTION + +This is the database schema for the Global Course Settings. + +=head2 fields + +=over + +=item * + +C: database id (autoincrement integer) + +=item * + +C: the name of the setting + +=item * + +C: a JSON object of the default value for the setting + +=item * + +C: a short description of the setting + +=item * + +C: more extensive help documentation. + +=item * + +C: a string representation of the type of setting (boolean, text, list, ...) + +=item * + +C: a JSON object that stores options if the setting is an list or multilist + +=item * + +C: the category the setting falls into + +=item * + +C: the subcategory of the setting (may be null) + +=back + +=cut + +__PACKAGE__->table('global_setting'); + +__PACKAGE__->load_components('InflateColumn::Serializer', 'Core'); + +__PACKAGE__->add_columns( + setting_id => { + data_type => 'integer', + size => 16, + is_nullable => 0, + is_auto_increment => 1, + }, + setting_name => { + data_type => 'varchar', + size => 256, + is_nullable => 0, + }, + default_value => { + data_type => 'text', + is_nullable => 0, + default_value => '\'\'', + serializer_class => 'JSON', + serializer_options => { utf8 => 1 } + }, + description => { + data_type => 'text', + is_nullable => 0, + default_value => '', + }, + doc => { + data_type => 'text', + is_nullable => 1, + }, + type => { + data_type => 'varchar', + size => 16, + is_nullable => 0, + default_value => '', + }, + options => { + data_type => 'text', + is_nullable => 1, + serializer_class => 'JSON', + serializer_options => { utf8 => 1 } + }, + category => { + data_type => 'varchar', + size => 64, + is_nullable => 0, + default_value => '' + }, + subcategory => { + data_type => 'varchar', + size => 64, + is_nullable => 1 + } +); + +__PACKAGE__->set_primary_key('setting_id'); + +__PACKAGE__->has_many(course_settings => 'DB::Schema::Result::CourseSetting', 'setting_id'); + +# __PACKAGE__->belongs_to(course => 'DB::Schema::Result::Course', 'course_id'); + +1; diff --git a/lib/DB/Schema/ResultSet/Course.pm b/lib/DB/Schema/ResultSet/Course.pm index f7921a3b..49ce48aa 100644 --- a/lib/DB/Schema/ResultSet/Course.pm +++ b/lib/DB/Schema/ResultSet/Course.pm @@ -8,13 +8,15 @@ no warnings qw(experimental::signatures); use base 'DBIx::Class::ResultSet'; use Clone qw/clone/; -use DB::Utils qw/getCourseInfo getUserInfo/; -use DB::Exception; -use Exception::Class ('DB::Exception::CourseNotFound', 'DB::Exception::CourseExists'); +use DB::Utils qw/getCourseInfo getUserInfo getSettingInfo/; -#use DB::TestUtils qw/removeIDs/; -use WeBWorK3::Utils::Settings qw/getDefaultCourseSettings mergeCourseSettings - getDefaultCourseValues validateCourseSettings/; +use Exception::Class qw( + DB::Exception::CourseNotFound + DB::Exception::CourseExists + DB::Exception::SettingNotFound +); + +use WeBWorK3::Utils::Settings qw/ mergeCourseSettings isValidSetting/; =head1 DESCRIPTION @@ -140,7 +142,6 @@ sub addCourse ($self, %args) { # This should be looked up. $params->{$field} = $course_params->{$field} if defined($course_params->{$field}); } - $params->{course_settings} = {}; # Check the parameters. my $new_course = $self->create($params); @@ -251,6 +252,78 @@ sub getUserCourses ($self, %args) { return @user_courses_hashref; } +=pod + +=head2 getGlobalSettings + +This gets the Global/Default Settings for all courses + +=head3 input + +=over + +=item * C<$as_result_set>, a boolean if the return is to be a result_set + +=back + +=head3 output + +An array of courses as a C object +if C<$as_result_set> is true. Otherwise an array of hash_ref. + +=cut + +sub getGlobalSettings ($self, %args) { + my @global_settings = $self->result_source->schema->resultset('GlobalSetting')->search({}); + + return \@global_settings if $args{as_result_set}; + my @settings = map { + { $_->get_inflated_columns }; + } @global_settings; + for my $setting (@settings) { + # The default_value is stored as a JSON and needs to be parsed. + $setting->{default_value} = $setting->{default_value}->{value}; + } + return \@settings; +} + +=pod + +=head2 getGlobalSetting + +This gets a single global/default setting. + +=head3 input + +=over + +=item * C which is a hash of either a C or C with information +on the setting. + +=item * C<$as_result_set>, a boolean if the return is to be a result_set + +=back + +=head3 output + +A single global/default setting. + +=cut + +sub getGlobalSetting ($self, %args) { + my $setting_info = getSettingInfo($args{info}); + my $global_setting = $self->result_source->schema->resultset('GlobalSetting')->find($setting_info); + + DB::Exception::SettingNotFound->throw(message => $setting_info->{setting_name} + ? "The setting with name $setting_info->{setting_name} is not found" + : "The setting with setting_id $setting_info->{setting_id} is not found") + unless $global_setting; + return $global_setting if $args{as_result_set}; + my $setting_to_return = { $global_setting->get_inflated_columns }; + $setting_to_return->{default_value} = $setting_to_return->{default_value}->{value}; + return $setting_to_return; +} + =head2 getCourseSettings This gets the Course Settings for a course @@ -259,7 +332,10 @@ This gets the Course Settings for a course =over -=item * hashref containing info about the course +=item * C, hashref containing info about the course + +=item * C, a boolean on whether the course setting is merged with its corresponding +global setting. =item * C<$as_result_set>, a boolean if the return is to be a result_set @@ -267,28 +343,177 @@ This gets the Course Settings for a course =head3 output -An array of courses as a C object +An array of course settings as a C object if C<$as_result_set> is true. Otherwise an array of hash_ref. =cut sub getCourseSettings ($self, %args) { - my $course = $self->getCourse(info => $args{info}, as_result_set => 1); + my $course = $self->getCourse(info => $args{info}, as_result_set => 1); + my @settings_from_db = $course->course_settings; + + return \@settings_from_db if $args{as_result_set}; + my @settings_to_return; + if ($args{merged}) { + @settings_to_return = map { + { $_->get_inflated_columns, $_->global_setting->get_inflated_columns }; + } @settings_from_db; + for my $setting (@settings_to_return) { + $setting->{default_value} = $setting->{default_value}->{value}; + } + } else { + @settings_to_return = map { + { $_->get_inflated_columns }; + } @settings_from_db; + } + return \@settings_to_return; +} + +=pod + +=head2 getCourseSetting + +This gets a single course setting. + +=head3 input + +=over + +=item * C which is a hash of either a C or C with information +on the setting. + +=item * C, a boolean on whether the course setting is merged with its corresponding +global setting. + +=item * C<$as_result_set>, a boolean if the return is to be a result_set + +=back + +=head3 output + +A single course setting as either a hashref or a C object. + +=cut + +sub getCourseSetting ($self, %args) { + + my $global_setting = $self->getGlobalSetting(info => $args{info}, as_result_set => 1); + DB::Exception::SettingNotFound->throw( + message => "The setting with name: '" . $args{info}->{setting_name} . "' is not a defined info.") + unless defined($global_setting); - my $course_settings = getDefaultCourseValues(); - my $settings_from_db = { $course->course_settings->get_inflated_columns }; - return mergeCourseSettings($course_settings, $settings_from_db); + my $course = $self->getCourse(info => getCourseInfo($args{info}), as_result_set => 1); + my $setting = $course->course_settings->find({ setting_id => $global_setting->setting_id }); + + return $setting if $args{as_result_set}; + if ($args{merged}) { + my $setting_to_return = { $setting->get_inflated_columns, $setting->global_setting->get_inflated_columns }; + $setting_to_return->{default_value} = $setting_to_return->{default_value}->{value}; + return $setting_to_return; + } else { + return { $setting->get_inflated_columns }; + } } -sub updateCourseSettings ($self, %args) { - my $course = $self->getCourse(info => $args{info}, as_result_set => 1); - validateCourseSettings($args{settings}); +=pod + +=head2 updateCourseSetting + +Update a single course setting. + +=head3 input + +=over + +=item * C which is a hash containing information about the course (either a +C or C) and a setting (either a C or C). + +=item * C the updated value of the course setting. + +=item * C, a boolean on whether the course setting is merged with its corresponding +global setting. + +=item * C<$as_result_set>, a boolean if the return is to be a result_set + +=back + +=head3 output + +A single course setting as either a hashref or a C object. + +=cut +use Data::Dumper; +sub updateCourseSetting ($self, %args) { + my $course = $self->getCourse(info => getCourseInfo($args{info}), as_result_set => 1); + + my $global_setting = $self->getGlobalSetting(info => getSettingInfo($args{info})); + + my $course_setting = $course->course_settings->find({ + setting_id => $global_setting->{setting_id} + }); + + # Check that the setting is valid. + + my $params = { + course_id => $course->course_id, + setting_id => $global_setting->{setting_id}, + value => $args{params}->{value} + }; + + # remove the following fields before checking for valid settings: + for (qw/setting_id course_id/) { delete $global_setting->{$_}; } + + isValidSetting($global_setting, $params->{value}); + + # The course_id must be deleted to ensure it is written to the database correctly. + delete $params->{course_id} if defined($params->{course_id}); + + my $updated_course_setting = defined($course_setting) ? $course_setting->update($params) : $course->add_to_course_settings($params); + + if ($args{merged}) { + my $setting_to_return = { + $updated_course_setting->get_inflated_columns, + $updated_course_setting->global_setting->get_inflated_columns + }; + $setting_to_return->{default_value} = $setting_to_return->{default_value}->{value}; + return $setting_to_return; + } else { + return { $updated_course_setting->get_inflated_columns }; + } + + + return $args{as_result_set} ? $updated_course_setting : { $updated_course_setting->get_inflated_columns }; +} + +=pod + +=head2 deleteCourseSetting + +Delete a single course setting. + +=head3 input + +=over + +=item * C which is a hash containing information about the course (either a +C or C) and a setting (either a C or C). + +=item * C<$as_result_set>, a boolean if the return is to be a result_set + +=back + +=head3 output + +A single course setting as either a hashref or a C object. + +=cut - my $current_settings = { $course->course_settings->get_inflated_columns }; - my $updated_settings = mergeCourseSettings($current_settings, $args{settings}); +sub deleteCourseSetting ($self, %args) { + my $setting = $self->getCourseSetting(info => $args{info}, as_result_set => 1); + my $deleted_setting = $setting->delete; - my $cs = $course->course_settings->update($updated_settings); - return mergeCourseSettings(getDefaultCourseValues(), { $cs->get_inflated_columns }); + return $deleted_setting if $args{as_result_set}; + return { $deleted_setting->get_inflated_columns }; } 1; diff --git a/lib/DB/Utils.pm b/lib/DB/Utils.pm index 7751877c..f0aa8184 100644 --- a/lib/DB/Utils.pm +++ b/lib/DB/Utils.pm @@ -8,7 +8,7 @@ no warnings qw(experimental::signatures); require Exporter; use base qw(Exporter); our @EXPORT_OK = qw/getCourseInfo getUserInfo getSetInfo updateAllFields - getPoolInfo getProblemInfo getPoolProblemInfo removeLoginParams/; + getPoolInfo getProblemInfo getPoolProblemInfo getSettingInfo removeLoginParams/; use Carp; use Clone qw/clone/; @@ -41,6 +41,10 @@ sub getPoolProblemInfo ($in) { return _get_info($in, qw/library_id pool_problem_id/); } +sub getSettingInfo ($in) { + return _get_info($in, qw/setting_name setting_id/); +} + # This is a generic internal subroutine to check that the info passed in contains certain fields. # $input_info is a hashref containing various search information. diff --git a/lib/WeBWorK3.pm b/lib/WeBWorK3.pm index 50f8e57b..d65442ca 100644 --- a/lib/WeBWorK3.pm +++ b/lib/WeBWorK3.pm @@ -183,12 +183,16 @@ sub problemRoutes ($self) { } sub settingsRoutes ($self) { - $self->routes->get('/webwork3/api/default_settings')->requires(authenticated => 1) - ->to('Settings#getDefaultCourseSettings'); + $self->routes->get('/webwork3/api/global-settings')->requires(authenticated => 1) + ->to('Settings#getGlobalSettings'); + $self->routes->get('/webwork3/api/global-setting/:setting_id')->requires(authenticated => 1) + ->to('Settings#getGlobalSetting'); $self->routes->get('/webwork3/api/courses/:course_id/settings')->requires(authenticated => 1) ->to('Settings#getCourseSettings'); - $self->routes->put('/webwork3/api/courses/:course_id/setting')->requires(authenticated => 1) + $self->routes->put('/webwork3/api/courses/:course_id/settings/:setting_id')->requires(authenticated => 1) ->to('Settings#updateCourseSetting'); + $self->routes->delete('/webwork3/api/courses/:course_id/settings/:setting_id')->requires(authenticated => 1) + ->to('Settings#deleteCourseSetting'); return; } diff --git a/lib/WeBWorK3/Controller/Settings.pm b/lib/WeBWorK3/Controller/Settings.pm index 7e959477..c88f2110 100644 --- a/lib/WeBWorK3/Controller/Settings.pm +++ b/lib/WeBWorK3/Controller/Settings.pm @@ -10,41 +10,53 @@ These are the methods that call the database for course settings =cut use Mojo::Base 'Mojolicious::Controller', -signatures; -use Mojo::File qw/path/; -use YAML::XS qw/LoadFile/; - -# This reads the default settings from a file. +sub getGlobalSettings ($c) { + my $settings = $c->schema->resultset('Course')->getGlobalSettings(); + $c->render(json => $settings); + return; +} -sub getDefaultCourseSettings ($self) { - my $settings = LoadFile(path($self->config->{webwork3_home}, 'conf', 'course_defaults.yml')); - # Check if the file exists. - $self->render(json => $settings); +sub getGlobalSetting ($c) { + my $setting = $c->schema->resultset('Course')->getGlobalSetting(info => { + setting_id => int($c->param('setting_id')) + }); + $c->render(json => $setting); return; } -sub getCourseSettings ($self) { - my $course_settings = $self->schema->resultset('Course')->getCourseSettings( +sub getCourseSettings ($c) { + my $course_settings = $c->schema->resultset('Course')->getCourseSettings( info => { - course_id => int($self->param('course_id')), - } + course_id => int($c->param('course_id')), + }, + merged => 1 + ); + $c->render(json => $course_settings); + return; +} + +sub updateCourseSetting ($c) { + my $course_setting = $c->schema->resultset('Course')->updateCourseSetting( + info => { + course_id => $c->param('course_id'), + setting_id => $c->param('setting_id') + }, + params => $c->req->json ); - # Flatten to a single array. - my @course_settings = (); - for my $category (keys %$course_settings) { - for my $key (keys %{ $course_settings->{$category} }) { - push(@course_settings, { var => $key, value => $course_settings->{$category}->{$key} }); - } - } - $self->render(json => \@course_settings); + $c->render(json => $course_setting); return; } -sub updateCourseSetting ($self) { - my $course_setting = $self->schema->resultset('Course') - ->updateCourseSettings({ course_id => $self->param('course_id') }, $self->req->json); - $self->render(json => $course_setting); +sub deleteCourseSetting ($c) { + my $course_setting = $c->schema->resultset('Course')->deleteCourseSetting( + info => { + course_id => $c->param('course_id'), + setting_id => $c->param('setting_id') + }); + $c->render(json => $course_setting); return; } + 1; diff --git a/lib/WeBWorK3/Utils/Settings.pm b/lib/WeBWorK3/Utils/Settings.pm index e4535728..54affc07 100644 --- a/lib/WeBWorK3/Utils/Settings.pm +++ b/lib/WeBWorK3/Utils/Settings.pm @@ -5,168 +5,33 @@ use warnings; use feature 'signatures'; no warnings qw(experimental::signatures); -use YAML::XS qw/LoadFile/; use Carp; require Exporter; use base qw(Exporter); -our @EXPORT_OK = qw/checkSettings getDefaultCourseSettings getDefaultCourseValues - mergeCourseSettings validateSettingsConfFile validateCourseSettings - validateSingleCourseSetting validateSettingConfig - isInteger isTimeString isTimeDuration isDecimal/; +our @EXPORT_OK = qw/isValidSetting mergeCourseSettings isInteger isTimeString isTimeDuration isDecimal/; use DB::Exception::UndefinedCourseField; use DB::Exception::InvalidCourseField; use DB::Exception::InvalidCourseFieldType; -use WeBWorK3; - -my @allowed_fields = qw/var category subcategory doc doc2 default type options/; -my @required_fields = qw/var doc type default/; - -=head1 loadDefaultCourseSettings - -load the default settings from the conf/course_settings.yaml file - -=cut - -sub getDefaultCourseSettings () { - return LoadFile("$ENV{WW3_ROOT}/conf/course_defaults.yml"); -} +use DateTime::TimeZone; +use JSON::PP; +use Array::Utils qw/array_minus/; +my @allowed_fields = qw/setting_name category subcategory description doc default_value type options/; +my @required_fields = qw/setting_name description type default_value/; my @course_setting_categories = qw/email optional general permissions problem problem_set/; - -=head1 getDefaultCourseValues - -getDefaultCourseValues returns the values of all default course values and returns -it as a hash of categories/variables - -=cut - -sub getDefaultCourseValues () { - my $course_defaults = getDefaultCourseSettings(); # The full default course settings - - my $all_settings = {}; - for my $category (@course_setting_categories) { - $all_settings->{$category} = {}; - my @settings = grep { $_->{category} eq $category } @$course_defaults; - for my $setting (@settings) { - $all_settings->{$category}->{ $setting->{var} } = $setting->{default}; - } - } - return $all_settings; -} - -=head1 mergeCourseSettings - -mergeCourseSettings takes in two settings and merges them in the following way: - -For each course setting in the first argument (typically from the configuration file) -1. If a value in the second argument is present use that else -2. use the value from the first argument - -=cut - -sub mergeCourseSettings ($settings, $settings_to_update) { - my $updated_settings = {}; - - # Merge the non-optional categories. - for my $category (@course_setting_categories) { - $updated_settings->{$category} = {}; - my @fields = keys %{ $settings->{$category} }; - push(@fields, keys %{ $settings_to_update->{$category} }); - for my $key (@fields) { - # Use the value in $settings_to_update if it exists, if not use the other. - $updated_settings->{$category}->{$key} = - $settings_to_update->{$category}->{$key} || $settings->{$category}->{$key}; - } - } - - return $updated_settings; -} +my @valid_types = qw/text list multilist boolean int decimal time date_time time_duration timezone/; =pod -checkSettingsConfFile loads the course settings configuration file and checks for validity - -=cut - -sub validateSettingsConfFile () { - #my @all_settings = getDefaultCourseSettings(); - for my $setting (@{ getDefaultCourseSettings() }) { - validateSettingConfig($setting); - } - return 1; -} - -=pod +=head2 isValidSetting -isValidCourseSettings checks if the course settings are valid including +This checks if the setting given the type, value and list of options (if needed). This includes =over -=item the key is defined in the course setting configuration file - -=item the value is appropriate for the given setting. - -=back - -=cut - -sub flattenCourseSettings ($settings) { - my @flattened_settings = (); - for my $category (keys %$settings) { - for my $var (keys %{ $settings->{$category} }) { - push( - @flattened_settings, - { - var => $var, - category => $category, - value => $settings->{$category}->{$var} - } - ); - } - } - return \@flattened_settings; -} - -sub validateCourseSettings ($course_settings) { - $course_settings = flattenCourseSettings($course_settings); - my $default_course_settings = getDefaultCourseSettings(); - for my $setting (@$course_settings) { - validateSingleCourseSetting($setting, $default_course_settings); - } - return 1; -} - -sub validateSingleCourseSetting ($setting, $default_course_settings) { - my @default_setting = grep { $_->{var} eq $setting->{var} } @$default_course_settings; - DB::Exception::UndefinedCourseField->throw(message => qq/The course setting $setting->{var} is not valid/) - unless scalar(@default_setting) == 1; - - validateSetting($setting); - - return 1; -} - -=pod - -This checks the variable name (to ensure it is in kebob case) - -=cut - -sub kebobCase ($in) { - return $in =~ /^[a-z][a-z_\d]*[a-z\d]$/; -} - -=head1 validateSettingsConfig - -This checks the configuration for a single setting is valid. This includes - -=over - -=item Check that the variable name is kebob case - =item Ensure that all fields passed in are valid =item Ensure that all require fields are present @@ -177,18 +42,16 @@ This checks the configuration for a single setting is valid. This includes =cut -my @valid_types = qw/text list multilist boolean integer decimal time date_time time_duration timezone/; - -sub validateSettingConfig ($setting) { - # Check that the variable name is kebobCase. - DB::Exception::InvalidCourseField->throw(message => "The variable name $setting->{var} must be in kebob case") - unless kebobCase($setting->{var}); +sub isValidSetting ($setting, $value = undef) { + return 0 if !defined $setting->{type}; + # If $value is not passed in, use the default_value for the setting + my $val = $value // $setting->{default_value}; # Check that each of the setting fields is allowed. for my $field (keys %$setting) { my @fields = grep { $_ eq $field } @allowed_fields; DB::Exception::InvalidCourseField->throw( - message => "The field: $field is not an allowed field of the setting $setting->{var}") + message => "The field: $field is not an allowed field of the setting $setting->{setting_name}") if scalar(@fields) == 0; } @@ -196,61 +59,100 @@ sub validateSettingConfig ($setting) { for my $field (@required_fields) { my @fields = grep { $_ eq $field } (keys %$setting); DB::Exception::InvalidCourseField->throw( - message => "The field: $field is a required field for the setting $setting->{var}") + message => "The field: $field is a required field for the setting $setting->{setting_name}") if scalar(@fields) == 0; } - my @type = grep { $_ eq $setting->{type} } @valid_types; - DB::Exception::InvalidCourseFieldType->throw( - message => "The setting type: $setting->{type} is not valid for variable: $setting->{var}") - unless scalar(@type) == 1; - - return validateSetting($setting); -} - -sub validateSetting ($setting) { - my $value = $setting->{default} || $setting->{value}; - - return 0 if !defined $setting->{type}; - - if ($setting->{type} eq 'list') { - validateList($setting); + if ($setting->{type} eq 'text') { + # any val is valid. + } elsif ($setting->{type} eq 'boolean') { + my $is_bool = JSON::PP::is_bool($val); + DB::Exception::InvalidCourseFieldType->throw( + message => qq/The variable $setting->{setting_name} has value $val and must be a boolean./) + unless $is_bool; + } elsif ($setting->{type} eq 'list') { + validateList($setting, $val); + } elsif ($setting->{type} eq 'multilist') { + validateMultilist($setting, $val); } elsif ($setting->{type} eq 'time') { DB::Exception::InvalidCourseFieldType->throw( - message => qq/The default for variable $setting->{var} which is $value must be a time value/) - unless isTimeString($setting->{default}); - } elsif ($setting->{type} eq 'integer') { + message => qq/The variable $setting->{setting_name} has value $val and must be a time in the form XX:XX/) + unless isTimeString($val); + } elsif ($setting->{type} eq 'int') { DB::Exception::InvalidCourseFieldType->throw( - message => qq/The default for variable $setting->{var} which is $value must be an integer/) - unless isInteger($setting->{default}); + message => qq/The variable $setting->{setting_name} has value $val and must be an integer./) + unless isInteger($val); } elsif ($setting->{type} eq 'decimal') { DB::Exception::InvalidCourseFieldType->throw( - message => qq/The default for variable $setting->{var} which is $value must be a decimal/) - unless isDecimal($setting->{default}); + message => qq/The variable $setting->{setting_name} has value $val and must be a decimal/) + unless isDecimal($val); } elsif ($setting->{type} eq 'time_duration') { DB::Exception::InvalidCourseFieldType->throw( - message => qq/The default for variable $setting->{var} which is $value must be a time duration/) - unless isTimeDuration($setting->{default}); + message => qq/The variable $setting->{setting_name} has value $val and must be a time duration/) + unless isTimeDuration($val); + } elsif ($setting->{type} eq 'timezone') { + # try to make a new timeZone. If the name isn't valid an 'Invalid offset:' will be thrown. + DateTime::TimeZone->new(name => $val); + } else { + DB::Exception::InvalidCourseFieldType->throw(message => qq/The setting type $setting->{type} is not valid/); } return 1; } -sub validateList ($setting) { - croak "The options field for the type list in $setting->{var} is missing " +=pod + +=head2 validateList + +This returns true if a valid setting of type 'list' given its value. Specifically, the options +field of the setting must exist and the value must be an elemeent in the array. + +Note: the options arrayref may contain hashes of label/value pairs, which is used +on the UI. + +=cut + +sub validateList ($setting, $value) { + DB::Exception::InvalidCourseFieldType->throw( + message => "The options field for the type list in $setting->{setting_name} is missing ") unless defined($setting->{options}); - croak "The options field for $setting->{var} is not an ARRAYREF" unless ref($setting->{options}) eq 'ARRAY'; + DB::Exception::InvalidCourseFieldType->throw(message => "The options field for $setting->{setting_name} is not an ARRAYREF") + unless ref($setting->{options}) eq 'ARRAY'; # See if the $setting->{options} is an arrayref of strings or hashrefs. my @opt = (ref($setting->{options}->[0]) eq 'HASH') - ? grep { $_ eq $setting->{default} } map { $_->{value} } @{ $setting->{options} } - : grep { $_ eq $setting->{default} } @{ $setting->{options} }; - croak "The default for variable $setting->{var} needs to be one of the given options" + ? grep { $_ eq $value } map { $_->{value} } @{ $setting->{options} } + : grep { $_ eq $value } @{ $setting->{options} }; + DB::Exception::InvalidCourseFieldType->throw( + message => "The default for variable $setting->{setting_name} needs to be one of the given options") unless scalar(@opt) == 1; return 1; } +=pod +=head2 validateMultilist + +This returns true if the setting of type mutlilist is valid. If not, a error is thrown. +A valid mutilist is one in which the value is a subset of the options. Unlike a list, a +multilist is only arrayrefs of strings (not label/value pairs). + +=cut + +sub validateMultilist ($setting, $value) { + DB::Exception::InvalidCourseFieldType->throw( + message => "The options field for the type multilist in $setting->{setting_name} is missing ") + unless defined($setting->{options}); + DB::Exception::InvalidCourseFieldType->throw(message => "The options field for $setting->{setting_name} is not an ARRAYREF") + unless ref($setting->{options}) eq 'ARRAY'; + + my @diff = array_minus(@{ $setting->{options} }, @$value); + throw DB::Exception::InvalidCourseFieldType->throw( + message => "The values for $setting->{setting_name} must be a subset of the options field") + unless scalar(@diff) == 0; +} + +# Test for an integer. sub isInteger ($in) { return $in =~ /^-?\d+$/; } @@ -265,6 +167,7 @@ sub isTimeDuration ($in) { return $in =~ /^(\d+)\s(sec|second|min|minute|day|week|hr|hour)s?$/i; } +# Test for a decimal. sub isDecimal ($in) { return $in =~ /(^-?\d+(\.\d+)?$)|(^-?\.\d+$)/; } diff --git a/package-lock.json b/package-lock.json index e9c51796..bef06ebc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,7 +47,8 @@ "stylelint-config-standard": "^22.0.0", "stylelint-scss": "^3.20.1", "stylelint-webpack-plugin": "^3.0.1", - "ts-jest": "^27.0.5" + "ts-jest": "^27.0.5", + "yaml": "^2.1.1" }, "engines": { "node": ">= 12.22.1", @@ -6017,6 +6018,15 @@ "node": ">=8" } }, + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, "node_modules/crc-32": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.1.tgz", @@ -6443,6 +6453,15 @@ "postcss": "^8.2.15" } }, + "node_modules/cssnano/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, "node_modules/csso": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/csso/-/csso-4.2.0.tgz", @@ -14153,6 +14172,15 @@ "node": ">=10" } }, + "node_modules/postcss-loader/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, "node_modules/postcss-media-query-parser": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz", @@ -16725,6 +16753,15 @@ "node": ">=8" } }, + "node_modules/stylelint/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, "node_modules/sugarss": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/sugarss/-/sugarss-2.0.0.tgz", @@ -18933,12 +18970,12 @@ "dev": true }, "node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.1.1.tgz", + "integrity": "sha512-o96x3OPo8GjWeSLF+wOAbrPfhFOGY0W00GNaxCDv+9hkcDJEnev1yh8S7pgHF0ik6zc8sQLuL8hjHjJULZp8bw==", "dev": true, "engines": { - "node": ">= 6" + "node": ">= 14" } }, "node_modules/yaml-eslint-parser": { @@ -18961,6 +18998,15 @@ "node": ">=4" } }, + "node_modules/yaml-eslint-parser/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, "node_modules/yargs": { "version": "16.2.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", @@ -23549,6 +23595,14 @@ "parse-json": "^5.0.0", "path-type": "^4.0.0", "yaml": "^1.7.2" + }, + "dependencies": { + "yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "dev": true + } } }, "crc-32": { @@ -23812,6 +23866,14 @@ "cssnano-preset-default": "^5.1.12", "lilconfig": "^2.0.3", "yaml": "^1.10.2" + }, + "dependencies": { + "yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "dev": true + } } }, "cssnano-preset-default": { @@ -29608,6 +29670,12 @@ "requires": { "lru-cache": "^6.0.0" } + }, + "yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "dev": true } } }, @@ -31420,6 +31488,12 @@ "requires": { "has-flag": "^4.0.0" } + }, + "yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "dev": true } } }, @@ -33149,9 +33223,9 @@ "dev": true }, "yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.1.1.tgz", + "integrity": "sha512-o96x3OPo8GjWeSLF+wOAbrPfhFOGY0W00GNaxCDv+9hkcDJEnev1yh8S7pgHF0ik6zc8sQLuL8hjHjJULZp8bw==", "dev": true }, "yaml-eslint-parser": { @@ -33170,6 +33244,12 @@ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", "dev": true + }, + "yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "dev": true } } }, diff --git a/package.json b/package.json index b6fd974a..b4df47cc 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,8 @@ "stylelint-config-standard": "^22.0.0", "stylelint-scss": "^3.20.1", "stylelint-webpack-plugin": "^3.0.1", - "ts-jest": "^27.0.5" + "ts-jest": "^27.0.5", + "yaml": "^2.1.1" }, "browserslist": [ "last 10 Chrome versions", diff --git a/src/common/models/parsers.ts b/src/common/models/parsers.ts index 701e30fb..2f8ee3fe 100644 --- a/src/common/models/parsers.ts +++ b/src/common/models/parsers.ts @@ -106,6 +106,9 @@ export const non_neg_int_re = /^\s*(\d+)\s*$/; export const non_neg_decimal_re = /(^\s*(\d+)(\.\d*)?\s*$)|(^\s*\.\d+\s*$)/; export const mail_re = /^[\w.]+@([a-zA-Z_.]+)+\.[a-zA-Z]{2,9}$/; export const username_re = /^[_a-zA-Z]([a-zA-Z._0-9])+$/; +export const time_re = /^([01][0-9]|2[0-3]):[0-5]\d$/; +// Update this for localization +export const time_duration_re = /^(\d+)\s(sec|second|min|minute|day|week|hr|hour)s?$/i; // Checking functions @@ -113,6 +116,8 @@ export const isNonNegInt = (v: number | string) => non_neg_int_re.test(`${v}`); export const isNonNegDecimal = (v: number | string) => non_neg_decimal_re.test(`${v}`); export const isValidUsername = (v: string) => username_re.test(v) || mail_re.test(v); export const isValidEmail = (v: string) => mail_re.test(v); +export const isTimeDuration = (v: string) => time_duration_re.test(v); +export const isTime = (v: string) => time_re.test(v); // Parsing functionis diff --git a/src/common/models/settings.ts b/src/common/models/settings.ts index c048a907..74ea3224 100644 --- a/src/common/models/settings.ts +++ b/src/common/models/settings.ts @@ -1,35 +1,320 @@ /* These are related to Course Settings */ -export enum CourseSettingOption { +import { Model } from '.'; +import { isTime, isTimeDuration } from './parsers'; + +export enum SettingType { int = 'int', decimal = 'decimal', list = 'list', multilist = 'multilist', text = 'text', - boolean = 'boolean' + boolean = 'boolean', + time_duration = 'time_duration', + timezone = 'timezone', + time = 'time', + unknown = 'unknown' } -export class CourseSetting { - var: string; - value: string | number | boolean | Array; - constructor(params: { var?: string; value?: string | number | boolean | Array}) { - this.var = params.var ?? ''; - this.value = params.value ?? ''; +export interface OptionType { + label: string; + value: string; +}; + +export type SettingValueType = number | boolean | string | string[] | OptionType[]; + +export interface ParseableGlobalSetting { + setting_id?: number; + setting_name?: string; + category?: string; + subcategory?: string; + description?: string; + doc?: string; + type?: string; + options?: string[] | OptionType[]; + default_value?: SettingValueType; +} + +export class GlobalSetting extends Model { + private _setting_id = 0; + private _setting_name = ''; + private _default_value: SettingValueType = ''; + private _category = ''; + private _subcategory?: string; + private _options?: string[] | OptionType[]; + private _description = ''; + private _doc?: string; + private _type: SettingType = SettingType.unknown; + + constructor(params: ParseableGlobalSetting = {}) { + super(); + this.set(params); + } + + static ALL_FIELDS = ['setting_id', 'setting_name', 'default_value', 'category', + 'subcategory', 'description', 'doc', 'type', 'options']; + get all_field_names(): string[] { return GlobalSetting.ALL_FIELDS; } + get param_fields(): string[] { return []; } + + set(params: ParseableGlobalSetting) { + if (params.setting_id != undefined) this.setting_id = params.setting_id; + if (params.setting_name != undefined) this.setting_name = params.setting_name; + if (params.default_value != undefined) this.default_value = params.default_value; + if (params.category != undefined) this.category = params.category; + this.subcategory = params.subcategory; + if (params.description != undefined) this.description = params.description; + this.doc = params.doc; + if (params.type != undefined) this.type = params.type; + this.options = params.options; } + + get setting_id() { return this._setting_id; } + set setting_id(v: number) { this._setting_id = v; } + + get setting_name() { return this._setting_name; } + set setting_name(v: string) { this._setting_name = v; } + + get default_value() { return this._default_value; } + set default_value(v: SettingValueType) { this._default_value = v; } + + get category() { return this._category; } + set category(v: string) { this._category = v; } + + get subcategory() { return this._subcategory; } + set subcategory(v: string | undefined) { this._subcategory = v; } + + get options() { return this._options; } + set options(v: undefined | string[] | OptionType[]) { this._options = v; } + + get description() { return this._description; } + set description(v: string) { this._description = v; } + + get doc() { return this._doc; } + set doc(v: string | undefined) { this._doc = v; } + + get type() { return this._type; } + set type(v: string) { this._type = parseSettingType(v); } + + clone(): GlobalSetting { return new GlobalSetting(this.toObject()); } + + /** + * returns whether or not the setting is valid. The name, category and description fields cannot + * be the empty string, and the type cannot be unknown. + */ + + isValid() { return this.setting_name.length > 0 && this.category.length > 0 && this.description.length > 0 + && validSettingValue(this, this.default_value); } } -export interface OptionType { - label: string; - value: string | number; +/** + * This checks if the value is consistent with the type of the setting. + */ +const validSettingValue = (setting: GlobalSetting | CourseSetting, v: SettingValueType): boolean => { + const opts = setting.options; + switch (setting.type) { + case SettingType.int: return typeof(v) === 'number' && Number.isInteger(v); + case SettingType.decimal: return typeof(v) === 'number'; + case SettingType.list: + return opts != undefined && Array.isArray(opts) && opts[0] != undefined + && (Object.prototype.hasOwnProperty.call(opts[0], 'label') ? + // opts is OptionType + (opts as OptionType[]).map(o => o.value).includes(v as string) : + // opts is a string + (opts as string[]).includes(v as string)); + case SettingType.multilist: + return opts != undefined && Array.isArray(opts) && opts[0] != undefined + && (Object.prototype.hasOwnProperty.call(opts[0], 'label') ? + // opts is OptionType[] + (v as string[]).every(x => (opts as OptionType[]).map(o => o.value).includes(x)) : + // opts is string[] + (v as string[]).every(x => (opts as string[]).includes(x))); + case SettingType.text: return typeof(v) === 'string'; + case SettingType.boolean: return typeof(v) === 'boolean'; + case SettingType.time: return typeof(v) === 'string' && isTime(v); + case SettingType.time_duration: return typeof(v) === 'string' && isTimeDuration(v); + case SettingType.timezone: return typeof(v) === 'string'; + default: return false; + + } +}; + +const parseSettingType = (v: string): SettingType => { + switch (v.toLowerCase()) { + case 'int': return SettingType.int; + case 'decimal': return SettingType.decimal; + case 'list': return SettingType.list; + case 'multilist': return SettingType.multilist; + case 'text': return SettingType.text; + case 'boolean': return SettingType.boolean; + case 'time': return SettingType.time; + case 'time_duration': return SettingType.time_duration; + case 'timezone': return SettingType.timezone; + default: + return SettingType.unknown; + } +}; + +/** + * This is a parseable version for the course settting in the database. + */ + +export interface ParseableDBCourseSetting { + course_setting_id?: number; + course_id?: number; + setting_id?: number; + value?: SettingValueType; } -export interface CourseSettingInfo { - var: string; - category: string; - doc: string; - doc2: string; - type: CourseSettingOption; - options: Array | Array | undefined; - default: string | number | boolean; +/** + * A DBCourseSetting is a CourseSetting in the database with foreign keys for + * the course and the global setting. + */ +export class DBCourseSetting extends Model { + private _course_setting_id = 0; + private _course_id = 0; + private _setting_id = 0; + private _value?: SettingValueType; + + constructor(params: ParseableDBCourseSetting = {}) { + super(); + this.set(params); + } + + static ALL_FIELDS = ['course_setting_id', 'course_id', 'setting_id', 'value']; + get all_field_names(): string[] { return DBCourseSetting.ALL_FIELDS; } + get param_fields(): string[] { return []; } + + set(params: ParseableDBCourseSetting) { + if (params.course_setting_id != undefined) this.course_setting_id = params.course_setting_id; + if (params.course_id != undefined) this.course_id = params.course_id; + if (params.setting_id != undefined) this.setting_id = params.setting_id; + this.value = params.value; + } + + get course_setting_id() { return this._course_setting_id; } + set course_setting_id(v: number) { this._course_setting_id = v; } + + get setting_id() { return this._setting_id; } + set setting_id(v: number) { this._setting_id = v; } + + get course_id() { return this._course_id; } + set course_id(v: number) { this._course_id = v; } + + get value() { return this._value; } + set value(v: SettingValueType | undefined) { this._value = v; } + + isValid(): boolean { + return true; + } + + clone(): DBCourseSetting { + return new DBCourseSetting(this.toObject()); + } +} + +export interface ParseableCourseSetting { + setting_id?: number; + course_setting_id?: number; + course_id?: number; + value?: SettingValueType; + setting_name?: string; + category?: string; + subcategory?: string; + description?: string; + doc?: string; + type?: string; + options?: string[] | OptionType[]; + default_value?: SettingValueType; +} + +/** + * A CourseSetting is a merge between a GlobalSetting and any override from the + * DBCourseSetting. + */ + +export class CourseSetting extends Model { + private _setting_id = 0; + private _course_setting_id = 0; + private _course_id = 0; + private _setting_name = ''; + private _default_value: SettingValueType = ''; + private _value?: SettingValueType; + private _category = ''; + private _subcategory?: string; + private _options?: string[] | OptionType[]; + private _description = ''; + private _doc?: string; + private _type: SettingType = SettingType.unknown; + + constructor(params: ParseableCourseSetting = {}) { + super(); + this.set(params); + } + + static ALL_FIELDS = ['setting_id', 'course_setting_id', 'course_id', 'value', 'setting_name', + 'default_value', 'category', 'subcategory', 'description', 'doc', 'type', 'options']; + get all_field_names(): string[] { return CourseSetting.ALL_FIELDS; } + get param_fields(): string[] { return []; } + + set(params: ParseableCourseSetting) { + if (params.setting_id != undefined) this.setting_id = params.setting_id; + if (params.course_setting_id != undefined) this.course_setting_id = params.course_setting_id; + if (params.course_id != undefined) this.course_id = params.course_id; + this.value = params.value; + if (params.setting_name != undefined) this.setting_name = params.setting_name; + if (params.default_value != undefined) this.default_value = params.default_value; + if (params.category != undefined) this.category = params.category; + this.subcategory = params.subcategory; + if (params.description != undefined) this.description = params.description; + this.doc = params.doc; + if (params.type != undefined) this.type = params.type; + this.options = params.options; + } + + get setting_id() { return this._setting_id; } + set setting_id(v: number) { this._setting_id = v; } + + get course_setting_id() { return this._course_setting_id; } + set course_setting_id(v: number) { this._course_setting_id = v; } + + get course_id() { return this._course_id; } + set course_id(v: number) { this._course_id = v; } + + get value(): SettingValueType { return this._value != undefined ? this._value : this.default_value; } + set value(v: SettingValueType | undefined) { this._value = v; } + + get setting_name() { return this._setting_name; } + set setting_name(v: string) { this._setting_name = v; } + + get default_value() { return this._default_value; } + set default_value(v: SettingValueType) { this._default_value = v; } + + get category() { return this._category; } + set category(v: string) { this._category = v; } + + get subcategory() { return this._subcategory; } + set subcategory(v: string | undefined) { this._subcategory = v; } + + get options() { return this._options; } + set options(v: undefined | string[] | OptionType[]) { this._options = v; } + + get description() { return this._description; } + set description(v: string) { this._description = v; } + + get doc() { return this._doc; } + set doc(v: string | undefined) { this._doc = v; } + + get type() { return this._type; } + set type(v: string) { this._type = parseSettingType(v); } + + clone(): CourseSetting { return new CourseSetting(this.toObject()); } + + /** + * returns whether or not the setting is valid. The name, category and description fields cannot + * be the empty string and the type cannot be unknown. + */ + + isValid() { return this.setting_name.length > 0 && this.category.length > 0 && this.description.length > 0 + && validSettingValue(this, this.default_value) && validSettingValue(this, this.value); } } diff --git a/src/components/instructor/SingleSetting.vue b/src/components/instructor/SingleSetting.vue index 698e28df..e662630e 100644 --- a/src/components/instructor/SingleSetting.vue +++ b/src/components/instructor/SingleSetting.vue @@ -1,7 +1,7 @@ - diff --git a/src/stores/settings.ts b/src/stores/settings.ts index df207056..d40c81d4 100644 --- a/src/stores/settings.ts +++ b/src/stores/settings.ts @@ -2,79 +2,113 @@ import { api } from 'boot/axios'; import { defineStore } from 'pinia'; import { useSessionStore } from 'src/stores/session'; -import type { CourseSettingInfo } from 'src/common/models/settings'; -import { CourseSetting, CourseSettingOption } from 'src/common/models/settings'; -import { Dictionary } from 'src/common/models'; - -// This is the structure that settings come back from the server -type SettingValue = string | number | boolean | string[]; -interface SettingsObject { - general: Dictionary; - optional: Dictionary; - permission: Dictionary; - problem_set: Dictionary; - problem: Dictionary; - email: Dictionary; -} +import { CourseSetting, DBCourseSetting, GlobalSetting, ParseableDBCourseSetting, + ParseableGlobalSetting } from 'src/common/models/settings'; export interface SettingsState { - default_settings: Array; // this contains default setting and documentation - course_settings: Array; // this is the specific settings for the course + // These are the default setting and documentation + global_settings: Array; + // This are the specific settings for the course as from the database. + db_course_settings: Array; } export const useSettingsStore = defineStore('settings', { state: (): SettingsState => ({ - default_settings: [], - course_settings: [] + global_settings: [], + db_course_settings: [] }), + getters: { + /** + * This is an array of all settings in the course. If the setting has been changed + * from the default, that setting is used, if not, used the default/global setting. + */ + course_settings: (state): CourseSetting[] => state.global_settings.map(global_setting => { + const db_setting = state.db_course_settings + .find(setting => setting.setting_id === global_setting.setting_id); + return new CourseSetting(Object.assign(db_setting?.toObject() ?? {}, global_setting.toObject())); + }), + /** + * This returns the course setting by name. + */ + getCourseSetting: (state) => (setting_name: string): CourseSetting => { + const global_setting = state.global_settings.find(setting => setting.setting_name === setting_name); + if (global_setting) { + const db_course_setting = state.db_course_settings + .find(setting => setting.setting_id === global_setting?.setting_id); + return new CourseSetting(Object.assign( + db_course_setting?.toObject() ?? {}, + global_setting?.toObject())); + } else { + throw `The setting with name: '${setting_name}' does not exist.`; + } + }, + /** + * This returns the value of the setting in the course. If the setting has been + * changed from the default, that value is used, if not the default value is used. + */ + // Note: using standard function notation (not arrow) due to using this. + getSettingValue() { + return (setting_name: string) => { + const course_setting = this.getCourseSetting(setting_name); + return course_setting.value; + }; + }, + }, actions: { - async fetchDefaultSettings(): Promise { - const response = await api.get('default_settings'); - this.default_settings = response.data as Array; + async fetchGlobalSettings(): Promise { + const response = await api.get('global-settings'); + this.global_settings = (response.data as Array).map(setting => + new GlobalSetting(setting)); }, async fetchCourseSettings(course_id: number): Promise { const response = await api.get(`courses/${course_id}/settings`); - // switch boolean values to javascript true/false - const course_settings = response.data as CourseSetting[]; - course_settings.forEach((setting: CourseSetting) => { - const found_setting = this.default_settings.find( - (_setting: CourseSettingInfo) => _setting.var === setting.var - ); - if (found_setting && found_setting.type === CourseSettingOption.boolean) { - setting.value = setting.value === 1 ? true : false; - } - }); - this.course_settings = course_settings; - }, - getCourseSetting(var_name: string): CourseSetting { - const setting = this.course_settings.find((_setting: CourseSetting) => _setting.var === var_name); - return setting || new CourseSetting({}); + + this.db_course_settings = (response.data as ParseableDBCourseSetting[]).map(setting => + new DBCourseSetting(setting)); }, - async updateCourseSetting(params: { var: string; value: string | number | boolean | string[] }): - Promise { + async updateCourseSetting(course_setting: CourseSetting): Promise { const session = useSessionStore(); const course_id = session.course.course_id; - const setting = this.default_settings.find(s => s.var === params.var); - // Build the setting as a object for the API. - const setting_to_update: Dictionary> = {}; - const s: Dictionary = {}; - s[params.var] = params.value; - setting_to_update[setting?.category || ''] = s; - const response = await api.put(`/courses/${course_id}/setting`, setting_to_update); - const updated_settings = response.data as SettingsObject; - const setting_value = updated_settings[setting?.category as keyof SettingValue][params.var]; - const updated_setting = new CourseSetting({ var: params.var, value: setting_value }); + // Send only the database course setting fields. + const response = await api.put(`/courses/${course_id}/settings/${course_setting.setting_id}`, + course_setting.toObject(DBCourseSetting.ALL_FIELDS)); + const updated_setting = new DBCourseSetting(response.data as ParseableDBCourseSetting); + // update the store - const i = this.course_settings.findIndex(s => s.var === params.var); + const i = this.db_course_settings.findIndex(setting => setting.setting_id === updated_setting.setting_id); if (i >= 0) { - this.course_settings.splice(i, 1, updated_setting); + this.db_course_settings.splice(i, 1, updated_setting); + } else { + this.db_course_settings.push(updated_setting); + } + const global_setting = this.global_settings + .find(setting => setting.setting_id === updated_setting.setting_id); + + return new CourseSetting(Object.assign(updated_setting.toObject(), global_setting?.toObject())); + }, + /** + * Deletes the course setting from both the store and sends a delete request to the database. + */ + async deleteCourseSetting(course_setting: CourseSetting): Promise { + const session = useSessionStore(); + const course_id = session.course.course_id; + + const i = this.db_course_settings.findIndex(setting => setting.setting_id == course_setting.setting_id); + if (i < 0) { + throw `The setting with name: '${course_setting.setting_name}' has not been defined for this course.`; } - return updated_setting; + const response = await api.delete(`/courses/${course_id}/settings/${course_setting.setting_id}`); + this.db_course_settings.splice(i, 1); + const deleted_setting = new DBCourseSetting(response.data as ParseableDBCourseSetting); + return new CourseSetting(Object.assign(deleted_setting.toObject(), course_setting.toObject())); }, + /** + * Used to clear out all of the settings. Useful when logging out. + */ clearAll() { - this.course_settings = []; - this.default_settings = []; + this.global_settings = []; + this.db_course_settings = []; } } }); diff --git a/t/db/002_course_settings.t b/t/db/002_course_settings.t index 845ca077..74b82d36 100644 --- a/t/db/002_course_settings.t +++ b/t/db/002_course_settings.t @@ -15,15 +15,15 @@ use lib "$main::ww3_dir/lib"; use Test::More; use Test::Exception; +use Mojo::JSON qw/true false/; use YAML::XS qw/LoadFile/; use DB::Schema; -use WeBWorK3::Utils::Settings qw/getDefaultCourseSettings getDefaultCourseValues - validateSettingsConfFile validateSingleCourseSetting validateSettingConfig - isInteger isTimeString isTimeDuration isDecimal mergeCourseSettings/; +use WeBWorK3::Utils::Settings qw/isInteger isTimeString isTimeDuration isDecimal mergeCourseSettings + isValidSetting/; -use DB::TestUtils qw/removeIDs loadSchema/; +use DB::TestUtils qw/removeIDs loadCSV/; # Load the database my $config_file = "$main::ww3_dir/conf/ww3-dev.yml"; @@ -71,64 +71,45 @@ ok(isDecimal('00.33'), 'check type: decimal'); ok(!isDecimal("0-.33"), 'check type: not a decimal'); ok(!isDecimal('abc'), 'check type: not a decimal'); -# Check that the configuration file is valid. -is(validateSettingsConfFile(), 1, 'configuration file valid'); - -# TODO: Test to make sure that all of the checks for the course configurations work. - -my $default_course_settings = getDefaultCourseSettings(); - # Check that each of the given course_setting types are both valid and invalid. my $valid_setting = { - var => 'my_setting', - doc => 'this is a setting', - type => 'integer', - category => 'general', - default => 0 + setting_name => 'my_setting', + description => 'this is a setting', + type => 'int', + category => 'general', + default_value => 0 }; -is(validateSettingConfig($valid_setting), 1, 'course setting: valid setting'); - -# Check various parts of the setting. +ok(isValidSetting($valid_setting), 'course setting: valid setting'); +# Check that the setting hash has only valid fields throws_ok { - validateSettingConfig({ - var => 'mySetting', - doc => 'this is a setting', - type => 'integer', - category => 'general', - default => 0 - }) -} -'DB::Exception::InvalidCourseField', 'course setting: variable not in kebob case'; - -throws_ok { - validateSettingConfig({ - var => 'my_setting', - doc3 => 'this is a setting', - type => 'integer', - category => 'general', - default => 0 + isValidSetting({ + setting_name => 'my_setting', + doc3 => 'this is a setting', + type => 'int', + category => 'general', + default_value => 0 }) } 'DB::Exception::InvalidCourseField', 'course setting: course setting with illegal field'; throws_ok { - validateSettingConfig({ - var => 'my_setting', - type => 'integer', - category => 'general', - default => 0 + isValidSetting({ + setting_name => 'my_setting', + type => 'int', + category => 'general', + default_value => 0 }) } 'DB::Exception::InvalidCourseField', 'course setting: missing required field'; throws_ok { - validateSettingConfig({ - var => 'my_setting', - doc => 'this is a setting', - type => 'nonnegint', - category => 'general', - default => 0 + isValidSetting({ + setting_name => 'my_setting', + description => 'this is a setting', + type => 'nonnegint', + category => 'general', + default_value => 0 }) } 'DB::Exception::InvalidCourseFieldType', 'course setting: non valid course parameter type'; @@ -136,119 +117,207 @@ throws_ok { # Validate settings throws_ok { - validateSettingConfig({ - var => 'my_setting', - doc => 'this is a setting', - type => 'time', - category => 'general', - default => '12:343' + isValidSetting({ + setting_name => 'my_setting', + description => 'this is a setting', + type => 'time', + category => 'general', + default_value => '12:343' }) } 'DB::Exception::InvalidCourseFieldType', 'course setting: bad time string'; throws_ok { - validateSettingConfig({ - var => 'my_setting', - doc => 'this is a setting', - type => 'integer', - category => 'general', - default => '12.343' + isValidSetting({ + setting_name => 'my_setting', + description => 'this is a setting', + type => 'integer', + category => 'general', + default_value => '12.343' }) } 'DB::Exception::InvalidCourseFieldType', 'course setting: bad integer format'; throws_ok { - validateSettingConfig({ - var => 'my_setting', - doc => 'this is a setting', - type => 'time_duration', - category => 'general', - default => '-2 days' + isValidSetting({ + setting_name => 'my_setting', + description => 'this is a setting', + type => 'time_duration', + category => 'general', + default_value => '-2 days' }) } 'DB::Exception::InvalidCourseFieldType', 'course setting: bad time duration format'; throws_ok { - validateSettingConfig({ - var => 'my_setting', - doc => 'this is a setting', - type => 'decimal', - category => 'general', - default => '12:343' + isValidSetting({ + setting_name => 'my_setting', + description => 'this is a setting', + type => 'decimal', + category => 'general', + default_value => '12:343' }) } 'DB::Exception::InvalidCourseFieldType', 'course setting: bad decimal format'; my $course_rs = $schema->resultset('Course'); -# Check that the default settings are working +# Check that the default_value settings are the same as the values in the file + +my $global_settings = $course_rs->getGlobalSettings(); +for my $setting (@$global_settings) { + removeIDs($setting); + for my $key (qw/doc subcategory options/) { + delete $setting->{$key} unless $setting->{$key}; + } +} + +# Ensure that booleans in the YAML file are loaded correctly. +local $YAML::XS::Boolean = "JSON::PP"; +my $global_settings_from_file = LoadFile("$main::ww3_dir/conf/course_settings.yml"); + +is_deeply($global_settings, $global_settings_from_file, + 'default settings: db values are the same as the file values.'); + +# Make sure all of the default settings are valid +for my $setting (@$global_settings) { + ok(isValidSetting($setting), "check default setting: $setting->{setting_name} is valid"); +} # Make a new course with no settings and compare to the default settings my $new_course = $course_rs->addCourse(params => { course_name => 'New Course' }); -my $default_course_values = getDefaultCourseValues(); -my $new_course_info = { course_id => $new_course->{course_id} }; -my $course_settings = $course_rs->getCourseSettings(info => $new_course_info); +my $course_settings = $course_rs->getCourseSettings(info => { course_id => $new_course->{course_id} }); -is_deeply($course_settings, $default_course_values, 'course settings: default course_settings'); +# check that the course_settings is an array of length 0. +is_deeply($course_settings, [], 'course settings from a new course is just the defaults.'); -# Set a single course setting in General -my $updated_general_setting = { general => { course_description => 'This is my new course description' } }; -my $updated_course_settings = $course_rs->updateCourseSettings( - info => $new_course_info, - settings => $updated_general_setting -); -my $current_course_values = mergeCourseSettings($default_course_values, $updated_general_setting); +# Compare the course settings with the file. + +# Get a list of courses from the CSV file. +my @course_settings_from_csv = loadCSV("$main::ww3_dir/t/db/sample_data/course_settings.csv"); +my @arith_settings = grep { $_->{course_name} eq 'Arithmetic' } @course_settings_from_csv; +@arith_settings = map { { setting_name => $_->{setting_name}, value => $_->{setting_value} }; } @arith_settings; -is_deeply($current_course_values, $updated_course_settings, 'course_settings: updated general setting'); +my $arith_settings_from_db = $course_rs->getCourseSettings(info => { course_name => 'Arithmetic' }, merged => 1); +for my $setting (@$arith_settings_from_db) { + removeIDs($setting); +} -# Update another general setting -$updated_general_setting = { general => { hardcopy_theme => 'One Column' } }; +# Only compare the name/value of the settings +for my $setting (@$arith_settings_from_db) { + $setting = { setting_name => $setting->{setting_name}, value => $setting->{value} }; +} +is_deeply($arith_settings_from_db, \@arith_settings, 'getCourseSettings: compare settings for given course'); + +my $updated_setting = $course_rs->updateCourseSetting( + info => { + course_id => $new_course->{course_id}, + setting_name => 'course_description' + }, + params => { value => 'This is my new course description' } +); -$updated_course_settings = $course_rs->updateCourseSettings( - info => $new_course_info, - settings => $updated_general_setting +is('This is my new course description', $updated_setting->{value}, 'updateCourseSetting: successfully update a course setting'); + +my $fetched_setting = $course_rs->getCourseSetting( + info => { + course_id => $new_course->{course_id}, + setting_name => 'course_description' + } ); -$current_course_values = mergeCourseSettings($current_course_values, $updated_general_setting); - -is_deeply($current_course_values, $updated_course_settings, 'course_settings: updated another general setting'); - -# Set a single course setting in Optional Modules. -my $updated_optional_setting = { optional => { enable_show_me_another => 1 } }; -$updated_course_settings = - $course_rs->updateCourseSettings(info => $new_course_info, settings => $updated_optional_setting); -$current_course_values = mergeCourseSettings($current_course_values, $updated_optional_setting); -is_deeply($current_course_values, $updated_course_settings, 'course_settings: updated optional setting'); - -# Set a single course setting in problem_set. -my $updated_problem_set_setting = { problem_set => { time_assign_due => '11:52' } }; -$updated_course_settings = - $course_rs->updateCourseSettings(info => $new_course_info, settings => $updated_problem_set_setting); -$current_course_values = mergeCourseSettings($current_course_values, $updated_problem_set_setting); -is_deeply($current_course_values, $updated_course_settings, 'course_settings: updated problem set setting'); - -# Set a single course setting in problem. -my $updated_problem_setting = { problem => { display_mode => 'images' } }; -$updated_course_settings = - $course_rs->updateCourseSettings(info => $new_course_info, settings => $updated_problem_setting); -$current_course_values = mergeCourseSettings($current_course_values, $updated_problem_setting); -is_deeply($current_course_values, $updated_course_settings, 'course_settings: updated problem setting'); - -# Make sure that an nonexistant setting throws an exception. -my $undefined_problem_setting = { general => { non_existent_setting => 1 } }; +is($fetched_setting->{value}, $updated_setting->{value}, 'getCourseSetting: fetch a single course setting'); + +# Make sure invalid course settings throw exceptions. + +throws_ok { + $course_rs->updateCourseSetting( + info => { + course_id => $new_course->{course_id}, + setting_name => 'non_existant_setting' + }, + params => { value => 3 } + ); +} +'DB::Exception::SettingNotFound', 'updateCourseSetting: try to update a non-existant course setting.'; + +throws_ok { + $course_rs->updateCourseSetting( + info => { + course_id => $new_course->{course_id}, + setting_name => 'language' + }, + params => { value => 'Klingon'} + ); +} +'DB::Exception::InvalidCourseFieldType', 'updateCourseSetting: try to update the list setting.'; + +throws_ok { + $course_rs->updateCourseSetting( + info => { + course_id => $new_course->{course_id}, + setting_name => 'session_key_timeout' + }, + params => { value => '45 years' } + ); +} +'DB::Exception::InvalidCourseFieldType', 'updateCourseSetting: try to update a time_duration setting.'; + throws_ok { - $course_rs->updateCourseSettings(info => $new_course_info, settings => $undefined_problem_setting); + $course_rs->updateCourseSetting( + info => { + course_id => $new_course->{course_id}, + setting_name => 'enable_reduced_scoring' + }, + params => { value => 'true' } + ); } -'DB::Exception::UndefinedCourseField', 'course settings: undefined course_setting field'; +'DB::Exception::InvalidCourseFieldType', 'updateCourseSetting: try to update a boolean setting.'; -# Make sure that an invalid list option setting throws an exception. -my $invalid_list_option = { general => { hardcopy_theme => 'default' } }; -$course_rs->updateCourseSettings(info => $new_course_info, settings => $invalid_list_option); +throws_ok { + $course_rs->updateCourseSetting( + info => { + course_id => $new_course->{course_id}, + setting_name => 'show_me_another_default' + }, + params => { value => 'true' } + ); +} +'DB::Exception::InvalidCourseFieldType', 'updateCourseSetting: try to update an integer setting.'; + +throws_ok { + $course_rs->updateCourseSetting( + info => { + course_id => $new_course->{course_id}, + setting_name => 'display_mode_options' + }, + params => { value => [ '1', '2' ] } + ); +} +'DB::Exception::InvalidCourseFieldType', 'updateCourseSetting: try to update a multilist setting.'; -# TODO: Make sure that an invalid integer setting throws an exception +throws_ok { + $course_rs->updateCourseSetting( + info => { + course_id => $new_course->{course_id}, + setting_name => 'num_rel_percent_tol_default' + }, + params => { value => 'true' } + ); +} +'DB::Exception::InvalidCourseFieldType', 'updateCourseSetting: try to update a decimal setting.'; + +# Delete a course setting + +my $deleted_setting = $course_rs->deleteCourseSetting( + info => { + course_name => 'New Course', + setting_name => 'course_description' + } +); -# TODO: Make sure that an invalid email list setting throws an exception +is_deeply($deleted_setting, $updated_setting, 'deleteCourseSetting: delete a course setting.'); # Finally delete the course that was made $course_rs->deleteCourse(info => { course_id => $new_course->{course_id} }); diff --git a/t/db/build_db.pl b/t/db/build_db.pl index 06ea4450..5f4e79c1 100755 --- a/t/db/build_db.pl +++ b/t/db/build_db.pl @@ -29,6 +29,9 @@ BEGIN # Load the configuration for the database settings. my $config_file = "$main::ww3_dir/conf/ww3-dev.yml"; $config_file = "$main::ww3_dir/conf/ww3-dev.dist.yml" unless (-e $config_file); + +# the YAML true/false will be loaded a JSON booleans. +local $YAML::XS::Boolean = "JSON::PP"; my $config = LoadFile($config_file); # Connect to the database. @@ -44,13 +47,14 @@ BEGIN # Create the database based on the schema. $schema->deploy({ add_drop_table => 1 }); -my $course_rs = $schema->resultset('Course'); -my $user_rs = $schema->resultset('User'); -my $course_user_rs = $schema->resultset('CourseUser'); -my $problem_set_rs = $schema->resultset('ProblemSet'); -my $problem_pool_rs = $schema->resultset('ProblemPool'); -my $set_problem_rs = $schema->resultset('SetProblem'); -my $user_set_rs = $schema->resultset('UserSet'); +my $course_rs = $schema->resultset('Course'); +my $global_setting_rs = $schema->resultset('GlobalSetting'); +my $user_rs = $schema->resultset('User'); +my $course_user_rs = $schema->resultset('CourseUser'); +my $problem_set_rs = $schema->resultset('ProblemSet'); +my $problem_pool_rs = $schema->resultset('ProblemPool'); +my $set_problem_rs = $schema->resultset('SetProblem'); +my $user_set_rs = $schema->resultset('UserSet'); my $strp_date = DateTime::Format::Strptime->new(pattern => '%F', on_error => 'croak'); @@ -62,20 +66,40 @@ sub addCourses { boolean_fields => ['visible'] } ); - # currently course_params from the csv file are written to the course_settings database table. for my $course (@courses) { - $course->{course_settings} = {}; - for my $key (keys %{ $course->{course_params} }) { - my @fields = split(/:/, $key); - $course->{course_settings}->{ $fields[0] } = { $fields[1] => $course->{course_params}->{$key} }; - } - - delete $course->{course_params}; $course_rs->create($course); } return; } +sub addSettings { + say 'adding default settings' if $verbose; + my $settings_file = "$main::ww3_dir/conf/course_settings.yml"; + die "The default settings file: '$settings_file' does not exist or is not readable" + unless -r $settings_file; + my $course_settings = LoadFile($settings_file); + for my $setting (@$course_settings) { + # encode default_value as a JSON object. + $setting->{default_value} = { value => $setting->{default_value} }; + $global_setting_rs->create($setting); + } + + say 'adding course settings' if $verbose; + my @course_settings = loadCSV("$main::ww3_dir/t/db/sample_data/course_settings.csv"); + for my $setting (@course_settings) { + my $course = $course_rs->find({ course_name => $setting->{course_name} }); + die "the course: '$setting->{course_name}' does not exist in the db" unless $course; + my $global_setting = $global_setting_rs->find({ setting_name => $setting->{setting_name} }); + die "the setting: '$setting->{setting_name}' does not exist in the db" unless $global_setting; + $course->add_to_course_settings({ + course_id => $course->course_id, + setting_id => $global_setting->setting_id, + value => $setting->{setting_value} + }); + } + return; +} + sub addUsers { # Add some users say 'adding users' if $verbose; @@ -322,6 +346,7 @@ sub addUserProblems { } addCourses; +addSettings; addUsers; addSets; addProblems; diff --git a/t/db/sample_data/course_settings.csv b/t/db/sample_data/course_settings.csv new file mode 100644 index 00000000..27d888de --- /dev/null +++ b/t/db/sample_data/course_settings.csv @@ -0,0 +1,8 @@ +course_name,setting_name,setting_value +Precalculus,institution,"Springfield CC" +"Abstract Algebra",institution,"Springfield University" +Topology,institution,"Springfield University" +Arithmetic,institution,"Springfield CC" +Arithmetic,timezone,"America/New_York" +Arithmetic,hardcopy_theme,"One Column" +Calculus,institution,"Springfield University" diff --git a/t/db/sample_data/courses.csv b/t/db/sample_data/courses.csv index 17c0eb20..8375cb83 100644 --- a/t/db/sample_data/courses.csv +++ b/t/db/sample_data/courses.csv @@ -1,6 +1,6 @@ -course_name,visible,COURSE_PARAMS:general:institution,COURSE_DATES:start,COURSE_DATES:end -Precalculus,1,"Springfield CC",2021-01-01,2021-12-31 -"Abstract Algebra",1,"Springfield University",2021-01-01,2021-12-31 -"Topology",1,"Springfield University",2021-01-01,2021-12-31 -Arithmetic,1,"Springfield CC",2020-09-01,2020-12-16 -Calculus,1,"Springfield University",2020-09-01,2020-12-16 +course_name,visible,COURSE_DATES:start,COURSE_DATES:end +Precalculus,1,2021-01-01,2021-12-31 +"Abstract Algebra",1,2021-01-01,2021-12-31 +Topology,1,2021-01-01,2021-12-31 +Arithmetic,1,2020-09-01,2020-12-16 +Calculus,1,2020-09-01,2020-12-16 diff --git a/t/mojolicious/015_course_settings.t b/t/mojolicious/015_course_settings.t new file mode 100644 index 00000000..b14ce267 --- /dev/null +++ b/t/mojolicious/015_course_settings.t @@ -0,0 +1,110 @@ +#!/usr/bin/env perl + +# Testing the mojolicious routes that involve global and course settings. + +use Mojo::Base -strict; + +use Test::More; +use Test::Mojo; +use Mojo::JSON qw/true false/; + +BEGIN { + use File::Basename qw/dirname/; + use Cwd qw/abs_path/; + $main::ww3_dir = abs_path(dirname(__FILE__)) . '/../..'; +} + +use lib "$main::ww3_dir/lib"; + +use Clone qw/clone/; +use YAML::XS qw/LoadFile/; +use List::MoreUtils qw/firstval/; + +use DB::TestUtils qw/loadCSV removeIDs/; + +# Load the config file. +my $config_file = "$main::ww3_dir/conf/ww3-dev.yml"; +$config_file = "$main::ww3_dir/conf/ww3-dev.dist.yml" unless (-e $config_file); + +# the YAML true/false will be loaded a JSON booleans. +local $YAML::XS::Boolean = "JSON::PP"; +my $config = clone(LoadFile($config_file)); + +my $t = Test::Mojo->new(WeBWorK3 => $config); + +# Authenticate with the admin user. +$t->post_ok('/webwork3/api/login' => json => { username => 'admin', password => 'admin' })->status_is(200) + ->content_type_is('application/json;charset=UTF-8')->json_is('/logged_in' => 1)->json_is('/user/user_id' => 1) + ->json_is('/user/is_admin' => 1); + +# Load the global settings from the file +my $global_settings_from_file = LoadFile("$main::ww3_dir/conf/course_settings.yml"); + + +# Get the global/default settings + +$t->get_ok('/webwork3/api/global-settings')->content_type_is('application/json;charset=UTF-8')->status_is(200); +my $global_settings_from_db = $t->tx->res->json; + +# This is needed for later. +my $global_settings = clone($global_settings_from_db); + +# Do some cleanup. +for my $setting (@$global_settings_from_db) { + delete $setting->{setting_id}; + for my $key (qw/subcategory options doc/) { + delete $setting->{$key} unless $setting->{$key}; + } +} + +is_deeply($global_settings_from_db, $global_settings_from_file, 'test that the global settings are correct.'); + +# get a single global/default setting +$t->get_ok('/webwork3/api/global-setting/1')->content_type_is('application/json;charset=UTF-8')->status_is(200) + ->json_is('/setting_name' => $global_settings_from_file->[0]->{setting_name}) + ->json_is('/default_value' => $global_settings_from_file->[0]->{default_value}) + ->json_is('/description' => $global_settings_from_file->[0]->{description}); + +# Get all of the course settings for Arithmetic from the csv file: +my @course_settings = loadCSV("$main::ww3_dir/t/db/sample_data/course_settings.csv"); + +@course_settings = grep { $_->{course_name} eq 'Arithmetic' } @course_settings; + + +# pull out setting_name/value pairs +for my $setting (@course_settings) { + $setting = { + setting_name => $setting->{setting_name}, + value => $setting->{setting_value} + } +}; + +# Get all course settings for a course (Arithmetic- course_id: 4) + +$t->get_ok('/webwork3/api/courses/4/settings')->content_type_is('application/json;charset=UTF-8')->status_is(200); +my $course_settings_from_db = $t->tx->res->json; +# pull out setting_name/value pairs +for my $setting (@$course_settings_from_db) { + $setting = { + setting_name => $setting->{setting_name}, + value => $setting->{value} + } +}; + +is_deeply($course_settings_from_db, \@course_settings, 'Ensure that the course settings are correct.'); + +# Update a course setting (enable_reduced_scoring) + +my $reduced_scoring = firstval { $_->{setting_name} eq 'reduced_scoring_value' } @$global_settings; + +$t->put_ok("/webwork3/api/courses/4/settings/$reduced_scoring->{setting_id}" => json =>{ + value => 0.5 +})->content_type_is('application/json;charset=UTF-8')->status_is(200) +->json_is('/value' => 0.5); + +$t->delete_ok("/webwork3/api/courses/4/settings/$reduced_scoring->{setting_id}") + ->content_type_is('application/json;charset=UTF-8')->status_is(200) + ->json_is('/value' => 0.5); + + +done_testing; diff --git a/tests/stores/settings.spec.ts b/tests/stores/settings.spec.ts new file mode 100644 index 00000000..0104ebcc --- /dev/null +++ b/tests/stores/settings.spec.ts @@ -0,0 +1,140 @@ +/** + * @jest-environment jsdom + */ +// The above is needed because 1) the logger uses the window object, which is only present +// when using the jsdom environment and 2) because the pinia store is used is being +// tested with persistance. + +// settings.spec.ts +// Test the Settings Store + +import { createApp } from 'vue'; +import { createPinia, setActivePinia } from 'pinia'; +import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'; + +import fs from 'fs'; +import { parse } from 'yaml'; + +import { api } from 'boot/axios'; + +import { useSessionStore } from 'src/stores/session'; +import { useSettingsStore } from 'src/stores/settings'; +import { DBCourseSetting, ParseableDBCourseSetting, ParseableGlobalSetting, SettingValueType +} from 'src/common/models/settings'; + +import { cleanIDs, loadCSV } from '../utils'; + +describe('Test the settings store', () => { + + const app = createApp({}); + let default_settings: ParseableGlobalSetting[]; + let arith_settings: {setting_name: string; value: SettingValueType}[]; + beforeAll(async () => { + // Since we have the piniaPluginPersistedState as a plugin, duplicate for the test. + const pinia = createPinia().use(piniaPluginPersistedstate); + app.use(pinia); + + setActivePinia(pinia); + + // Load the default settings + const file = fs.readFileSync('conf/course_settings.yml', 'utf8'); + default_settings = parse(file) as ParseableGlobalSetting[]; + + // Fetch the course settings from the CSV file + const course_settings = await loadCSV('t/db/sample_data/course_settings.csv', {}); + arith_settings = course_settings.filter(setting => setting['course_name'] === 'Arithmetic') + .map(setting => ({ + setting_name: setting.setting_name as string, value: setting.setting_value as SettingValueType + })); + // Login to the course as the admin in order to be authenticated for the rest of the test. + await api.post('login', { username: 'admin', password: 'admin' }); + + const settings_store = useSettingsStore(); + await settings_store.fetchGlobalSettings(); + + }); + + describe('Check the global settings', () => { + + test('Check the default settings', () => { + const settings_store = useSettingsStore(); + expect(cleanIDs(settings_store.global_settings)).toStrictEqual(default_settings); + }); + + test('Make sure the settings are valid.', () => { + const settings_store = useSettingsStore(); + settings_store.global_settings.forEach(setting => { + expect(setting.isValid()).toBe(true); + }); + }); + + }); + + describe('Get all course settings and individual course settings', () => { + + test('Get the course settings for a course', async () => { + const settings_store = useSettingsStore(); + // The arithmetic course has course_id: 4 + await settings_store.fetchCourseSettings(4); + const arith_setting_ids = settings_store.db_course_settings.map(setting => setting.setting_id); + + const arith_settings_from_db = settings_store.course_settings + .filter(setting => arith_setting_ids.includes(setting.setting_id)) + .map(setting => ({ setting_name: setting.setting_name, value: setting.value })); + expect(arith_settings_from_db).toStrictEqual(arith_settings); + + // set the session course to this course + const session_store = useSessionStore(); + session_store.setCourse({ course_id: 4, course_name: 'Arithmetic' }); + }); + + test('Get a single course setting based on name', () => { + const settings_store = useSettingsStore(); + const timezone_setting = settings_store.getCourseSetting('timezone'); + const timezone_from_file = arith_settings.find(setting => setting.setting_name === 'timezone'); + expect(timezone_setting.value).toBe(timezone_from_file?.value); + }); + + test('Ensure that getting a non-existant setting throws an error', () => { + const settings_store = useSettingsStore(); + expect(() => { + settings_store.getCourseSetting('non_existant_setting'); + }).toThrowError('The setting with name: \'non_existant_setting\' does not exist.'); + }); + }); + + describe('Update a Course Setting', () => { + test('Update a setting', async () => { + const settings_store = useSettingsStore(); + const setting = settings_store.getCourseSetting('course_description'); + setting.value = 'this is a new description'; + const updated_setting = await settings_store.updateCourseSetting(setting); + expect(updated_setting.value).toBe(setting.value); + }); + + test('Make sure the updated settings are synched with the database', async () => { + const settings_store = useSettingsStore(); + const settings_in_store = settings_store.db_course_settings.map(setting => ({ + value: setting.value, + course_setting_id: setting.course_setting_id, + setting_id: setting.setting_id, + course_id: setting.course_id + })); + const response = await api.get('/courses/4/settings'); + const settings_from_db = (response.data as ParseableDBCourseSetting[]) + .map(setting => new DBCourseSetting(setting)); + expect(settings_in_store).toStrictEqual(settings_from_db.map(s => s.toObject())); + }); + }); + + describe('Deleting a Course Setting', () => { + test('Update a setting', async () => { + const settings_store = useSettingsStore(); + const setting = settings_store.getCourseSetting('course_description'); + const deleted_setting = await settings_store.deleteCourseSetting(setting); + expect(deleted_setting.value).toBe('this is a new description'); + + }); + }); + +}); diff --git a/tests/unit-tests/parsing.spec.ts b/tests/unit-tests/parsing.spec.ts index 1245dc24..f17e6771 100644 --- a/tests/unit-tests/parsing.spec.ts +++ b/tests/unit-tests/parsing.spec.ts @@ -2,72 +2,111 @@ import { parseNonNegInt, parseBoolean, parseEmail, parseUsername, EmailParseException, NonNegIntException, BooleanParseException, UsernameParseException, - parseUserRole, parseNonNegDecimal, NonNegDecimalException } from 'src/common/models/parsers'; - -test('parsing nonnegative integers', () => { - expect(parseNonNegInt(1)).toBe(1); - expect(parseNonNegInt('1')).toBe(1); - expect(parseNonNegInt(0)).toBe(0); - expect(parseNonNegInt('0')).toBe(0); - expect(() => {parseNonNegInt(-1);}).toThrow(NonNegIntException); - expect(() => {parseNonNegInt('-1');}).toThrow(NonNegIntException); -}); + parseUserRole, parseNonNegDecimal, NonNegDecimalException, isTime, isTimeDuration +} from 'src/common/models/parsers'; -test('parsing nonnegative decimals', () => { - expect(parseNonNegDecimal(1.5)).toBe(1.5); - expect(parseNonNegDecimal(0.5)).toBe(0.5); - expect(parseNonNegDecimal(.5)).toBe(.5); - expect(parseNonNegDecimal(2)).toBe(2); - expect(parseNonNegDecimal('1.5')).toBe(1.5); - expect(parseNonNegDecimal('0.5')).toBe(0.5); - expect(parseNonNegDecimal('.5')).toBe(.5); - expect(parseNonNegDecimal('2')).toBe(2); - - expect(() => {parseNonNegDecimal(-1);}).toThrow(NonNegDecimalException); - expect(() => {parseNonNegDecimal(-0.5);}).toThrow(NonNegDecimalException); - expect(() => {parseNonNegDecimal(-.5);}).toThrow(NonNegDecimalException); -}); +describe('Testing Parsers and Regular Expressions', () => { -test('parsing booleans', () => { - expect(parseBoolean(true)).toBe(true); - expect(parseBoolean(false)).toBe(false); - expect(parseBoolean('true')).toBe(true); - expect(parseBoolean('false')).toBe(false); - expect(parseBoolean(1)).toBe(true); - expect(parseBoolean(0)).toBe(false); - expect(parseBoolean('1')).toBe(true); - expect(parseBoolean('0')).toBe(false); - expect(() => {parseBoolean('T');}).toThrow(BooleanParseException); - expect(() => {parseBoolean('F');}).toThrow(BooleanParseException); - expect(() => {parseBoolean('-1');}).toThrow(BooleanParseException); - expect(() => {parseBoolean(-1);}).toThrow(BooleanParseException); -}); + test('parsing nonnegative integers', () => { + expect(parseNonNegInt(1)).toBe(1); + expect(parseNonNegInt('1')).toBe(1); + expect(parseNonNegInt(0)).toBe(0); + expect(parseNonNegInt('0')).toBe(0); + expect(() => {parseNonNegInt(-1);}).toThrow(NonNegIntException); + expect(() => {parseNonNegInt('-1');}).toThrow(NonNegIntException); + }); -test('parsing emails', () => { - expect(parseEmail('user@site.com')).toBe('user@site.com'); - expect(parseEmail('first.last@site.com')).toBe('first.last@site.com'); - expect(parseEmail('user1234@site.com')).toBe('user1234@site.com'); - expect(parseEmail('first.last@sub.site.com')).toBe('first.last@sub.site.com'); - expect(() => {parseEmail('first last@site.com');}).toThrow(EmailParseException); -}); + test('parsing nonnegative decimals', () => { + expect(parseNonNegDecimal(1.5)).toBe(1.5); + expect(parseNonNegDecimal(0.5)).toBe(0.5); + expect(parseNonNegDecimal(.5)).toBe(.5); + expect(parseNonNegDecimal(2)).toBe(2); + expect(parseNonNegDecimal('1.5')).toBe(1.5); + expect(parseNonNegDecimal('0.5')).toBe(0.5); + expect(parseNonNegDecimal('.5')).toBe(.5); + expect(parseNonNegDecimal('2')).toBe(2); -test('parsing usernames', () => { - expect(parseUsername('login')).toBe('login'); - expect(parseUsername('login123')).toBe('login123'); - expect(() => {parseUsername('@login');}).toThrow(UsernameParseException); - expect(() => {parseUsername('1234login');}).toThrow(UsernameParseException); + expect(() => {parseNonNegDecimal(-1);}).toThrow(NonNegDecimalException); + expect(() => {parseNonNegDecimal(-0.5);}).toThrow(NonNegDecimalException); + expect(() => {parseNonNegDecimal(-.5);}).toThrow(NonNegDecimalException); + }); - expect(parseUsername('user@site.com')).toBe('user@site.com'); - expect(parseUsername('first.last@site.com')).toBe('first.last@site.com'); - expect(parseUsername('user1234@site.com')).toBe('user1234@site.com'); - expect(parseUsername('first.last@sub.site.com')).toBe('first.last@sub.site.com'); - expect(() => {parseUsername('first last@site.com');}).toThrow(UsernameParseException); + test('parsing booleans', () => { + expect(parseBoolean(true)).toBe(true); + expect(parseBoolean(false)).toBe(false); + expect(parseBoolean('true')).toBe(true); + expect(parseBoolean('false')).toBe(false); + expect(parseBoolean(1)).toBe(true); + expect(parseBoolean(0)).toBe(false); + expect(parseBoolean('1')).toBe(true); + expect(parseBoolean('0')).toBe(false); + expect(() => {parseBoolean('T');}).toThrow(BooleanParseException); + expect(() => {parseBoolean('F');}).toThrow(BooleanParseException); + expect(() => {parseBoolean('-1');}).toThrow(BooleanParseException); + expect(() => {parseBoolean(-1);}).toThrow(BooleanParseException); + }); -}); + test('parsing emails', () => { + expect(parseEmail('user@site.com')).toBe('user@site.com'); + expect(parseEmail('first.last@site.com')).toBe('first.last@site.com'); + expect(parseEmail('user1234@site.com')).toBe('user1234@site.com'); + expect(parseEmail('first.last@sub.site.com')).toBe('first.last@sub.site.com'); + expect(() => {parseEmail('first last@site.com');}).toThrow(EmailParseException); + }); + + test('parsing usernames', () => { + expect(parseUsername('login')).toBe('login'); + expect(parseUsername('login123')).toBe('login123'); + expect(() => {parseUsername('@login');}).toThrow(UsernameParseException); + expect(() => {parseUsername('1234login');}).toThrow(UsernameParseException); + + expect(parseUsername('user@site.com')).toBe('user@site.com'); + expect(parseUsername('first.last@site.com')).toBe('first.last@site.com'); + expect(parseUsername('user1234@site.com')).toBe('user1234@site.com'); + expect(parseUsername('first.last@sub.site.com')).toBe('first.last@sub.site.com'); + expect(() => {parseUsername('first last@site.com');}).toThrow(UsernameParseException); + + }); + + test('parsing user roles', () => { + expect(parseUserRole('instructor')).toBe('INSTRUCTOR'); + expect(parseUserRole('TA')).toBe('TA'); + expect(parseUserRole('student')).toBe('STUDENT'); + expect(parseUserRole('not_existent')).toBe('UNKNOWN'); + }); + + test('testing time regular expressions.', () => { + expect(isTime('00:00')).toBe(true); + expect(isTime('01:00')).toBe(true); + expect(isTime('23:59')).toBe(true); + expect(isTime('24:00')).toBe(false); + expect(isTime('11:65')).toBe(false); + }); + + test('testing time interval regular expressions.', () => { + expect(isTimeDuration('10 sec')).toBe(true); + expect(isTimeDuration('10 secs')).toBe(true); + + expect(isTimeDuration('10 second')).toBe(true); + expect(isTimeDuration('10 seconds')).toBe(true); + + expect(isTimeDuration('10 mins')).toBe(true); + expect(isTimeDuration('10 min')).toBe(true); + + expect(isTimeDuration('10 minute')).toBe(true); + expect(isTimeDuration('10 minutes')).toBe(true); + + expect(isTimeDuration('10 hour')).toBe(true); + expect(isTimeDuration('10 hours')).toBe(true); + + expect(isTimeDuration('10 hr')).toBe(true); + expect(isTimeDuration('10 hrs')).toBe(true); + + expect(isTimeDuration('10 day')).toBe(true); + expect(isTimeDuration('10 days')).toBe(true); + + expect(isTimeDuration('10 week')).toBe(true); + expect(isTimeDuration('10 weeks')).toBe(true); + }); -test('parsing user roles', () => { - expect(parseUserRole('instructor')).toBe('INSTRUCTOR'); - expect(parseUserRole('TA')).toBe('TA'); - expect(parseUserRole('student')).toBe('STUDENT'); - expect(parseUserRole('not_existent')).toBe('UNKNOWN'); }); diff --git a/tests/unit-tests/settings.spec.ts b/tests/unit-tests/settings.spec.ts new file mode 100644 index 00000000..52604603 --- /dev/null +++ b/tests/unit-tests/settings.spec.ts @@ -0,0 +1,827 @@ +// tests parsing and handling of users + +import { CourseSetting, DBCourseSetting, GlobalSetting, SettingType +} from 'src/common/models/settings'; + +describe('Testing Course Settings', () => { + const global_setting = { + setting_id: 0, + setting_name: '', + default_value: '', + category: '', + description: '', + type: SettingType.unknown + }; + + describe('Create a new GlobalSetting', () => { + test('Create a default GlobalSetting', () => { + const setting = new GlobalSetting(); + + expect(setting).toBeInstanceOf(GlobalSetting); + expect(setting.toObject()).toStrictEqual(global_setting); + }); + + test('Create a new GlobalSetting', () => { + const global_setting = new GlobalSetting({ + setting_id: 10, + setting_name: 'description', + default_value: 'This is the description', + description: 'Describe this.', + doc: 'Extended help', + type: 'text', + category: 'general' + }); + + expect(global_setting.setting_id).toBe(10); + expect(global_setting.setting_name).toBe('description'); + expect(global_setting.default_value).toBe('This is the description'); + expect(global_setting.description).toBe('Describe this.'); + expect(global_setting.doc).toBe('Extended help'); + expect(global_setting.type).toBe(SettingType.text); + expect(global_setting.category).toBe('general'); + }); + + test('Check that calling all_fields() and params() is correct', () => { + const settings_fields = ['setting_id', 'setting_name', 'default_value', 'category', 'subcategory', + 'description', 'doc', 'type', 'options']; + const setting = new GlobalSetting(); + + expect(setting.all_field_names.sort()).toStrictEqual(settings_fields.sort()); + expect(setting.param_fields.sort()).toStrictEqual([]); + expect(GlobalSetting.ALL_FIELDS.sort()).toStrictEqual(settings_fields.sort()); + }); + + test('Check that cloning works', () => { + const setting = new GlobalSetting(); + expect(setting.clone().toObject()).toStrictEqual(global_setting); + expect(setting.clone()).toBeInstanceOf(GlobalSetting); + }); + + }); + + describe('Updating global settings', () => { + test('set fields of a global setting directly', () => { + const global_setting = new GlobalSetting(); + + global_setting.setting_id = 10; + expect(global_setting.setting_id).toBe(10); + + global_setting.setting_name = 'description'; + expect(global_setting.setting_name).toBe('description'); + + global_setting.category = 'general'; + expect(global_setting.category).toBe('general'); + + global_setting.subcategory = 'problems'; + expect(global_setting.subcategory).toBe('problems'); + + global_setting.default_value = 6; + expect(global_setting.default_value).toBe(6); + + global_setting.description = 'This is the help.'; + expect(global_setting.description).toBe('This is the help.'); + + global_setting.description = 'This is the extended help.'; + expect(global_setting.description).toBe('This is the extended help.'); + + global_setting.type = 'int'; + expect(global_setting.type).toBe(SettingType.int); + + global_setting.type = 'undefined type'; + expect(global_setting.type).toBe(SettingType.unknown); + + }); + + test('set fields of a course setting using the set method', () => { + const global_setting = new GlobalSetting(); + + global_setting.set({ setting_id: 25 }); + expect(global_setting.setting_id).toBe(25); + + global_setting.set({ setting_name: 'description' }); + expect(global_setting.setting_name).toBe('description'); + + global_setting.set({ category: 'general' }); + expect(global_setting.category).toBe('general'); + + global_setting.set({ subcategory: 'problems' }); + expect(global_setting.subcategory).toBe('problems'); + + global_setting.set({ default_value: 6 }); + expect(global_setting.default_value).toBe(6); + + global_setting.set({ description: 'This is the help.' }); + expect(global_setting.description).toBe('This is the help.'); + + global_setting.set({ doc: 'This is the extended help.' }); + expect(global_setting.doc).toBe('This is the extended help.'); + + global_setting.set({ type: 'int' }); + expect(global_setting.type).toBe(SettingType.int); + + global_setting.set({ type: 'undefined type' }); + expect(global_setting.type).toBe(SettingType.unknown); + + }); + + }); + + describe('Test the validity of settings', () => { + test('test the validity of settings.', () => { + const global_setting = new GlobalSetting(); + expect(global_setting.isValid()).toBe(false); + + global_setting.set({ + setting_name: 'description', + default_value: 'This is the description', + description: 'Describe this.', + doc: 'Extended help', + type: 'text', + category: 'general' + }); + expect(global_setting.isValid()).toBe(true); + + global_setting.type = 'unknown_type'; + expect(global_setting.isValid()).toBe(false); + + global_setting.set({ type: 'list', description: '' }); + expect(global_setting.isValid()).toBe(false); + + global_setting.set({ description: 'This is the help.', setting_name: '' }); + expect(global_setting.isValid()).toBe(false); + + global_setting.set({ setting_name: 'description', category: '' }); + expect(global_setting.isValid()).toBe(false); + + global_setting.set({ category: 'general', doc: '', type: 'text' }); + expect(global_setting.isValid()).toBe(true); + + }); + + test('test the validity of global settings for default_value type text', () => { + const global_setting = new GlobalSetting(); + expect(global_setting.isValid()).toBe(false); + + global_setting.set({ + setting_name: 'description', + default_value: 'This is the description', + description: 'Describe this.', + doc: 'Extended help', + type: 'text', + category: 'general' + }); + + expect(global_setting.isValid()).toBe(true); + + global_setting.set({ default_value: 3.14 }); + expect(global_setting.isValid()).toBe(false); + + global_setting.set({ default_value: true }); + expect(global_setting.isValid()).toBe(false); + + global_setting.set({ default_value: ['1', '2', '3'] }); + expect(global_setting.isValid()).toBe(false); + }); + + test('test the validity of global settings for default_value type int', () => { + const global_setting = new GlobalSetting(); + expect(global_setting.isValid()).toBe(false); + + global_setting.set({ + setting_name: 'number_1', + default_value: 10, + description: 'I am an integer', + doc: 'Extended help', + type: 'int', + category: 'general' + }); + + expect(global_setting.isValid()).toBe(true); + + global_setting.set({ default_value: 3.14 }); + expect(global_setting.isValid()).toBe(false); + + global_setting.set({ default_value: 'hi' }); + expect(global_setting.isValid()).toBe(false); + + global_setting.set({ default_value: ['1', '2', '3'] }); + expect(global_setting.isValid()).toBe(false); + }); + + test('test the validity of global settings for default_value type decimal', () => { + const global_setting = new GlobalSetting(); + expect(global_setting.isValid()).toBe(false); + + global_setting.set({ + setting_name: 'number_1', + default_value: 3.14, + description: 'I am a decimal', + doc: 'Extended help', + type: 'decimal', + category: 'general' + }); + + expect(global_setting.isValid()).toBe(true); + + global_setting.set({ default_value: 3 }); + expect(global_setting.isValid()).toBe(true); + + global_setting.set({ default_value: 'hi' }); + expect(global_setting.isValid()).toBe(false); + + global_setting.set({ default_value: ['1', '2', '3'] }); + expect(global_setting.isValid()).toBe(false); + + }); + + test('test the validity of global settings for default_value type list', () => { + const global_setting = new GlobalSetting(); + expect(global_setting.isValid()).toBe(false); + + global_setting.set({ + setting_name: 'the list', + default_value: '1', + description: 'I am a list', + doc: 'Extended help', + type: 'list', + category: 'general' + }); + + // The options are missing + expect(global_setting.isValid()).toBe(false); + + global_setting.set({ options: ['1', '2', '3'] }); + expect(global_setting.isValid()).toBe(true); + + global_setting.set({ default_value: 3.14 }); + expect(global_setting.isValid()).toBe(false); + + global_setting.set({ default_value: 'hi' }); + expect(global_setting.isValid()).toBe(false); + + global_setting.set({ default_value: ['1', '2', '3'] }); + expect(global_setting.isValid()).toBe(false); + + // Test the options with label/values + global_setting.set({ options: [ + { label: 'label1', value: '1' }, + { label: 'label2', value: '2' }, + { label: 'label3', value: '3' }, + ], default_value: '2' }); + expect(global_setting.isValid()).toBe(true); + }); + + test('test the validity of global settings for default_value type multilist', () => { + const global_setting = new GlobalSetting(); + expect(global_setting.isValid()).toBe(false); + + global_setting.set({ + setting_name: 'my_multilist', + default_value: ['1', '2'], + description: 'I am a multilist', + doc: 'Extended help', + type: 'multilist', + category: 'general' + }); + + // The options is missing, so not valid. + expect(global_setting.isValid()).toBe(false); + + global_setting.set({ options: ['1', '2', '3'] }); + expect(global_setting.isValid()).toBe(true); + + global_setting.set({ default_value: 3.14 }); + expect(global_setting.isValid()).toBe(false); + + global_setting.set({ default_value: 'hi' }); + expect(global_setting.isValid()).toBe(false); + + global_setting.set({ default_value: ['1', '2', '4'] }); + expect(global_setting.isValid()).toBe(false); + + // Test the options in the form label/value + global_setting.set({ + options: [ + { label: 'option 1', value: '1' }, + { label: 'option 2', value: '2' }, + { label: 'option 3', value: '3' }, + ], + default_value: ['1', '3'] + }); + expect(global_setting.isValid()).toBe(true); + + }); + + test('test the validity of global settings for default_value type boolean', () => { + const global_setting = new GlobalSetting(); + expect(global_setting.isValid()).toBe(false); + + global_setting.set({ + setting_name: 'a_boolean', + default_value: true, + description: 'I am true or false', + doc: 'Extended help', + type: 'boolean', + category: 'general' + }); + + expect(global_setting.isValid()).toBe(true); + + global_setting.set({ default_value: 3.14 }); + expect(global_setting.isValid()).toBe(false); + + global_setting.set({ default_value: 3 }); + expect(global_setting.isValid()).toBe(false); + + global_setting.set({ default_value: ['1', '2', '3'] }); + expect(global_setting.isValid()).toBe(false); + }); + + test('test the validity of global settings for default_value type boolean', () => { + const global_setting = new GlobalSetting(); + expect(global_setting.isValid()).toBe(false); + + global_setting.set({ + setting_name: 'time_due', + default_value: '23:59', + description: 'The time that is due', + doc: 'Extended help', + type: 'time', + category: 'general' + }); + + expect(global_setting.isValid()).toBe(true); + + global_setting.set({ default_value: 3.14 }); + expect(global_setting.isValid()).toBe(false); + + global_setting.set({ default_value: '31:45' }); + expect(global_setting.isValid()).toBe(false); + + global_setting.set({ default_value: '13:65' }); + expect(global_setting.isValid()).toBe(false); + + global_setting.set({ default_value: ['23:45'] }); + expect(global_setting.isValid()).toBe(false); + }); + + }); + + const default_db_setting = { + course_setting_id: 0, + course_id: 0, + setting_id: 0 + }; + + describe('Create a new DBCourseSetting', () => { + test('Create a default DBCourseSetting', () => { + const setting = new DBCourseSetting(); + + expect(setting).toBeInstanceOf(DBCourseSetting); + expect(setting.toObject()).toStrictEqual(default_db_setting); + }); + + test('Create a new GlobalSetting', () => { + const course_setting = new DBCourseSetting({ + course_setting_id: 10, + course_id: 34, + setting_id: 199, + value: 'xyz' + }); + + expect(course_setting.course_setting_id).toBe(10); + expect(course_setting.course_id).toBe(34); + expect(course_setting.setting_id).toBe(199); + expect(course_setting.value).toBe('xyz'); + }); + + test('Check that calling all_fields() and params() is correct', () => { + const settings_fields = ['course_setting_id', 'setting_id', 'course_id', 'value']; + const setting = new DBCourseSetting(); + + expect(setting.all_field_names.sort()).toStrictEqual(settings_fields.sort()); + expect(setting.param_fields.sort()).toStrictEqual([]); + expect(DBCourseSetting.ALL_FIELDS.sort()).toStrictEqual(settings_fields.sort()); + }); + + test('Check that cloning works', () => { + const setting = new DBCourseSetting(); + expect(setting.clone().toObject()).toStrictEqual(default_db_setting); + expect(setting.clone()).toBeInstanceOf(DBCourseSetting); + }); + + }); + + describe('Updating db course settings', () => { + test('set fields of a db course setting directly', () => { + const course_setting = new DBCourseSetting(); + course_setting.course_setting_id = 10; + expect(course_setting.course_setting_id).toBe(10); + + course_setting.setting_id = 25; + expect(course_setting.setting_id).toBe(25); + + course_setting.course_id = 15; + expect(course_setting.course_id).toBe(15); + + course_setting.value = 6; + expect(course_setting.value).toBe(6); + }); + + test('set fields of a course setting using the set method', () => { + const course_setting = new DBCourseSetting(); + + course_setting.set({ course_setting_id: 10 }); + expect(course_setting.course_setting_id).toBe(10); + + course_setting.set({ setting_id: 25 }); + expect(course_setting.setting_id).toBe(25); + + course_setting.set({ course_id: 15 }); + expect(course_setting.course_id).toBe(15); + + course_setting.set({ value: 6 }); + expect(course_setting.value).toBe(6); + }); + }); + + const default_course_setting = { + setting_id: 0, + course_id: 0, + course_setting_id: 0, + setting_name: '', + default_value: '', + category: '', + description: '', + value: '', + type: SettingType.unknown + }; + + describe('Create a new CourseSetting', () => { + test('Create a default CourseSetting', () => { + const setting = new CourseSetting(); + + expect(setting).toBeInstanceOf(CourseSetting); + expect(setting.toObject()).toStrictEqual(default_course_setting); + }); + + test('Create a new CourseSetting', () => { + const course_setting = new CourseSetting({ + setting_id: 10, + course_id: 5, + course_setting_id: 17, + value: 'this is my value', + setting_name: 'description', + default_value: 'This is the description', + description: 'Describe this.', + doc: 'Extended help', + type: 'text', + category: 'general' + }); + + expect(course_setting.setting_id).toBe(10); + expect(course_setting.course_id).toBe(5); + expect(course_setting.course_setting_id).toBe(17); + expect(course_setting.value).toBe('this is my value'); + expect(course_setting.setting_name).toBe('description'); + expect(course_setting.default_value).toBe('This is the description'); + expect(course_setting.description).toBe('Describe this.'); + expect(course_setting.doc).toBe('Extended help'); + expect(course_setting.type).toBe(SettingType.text); + expect(course_setting.category).toBe('general'); + }); + + test('Check that calling all_fields() and params() is correct', () => { + const settings_fields = ['setting_id', 'course_setting_id', 'course_id', 'value', 'setting_name', + 'default_value', 'category', 'subcategory', 'description', 'doc', 'type', 'options']; + const setting = new CourseSetting(); + + expect(setting.all_field_names.sort()).toStrictEqual(settings_fields.sort()); + expect(setting.param_fields.sort()).toStrictEqual([]); + expect(CourseSetting.ALL_FIELDS.sort()).toStrictEqual(settings_fields.sort()); + }); + + test('Check that cloning works', () => { + const setting = new CourseSetting(); + expect(setting.clone().toObject()).toStrictEqual(default_course_setting); + expect(setting.clone()).toBeInstanceOf(CourseSetting); + }); + + }); + + describe('Updating course settings', () => { + test('set fields of a course setting directly', () => { + const course_setting = new CourseSetting(); + + course_setting.setting_id = 25; + expect(course_setting.setting_id).toBe(25); + + course_setting.course_id = 15; + expect(course_setting.course_id).toBe(15); + + course_setting.value = 6; + expect(course_setting.value).toBe(6); + + course_setting.setting_id = 10; + expect(course_setting.setting_id).toBe(10); + + course_setting.setting_name = 'description'; + expect(course_setting.setting_name).toBe('description'); + + course_setting.category = 'general'; + expect(course_setting.category).toBe('general'); + + course_setting.subcategory = 'problems'; + expect(course_setting.subcategory).toBe('problems'); + + course_setting.default_value = 6; + expect(course_setting.default_value).toBe(6); + + course_setting.description = 'This is the help.'; + expect(course_setting.description).toBe('This is the help.'); + + course_setting.doc = 'This is the extended help.'; + expect(course_setting.doc).toBe('This is the extended help.'); + + course_setting.type = 'int'; + expect(course_setting.type).toBe(SettingType.int); + + course_setting.type = 'undefined type'; + expect(course_setting.type).toBe(SettingType.unknown); + + }); + + test('set fields of a course setting using the set method', () => { + const course_setting = new CourseSetting(); + + course_setting.set({ course_setting_id: 10 }); + expect(course_setting.course_setting_id).toBe(10); + + course_setting.set({ setting_id: 25 }); + expect(course_setting.setting_id).toBe(25); + + course_setting.set({ course_id: 15 }); + expect(course_setting.course_id).toBe(15); + + course_setting.set({ value: 6 }); + expect(course_setting.value).toBe(6); + + course_setting.set({ setting_id: 25 }); + expect(course_setting.setting_id).toBe(25); + + course_setting.set({ setting_name: 'description' }); + expect(course_setting.setting_name).toBe('description'); + + course_setting.set({ category: 'general' }); + expect(course_setting.category).toBe('general'); + + course_setting.set({ subcategory: 'problems' }); + expect(course_setting.subcategory).toBe('problems'); + + course_setting.set({ default_value: 6 }); + expect(course_setting.default_value).toBe(6); + + course_setting.set({ description: 'This is the help.' }); + expect(course_setting.description).toBe('This is the help.'); + + course_setting.set({ doc: 'This is the extended help.' }); + expect(course_setting.doc).toBe('This is the extended help.'); + + course_setting.set({ type: 'int' }); + expect(course_setting.type).toBe(SettingType.int); + + course_setting.set({ type: 'undefined type' }); + expect(course_setting.type).toBe(SettingType.unknown); + + }); + + }); + + describe('Test to determine that course settings overrides are working', () => { + test('Test to determine that course settings overrides are working', () => { + // If the Course Setting value is defined, then the value should be that. + // If instead the value is undefined, use the default_value. + + const course_setting = new CourseSetting({ + setting_id: 10, + course_id: 5, + course_setting_id: 17, + setting_name: 'description', + default_value: 'This is the default value', + description: 'Describe this.', + doc: 'Extended help', + type: 'text', + category: 'general' + }); + + expect(course_setting.value).toBe('This is the default value'); + + course_setting.value = 'This is the value.'; + expect(course_setting.value).toBe('This is the value.'); + }); + }); + + describe('Test the validity of course settings', () => { + test('test the basic validity of course settings.', () => { + const course_setting = new CourseSetting(); + expect(course_setting.isValid()).toBe(false); + + course_setting.set({ + setting_name: 'description', + default_value: 'This is the description', + description: 'Describe this.', + doc: 'Extended help', + type: 'text', + category: 'general', + value: 'my value' + }); + expect(course_setting.isValid()).toBe(true); + + course_setting.type = 'unknown_type'; + expect(course_setting.isValid()).toBe(false); + + course_setting.set({ type: 'text', description: '' }); + expect(course_setting.isValid()).toBe(false); + + course_setting.set({ description: 'This is the help.', setting_name: '' }); + expect(course_setting.isValid()).toBe(false); + + course_setting.set({ setting_name: 'description', category: '' }); + expect(course_setting.isValid()).toBe(false); + + course_setting.set({ category: 'general', doc: '' }); + expect(course_setting.isValid()).toBe(true); + + }); + + test('test the validity of course settings for default_value type int', () => { + const course_setting = new CourseSetting(); + expect(course_setting.isValid()).toBe(false); + + course_setting.set({ + setting_name: 'number_1', + default_value: 10, + description: 'I am an integer', + doc: 'Extended help', + type: 'int', + category: 'general' + }); + + expect(course_setting.isValid()).toBe(true); + + course_setting.set({ value: 3.14 }); + expect(course_setting.isValid()).toBe(false); + + course_setting.set({ value: 'hi' }); + expect(course_setting.isValid()).toBe(false); + + course_setting.set({ value: ['1', '2', '3'] }); + expect(course_setting.isValid()).toBe(false); + }); + + test('test the validity of course settings for default_value type decimal', () => { + const course_setting = new CourseSetting(); + expect(course_setting.isValid()).toBe(false); + + course_setting.set({ + setting_name: 'number_1', + default_value: 3.14, + description: 'I am a decimal', + doc: 'Extended help', + type: 'decimal', + category: 'general' + }); + + expect(course_setting.isValid()).toBe(true); + + course_setting.set({ value: 3 }); + expect(course_setting.isValid()).toBe(true); + + course_setting.set({ value: 'hi' }); + expect(course_setting.isValid()).toBe(false); + + course_setting.set({ value: ['1', '2', '3'] }); + expect(course_setting.isValid()).toBe(false); + }); + + test('test the validity of course settings for default_value type list', () => { + const course_setting = new CourseSetting(); + expect(course_setting.isValid()).toBe(false); + + course_setting.set({ + setting_name: 'the list', + default_value: '1', + description: 'I am a list', + doc: 'Extended help', + type: 'list', + category: 'general' + }); + + // The options are missing + expect(course_setting.isValid()).toBe(false); + + course_setting.set({ options: ['1', '2', '3'] }); + expect(course_setting.isValid()).toBe(true); + + course_setting.set({ value: 3.14 }); + expect(course_setting.isValid()).toBe(false); + + course_setting.set({ value: 'hi' }); + expect(course_setting.isValid()).toBe(false); + + course_setting.set({ value: ['1', '2', '3'] }); + expect(course_setting.isValid()).toBe(false); + }); + + test('test the validity of course settings for default_value type multilist', () => { + const course_setting = new CourseSetting(); + expect(course_setting.isValid()).toBe(false); + + course_setting.set({ + setting_name: 'my_multilist', + default_value: ['1', '2'], + description: 'I am a multilist', + doc: 'Extended help', + type: 'multilist', + category: 'general' + }); + + // The options is missing, so not valid. + expect(course_setting.isValid()).toBe(false); + + course_setting.set({ options: ['1', '2', '3'] }); + expect(course_setting.isValid()).toBe(true); + + course_setting.set({ value: 3.14 }); + expect(course_setting.isValid()).toBe(false); + + course_setting.set({ value: 'hi' }); + expect(course_setting.isValid()).toBe(false); + + course_setting.set({ value: ['1', '2', '4'] }); + expect(course_setting.isValid()).toBe(false); + + // Test the options in the form label/value + course_setting.set({ + options: [ + { label: 'option 1', value: '1' }, + { label: 'option 2', value: '2' }, + { label: 'option 3', value: '3' }, + ] + }); + expect(course_setting.isValid()).toBe(true); + + }); + + test('test the validity of course settings for default_value type boolean', () => { + const course_setting = new CourseSetting(); + expect(course_setting.isValid()).toBe(false); + + course_setting.set({ + setting_name: 'a_boolean', + default_value: true, + description: 'I am true or false', + doc: 'Extended help', + type: 'boolean', + category: 'general' + }); + + expect(course_setting.isValid()).toBe(true); + + course_setting.set({ value: 3.14 }); + expect(course_setting.isValid()).toBe(false); + + course_setting.set({ value: 3 }); + expect(course_setting.isValid()).toBe(false); + + course_setting.set({ value: ['1', '2', '3'] }); + expect(course_setting.isValid()).toBe(false); + }); + + test('test the validity of course settings for default_value type time_duration', () => { + const course_setting = new CourseSetting(); + expect(course_setting.isValid()).toBe(false); + + course_setting.set({ + setting_name: 'time_duration', + default_value: '10 days', + description: 'I am an time interval', + doc: 'Extended help', + type: 'time_duration', + category: 'general' + }); + + expect(course_setting.isValid()).toBe(true); + + course_setting.set({ value: 3.14 }); + expect(course_setting.isValid()).toBe(false); + + course_setting.set({ value: 'hi' }); + expect(course_setting.isValid()).toBe(false); + + course_setting.set({ value: ['1', '2', '3'] }); + expect(course_setting.isValid()).toBe(false); + }); + + }); +}); From a32d5c3270604181dab1246d8b8760a3221266d6 Mon Sep 17 00:00:00 2001 From: Peter Staab Date: Tue, 2 Aug 2022 08:45:15 -0400 Subject: [PATCH 33/35] WIP: work on course settings --- lib/DB/Schema/Result/CourseSetting.pm | 11 ++- lib/DB/Schema/Result/GlobalSetting.pm | 10 +- lib/DB/Schema/ResultSet/Course.pm | 78 +++++++++------ lib/WeBWorK3.pm | 3 +- lib/WeBWorK3/Controller/Settings.pm | 20 ++-- lib/WeBWorK3/Utils/Settings.pm | 12 ++- src/components/instructor/SingleSetting.vue | 70 ++++++++----- src/layouts/MenuBar.vue | 5 +- src/pages/instructor/Instructor.vue | 8 +- src/pages/instructor/Settings.vue | 39 +++----- src/stores/settings.ts | 17 +++- t/db/002_course_settings.t | 103 ++++++++++++-------- t/db/build_db.pl | 4 +- t/mojolicious/015_course_settings.t | 31 +++--- tests/stores/settings.spec.ts | 17 +++- 15 files changed, 250 insertions(+), 178 deletions(-) diff --git a/lib/DB/Schema/Result/CourseSetting.pm b/lib/DB/Schema/Result/CourseSetting.pm index b1604e22..6217a65d 100644 --- a/lib/DB/Schema/Result/CourseSetting.pm +++ b/lib/DB/Schema/Result/CourseSetting.pm @@ -25,7 +25,7 @@ C: database id that the given setting is related to (foreign key) =item * -C: the value of the setting +C: the value of the setting as a JSON so different types of data can be stored. =back @@ -33,6 +33,8 @@ C: the value of the setting __PACKAGE__->table('course_setting'); +__PACKAGE__->load_components('InflateColumn::Serializer', 'Core'); + __PACKAGE__->add_columns( course_setting_id => { data_type => 'integer', @@ -51,8 +53,11 @@ __PACKAGE__->add_columns( is_nullable => 0, }, value => { - data_type => 'text', - is_nullable => 0, + data_type => 'text', + is_nullable => 0, + default_value => '\'\'', + serializer_class => 'JSON', + serializer_options => { utf8 => 1 } }, ); diff --git a/lib/DB/Schema/Result/GlobalSetting.pm b/lib/DB/Schema/Result/GlobalSetting.pm index a16146c9..e2d6045f 100644 --- a/lib/DB/Schema/Result/GlobalSetting.pm +++ b/lib/DB/Schema/Result/GlobalSetting.pm @@ -80,8 +80,8 @@ __PACKAGE__->add_columns( default_value => '', }, doc => { - data_type => 'text', - is_nullable => 1, + data_type => 'text', + is_nullable => 1, }, type => { data_type => 'varchar', @@ -102,9 +102,9 @@ __PACKAGE__->add_columns( default_value => '' }, subcategory => { - data_type => 'varchar', - size => 64, - is_nullable => 1 + data_type => 'varchar', + size => 64, + is_nullable => 1 } ); diff --git a/lib/DB/Schema/ResultSet/Course.pm b/lib/DB/Schema/ResultSet/Course.pm index 49ce48aa..a7fccb33 100644 --- a/lib/DB/Schema/ResultSet/Course.pm +++ b/lib/DB/Schema/ResultSet/Course.pm @@ -353,18 +353,19 @@ sub getCourseSettings ($self, %args) { my @settings_from_db = $course->course_settings; return \@settings_from_db if $args{as_result_set}; - my @settings_to_return; - if ($args{merged}) { - @settings_to_return = map { + my @settings_to_return = ($args{merged}) + ? map { { $_->get_inflated_columns, $_->global_setting->get_inflated_columns }; - } @settings_from_db; - for my $setting (@settings_to_return) { - $setting->{default_value} = $setting->{default_value}->{value}; - } - } else { - @settings_to_return = map { + } @settings_from_db + : map { { $_->get_inflated_columns }; } @settings_from_db; + + for my $setting (@settings_to_return) { + # value and default_value are decoded from JSON as a hash. Return to a value. + for my $key (qw/default_value value/) { + $setting->{$key} = $setting->{$key}->{value} if defined($setting->{$key}); + } } return \@settings_to_return; } @@ -406,13 +407,16 @@ sub getCourseSetting ($self, %args) { my $setting = $course->course_settings->find({ setting_id => $global_setting->setting_id }); return $setting if $args{as_result_set}; - if ($args{merged}) { - my $setting_to_return = { $setting->get_inflated_columns, $setting->global_setting->get_inflated_columns }; - $setting_to_return->{default_value} = $setting_to_return->{default_value}->{value}; - return $setting_to_return; - } else { - return { $setting->get_inflated_columns }; + my $setting_to_return = + $args{merged} + ? { $setting->get_inflated_columns, $setting->global_setting->get_inflated_columns } + : { $setting->get_inflated_columns }; + + # value and default_value are decoded from JSON as a hash. Return to a value. + for my $key (qw/default_value value/) { + $setting_to_return->{$key} = $setting_to_return->{$key}->{value} if defined($setting_to_return->{$key}); } + return $setting_to_return; } =pod @@ -442,7 +446,9 @@ global setting. A single course setting as either a hashref or a C object. =cut + use Data::Dumper; + sub updateCourseSetting ($self, %args) { my $course = $self->getCourse(info => getCourseInfo($args{info}), as_result_set => 1); @@ -457,32 +463,32 @@ sub updateCourseSetting ($self, %args) { my $params = { course_id => $course->course_id, setting_id => $global_setting->{setting_id}, - value => $args{params}->{value} + value => { value => $args{params}->{value} } }; # remove the following fields before checking for valid settings: for (qw/setting_id course_id/) { delete $global_setting->{$_}; } - isValidSetting($global_setting, $params->{value}); + isValidSetting($global_setting, $params->{value}->{value}); # The course_id must be deleted to ensure it is written to the database correctly. delete $params->{course_id} if defined($params->{course_id}); - my $updated_course_setting = defined($course_setting) ? $course_setting->update($params) : $course->add_to_course_settings($params); - - if ($args{merged}) { - my $setting_to_return = { - $updated_course_setting->get_inflated_columns, - $updated_course_setting->global_setting->get_inflated_columns - }; - $setting_to_return->{default_value} = $setting_to_return->{default_value}->{value}; - return $setting_to_return; - } else { - return { $updated_course_setting->get_inflated_columns }; - } + my $updated_course_setting = + defined($course_setting) ? $course_setting->update($params) : $course->add_to_course_settings($params); + return $updated_course_setting if $args{as_result_set}; + my $setting_to_return = + ($args{merged}) + ? { $updated_course_setting->get_inflated_columns, + $updated_course_setting->global_setting->get_inflated_columns } + : { $updated_course_setting->get_inflated_columns }; - return $args{as_result_set} ? $updated_course_setting : { $updated_course_setting->get_inflated_columns }; + # value and default_value are decoded from JSON as a hash. Return to a value. + for my $key (qw/default_value value/) { + $setting_to_return->{$key} = $setting_to_return->{$key}->{value} if defined($setting_to_return->{$key}); + } + return $setting_to_return; } =pod @@ -513,7 +519,17 @@ sub deleteCourseSetting ($self, %args) { my $deleted_setting = $setting->delete; return $deleted_setting if $args{as_result_set}; - return { $deleted_setting->get_inflated_columns }; + + my $setting_to_return = + ($args{merged}) + ? { $deleted_setting->get_inflated_columns, $deleted_setting->global_setting->get_inflated_columns } + : { $deleted_setting->get_inflated_columns }; + + # value and default_value are decoded from JSON as a hash. Return to a value. + for my $key (qw/default_value value/) { + $setting_to_return->{$key} = $setting_to_return->{$key}->{value} if defined($setting_to_return->{$key}); + } + return $setting_to_return; } 1; diff --git a/lib/WeBWorK3.pm b/lib/WeBWorK3.pm index d65442ca..db58de94 100644 --- a/lib/WeBWorK3.pm +++ b/lib/WeBWorK3.pm @@ -183,8 +183,7 @@ sub problemRoutes ($self) { } sub settingsRoutes ($self) { - $self->routes->get('/webwork3/api/global-settings')->requires(authenticated => 1) - ->to('Settings#getGlobalSettings'); + $self->routes->get('/webwork3/api/global-settings')->requires(authenticated => 1)->to('Settings#getGlobalSettings'); $self->routes->get('/webwork3/api/global-setting/:setting_id')->requires(authenticated => 1) ->to('Settings#getGlobalSetting'); $self->routes->get('/webwork3/api/courses/:course_id/settings')->requires(authenticated => 1) diff --git a/lib/WeBWorK3/Controller/Settings.pm b/lib/WeBWorK3/Controller/Settings.pm index c88f2110..303db1dd 100644 --- a/lib/WeBWorK3/Controller/Settings.pm +++ b/lib/WeBWorK3/Controller/Settings.pm @@ -18,9 +18,11 @@ sub getGlobalSettings ($c) { } sub getGlobalSetting ($c) { - my $setting = $c->schema->resultset('Course')->getGlobalSetting(info => { - setting_id => int($c->param('setting_id')) - }); + my $setting = $c->schema->resultset('Course')->getGlobalSetting( + info => { + setting_id => int($c->param('setting_id')) + } + ); $c->render(json => $setting); return; } @@ -38,8 +40,8 @@ sub getCourseSettings ($c) { sub updateCourseSetting ($c) { my $course_setting = $c->schema->resultset('Course')->updateCourseSetting( - info => { - course_id => $c->param('course_id'), + info => { + course_id => $c->param('course_id'), setting_id => $c->param('setting_id') }, params => $c->req->json @@ -50,13 +52,13 @@ sub updateCourseSetting ($c) { sub deleteCourseSetting ($c) { my $course_setting = $c->schema->resultset('Course')->deleteCourseSetting( - info => { - course_id => $c->param('course_id'), + info => { + course_id => $c->param('course_id'), setting_id => $c->param('setting_id') - }); + } + ); $c->render(json => $course_setting); return; } - 1; diff --git a/lib/WeBWorK3/Utils/Settings.pm b/lib/WeBWorK3/Utils/Settings.pm index 54affc07..01039af9 100644 --- a/lib/WeBWorK3/Utils/Settings.pm +++ b/lib/WeBWorK3/Utils/Settings.pm @@ -22,7 +22,7 @@ use Array::Utils qw/array_minus/; my @allowed_fields = qw/setting_name category subcategory description doc default_value type options/; my @required_fields = qw/setting_name description type default_value/; my @course_setting_categories = qw/email optional general permissions problem problem_set/; -my @valid_types = qw/text list multilist boolean int decimal time date_time time_duration timezone/; +my @valid_types = qw/text list multilist boolean int decimal time date_time time_duration timezone/; =pod @@ -75,8 +75,8 @@ sub isValidSetting ($setting, $value = undef) { } elsif ($setting->{type} eq 'multilist') { validateMultilist($setting, $val); } elsif ($setting->{type} eq 'time') { - DB::Exception::InvalidCourseFieldType->throw( - message => qq/The variable $setting->{setting_name} has value $val and must be a time in the form XX:XX/) + DB::Exception::InvalidCourseFieldType->throw(message => + qq/The variable $setting->{setting_name} has value $val and must be a time in the form XX:XX/) unless isTimeString($val); } elsif ($setting->{type} eq 'int') { DB::Exception::InvalidCourseFieldType->throw( @@ -115,7 +115,8 @@ sub validateList ($setting, $value) { DB::Exception::InvalidCourseFieldType->throw( message => "The options field for the type list in $setting->{setting_name} is missing ") unless defined($setting->{options}); - DB::Exception::InvalidCourseFieldType->throw(message => "The options field for $setting->{setting_name} is not an ARRAYREF") + DB::Exception::InvalidCourseFieldType->throw( + message => "The options field for $setting->{setting_name} is not an ARRAYREF") unless ref($setting->{options}) eq 'ARRAY'; # See if the $setting->{options} is an arrayref of strings or hashrefs. @@ -143,7 +144,8 @@ sub validateMultilist ($setting, $value) { DB::Exception::InvalidCourseFieldType->throw( message => "The options field for the type multilist in $setting->{setting_name} is missing ") unless defined($setting->{options}); - DB::Exception::InvalidCourseFieldType->throw(message => "The options field for $setting->{setting_name} is not an ARRAYREF") + DB::Exception::InvalidCourseFieldType->throw( + message => "The options field for $setting->{setting_name} is not an ARRAYREF") unless ref($setting->{options}) eq 'ARRAY'; my @diff = array_minus(@{ $setting->{options} }, @$value); diff --git a/src/components/instructor/SingleSetting.vue b/src/components/instructor/SingleSetting.vue index e662630e..8f6e805e 100644 --- a/src/components/instructor/SingleSetting.vue +++ b/src/components/instructor/SingleSetting.vue @@ -1,9 +1,9 @@ diff --git a/src/layouts/MenuBar.vue b/src/layouts/MenuBar.vue index 768ea49c..496b4665 100644 --- a/src/layouts/MenuBar.vue +++ b/src/layouts/MenuBar.vue @@ -68,7 +68,6 @@ import { endSession } from 'src/common/api-requests/session'; import { useI18n } from 'vue-i18n'; import { setI18nLanguage } from 'boot/i18n'; import { useSessionStore } from 'src/stores/session'; -import type { CourseSettingInfo } from 'src/common/models/settings'; import { useSettingsStore } from 'src/stores/settings'; import { logger } from 'src/boot/logger'; @@ -99,9 +98,7 @@ const changeCourse = (course_id: number, course_name: string) => { } }; -const availableLocales = computed(() => - settings.default_settings.find((setting: CourseSettingInfo) => setting.var === 'language')?.options -); +const availableLocales = computed(() => settings.getCourseSetting('language')?.options); const logout = async () => { await endSession(); diff --git a/src/pages/instructor/Instructor.vue b/src/pages/instructor/Instructor.vue index 1f454e2c..d0ce283e 100644 --- a/src/pages/instructor/Instructor.vue +++ b/src/pages/instructor/Instructor.vue @@ -26,7 +26,7 @@ export default defineComponent({ async setup() { const session = useSessionStore(); const users = useUserStore(); - const settings = useSettingsStore(); + const settings_store = useSettingsStore(); const problem_sets = useProblemSetStore(); const route = useRoute(); @@ -47,9 +47,9 @@ export default defineComponent({ await users.fetchGlobalCourseUsers(course_id); await users.fetchCourseUsers(course_id); await problem_sets.fetchProblemSets(course_id); - await settings.fetchDefaultSettings() - .then(() => settings.fetchCourseSettings(course_id)) - .then(() => void setI18nLanguage(settings.getCourseSetting('language').value as string)) + await settings_store.fetchGlobalSettings() + .then(() => settings_store.fetchCourseSettings(course_id)) + .then(() => void setI18nLanguage(settings_store.getCourseSetting('language').value as string)) .catch((err) => logger.error(`${JSON.stringify(err)}`)); }, diff --git a/src/pages/instructor/Settings.vue b/src/pages/instructor/Settings.vue index ccd3432c..ba0ed7e9 100644 --- a/src/pages/instructor/Settings.vue +++ b/src/pages/instructor/Settings.vue @@ -19,8 +19,8 @@ - - diff --git a/src/stores/settings.ts b/src/stores/settings.ts index d40c81d4..8937d67c 100644 --- a/src/stores/settings.ts +++ b/src/stores/settings.ts @@ -3,7 +3,7 @@ import { defineStore } from 'pinia'; import { useSessionStore } from 'src/stores/session'; import { CourseSetting, DBCourseSetting, GlobalSetting, ParseableDBCourseSetting, - ParseableGlobalSetting } from 'src/common/models/settings'; + ParseableGlobalSetting, SettingValueType } from 'src/common/models/settings'; export interface SettingsState { // These are the default setting and documentation @@ -43,16 +43,25 @@ export const useSettingsStore = defineStore('settings', { } }, /** - * This returns the value of the setting in the course. If the setting has been - * changed from the default, that value is used, if not the default value is used. + * This returns the value of the setting in the course passed in as a string. If the + * setting has been changed from the default, that value is used, if not the default value is used. */ // Note: using standard function notation (not arrow) due to using this. - getSettingValue() { + getSettingValue(): {(setting_name: string): SettingValueType} { return (setting_name: string) => { const course_setting = this.getCourseSetting(setting_name); return course_setting.value; }; }, + /** + * This returns the course settings for the given category (as a string) + */ + getSettingsByCategory(state): { (category_name: string): CourseSetting[] } { + return (category_name: string): CourseSetting[] => { + const category = state.global_settings.filter(setting => setting.category === category_name); + return category.map(setting => this.getCourseSetting(setting.setting_name)); + }; + } }, actions: { async fetchGlobalSettings(): Promise { diff --git a/t/db/002_course_settings.t b/t/db/002_course_settings.t index 74b82d36..e223c219 100644 --- a/t/db/002_course_settings.t +++ b/t/db/002_course_settings.t @@ -73,7 +73,7 @@ ok(!isDecimal('abc'), 'check type: not a decimal'); # Check that each of the given course_setting types are both valid and invalid. my $valid_setting = { - setting_name => 'my_setting', + setting_name => 'my_setting', description => 'this is a setting', type => 'int', category => 'general', @@ -84,7 +84,7 @@ ok(isValidSetting($valid_setting), 'course setting: valid setting'); # Check that the setting hash has only valid fields throws_ok { isValidSetting({ - setting_name => 'my_setting', + setting_name => 'my_setting', doc3 => 'this is a setting', type => 'int', category => 'general', @@ -95,7 +95,7 @@ throws_ok { throws_ok { isValidSetting({ - setting_name => 'my_setting', + setting_name => 'my_setting', type => 'int', category => 'general', default_value => 0 @@ -105,7 +105,7 @@ throws_ok { throws_ok { isValidSetting({ - setting_name => 'my_setting', + setting_name => 'my_setting', description => 'this is a setting', type => 'nonnegint', category => 'general', @@ -118,7 +118,7 @@ throws_ok { throws_ok { isValidSetting({ - setting_name => 'my_setting', + setting_name => 'my_setting', description => 'this is a setting', type => 'time', category => 'general', @@ -129,7 +129,7 @@ throws_ok { throws_ok { isValidSetting({ - setting_name => 'my_setting', + setting_name => 'my_setting', description => 'this is a setting', type => 'integer', category => 'general', @@ -140,7 +140,7 @@ throws_ok { throws_ok { isValidSetting({ - setting_name => 'my_setting', + setting_name => 'my_setting', description => 'this is a setting', type => 'time_duration', category => 'general', @@ -151,7 +151,7 @@ throws_ok { throws_ok { isValidSetting({ - setting_name => 'my_setting', + setting_name => 'my_setting', description => 'this is a setting', type => 'decimal', category => 'general', @@ -176,8 +176,7 @@ for my $setting (@$global_settings) { local $YAML::XS::Boolean = "JSON::PP"; my $global_settings_from_file = LoadFile("$main::ww3_dir/conf/course_settings.yml"); -is_deeply($global_settings, $global_settings_from_file, - 'default settings: db values are the same as the file values.'); +is_deeply($global_settings, $global_settings_from_file, 'default settings: db values are the same as the file values.'); # Make sure all of the default settings are valid for my $setting (@$global_settings) { @@ -211,19 +210,36 @@ for my $setting (@$arith_settings_from_db) { is_deeply($arith_settings_from_db, \@arith_settings, 'getCourseSettings: compare settings for given course'); my $updated_setting = $course_rs->updateCourseSetting( - info => { - course_id => $new_course->{course_id}, + info => { + course_id => $new_course->{course_id}, setting_name => 'course_description' }, params => { value => 'This is my new course description' } ); -is('This is my new course description', $updated_setting->{value}, 'updateCourseSetting: successfully update a course setting'); +# Check that updating a boolean is a JSON boolean + +my $boolean_setting = $course_rs->updateCourseSetting( + info => { + course_id => $new_course->{course_id}, + setting_name => 'enable_conditional_release' + }, + params => { value => true } +); + +is($boolean_setting->{value}, true, 'updateCourseSetting: ensure that a value is truthy'); +ok(JSON::PP::is_bool($boolean_setting->{value}), 'updateCourseSetting: ensure that a value is a JSON boolean'); + +is( + 'This is my new course description', + $updated_setting->{value}, + 'updateCourseSetting: successfully update a course setting' +); my $fetched_setting = $course_rs->getCourseSetting( info => { - course_id => $new_course->{course_id}, - setting_name => 'course_description' + course_id => $new_course->{course_id}, + setting_name => 'course_description' } ); @@ -233,9 +249,9 @@ is($fetched_setting->{value}, $updated_setting->{value}, 'getCourseSetting: fetc throws_ok { $course_rs->updateCourseSetting( - info => { - course_id => $new_course->{course_id}, - setting_name => 'non_existant_setting' + info => { + course_id => $new_course->{course_id}, + setting_name => 'non_existant_setting' }, params => { value => 3 } ); @@ -244,20 +260,20 @@ throws_ok { throws_ok { $course_rs->updateCourseSetting( - info => { - course_id => $new_course->{course_id}, - setting_name => 'language' + info => { + course_id => $new_course->{course_id}, + setting_name => 'language' }, - params => { value => 'Klingon'} + params => { value => 'Klingon' } ); } 'DB::Exception::InvalidCourseFieldType', 'updateCourseSetting: try to update the list setting.'; throws_ok { $course_rs->updateCourseSetting( - info => { - course_id => $new_course->{course_id}, - setting_name => 'session_key_timeout' + info => { + course_id => $new_course->{course_id}, + setting_name => 'session_key_timeout' }, params => { value => '45 years' } ); @@ -266,9 +282,9 @@ throws_ok { throws_ok { $course_rs->updateCourseSetting( - info => { - course_id => $new_course->{course_id}, - setting_name => 'enable_reduced_scoring' + info => { + course_id => $new_course->{course_id}, + setting_name => 'enable_reduced_scoring' }, params => { value => 'true' } ); @@ -277,9 +293,9 @@ throws_ok { throws_ok { $course_rs->updateCourseSetting( - info => { - course_id => $new_course->{course_id}, - setting_name => 'show_me_another_default' + info => { + course_id => $new_course->{course_id}, + setting_name => 'show_me_another_default' }, params => { value => 'true' } ); @@ -288,9 +304,9 @@ throws_ok { throws_ok { $course_rs->updateCourseSetting( - info => { - course_id => $new_course->{course_id}, - setting_name => 'display_mode_options' + info => { + course_id => $new_course->{course_id}, + setting_name => 'display_mode_options' }, params => { value => [ '1', '2' ] } ); @@ -299,9 +315,9 @@ throws_ok { throws_ok { $course_rs->updateCourseSetting( - info => { - course_id => $new_course->{course_id}, - setting_name => 'num_rel_percent_tol_default' + info => { + course_id => $new_course->{course_id}, + setting_name => 'num_rel_percent_tol_default' }, params => { value => 'true' } ); @@ -312,13 +328,22 @@ throws_ok { my $deleted_setting = $course_rs->deleteCourseSetting( info => { - course_name => 'New Course', - setting_name => 'course_description' + course_name => 'New Course', + setting_name => 'course_description' } ); is_deeply($deleted_setting, $updated_setting, 'deleteCourseSetting: delete a course setting.'); +my $deleted_setting2 = $course_rs->deleteCourseSetting( + info => { + course_name => 'New Course', + setting_name => 'enable_conditional_release' + } +); + +is_deeply($deleted_setting2, $boolean_setting, 'deleteCourseSetting: delete another course setting.'); + # Finally delete the course that was made $course_rs->deleteCourse(info => { course_id => $new_course->{course_id} }); diff --git a/t/db/build_db.pl b/t/db/build_db.pl index 5f4e79c1..47b0b9aa 100755 --- a/t/db/build_db.pl +++ b/t/db/build_db.pl @@ -91,10 +91,12 @@ sub addSettings { die "the course: '$setting->{course_name}' does not exist in the db" unless $course; my $global_setting = $global_setting_rs->find({ setting_name => $setting->{setting_name} }); die "the setting: '$setting->{setting_name}' does not exist in the db" unless $global_setting; + $course->add_to_course_settings({ course_id => $course->course_id, setting_id => $global_setting->setting_id, - value => $setting->{setting_value} + # encode value as a JSON object. + value => { value => $setting->{setting_value} } }); } return; diff --git a/t/mojolicious/015_course_settings.t b/t/mojolicious/015_course_settings.t index b14ce267..dc564356 100644 --- a/t/mojolicious/015_course_settings.t +++ b/t/mojolicious/015_course_settings.t @@ -40,7 +40,6 @@ $t->post_ok('/webwork3/api/login' => json => { username => 'admin', password => # Load the global settings from the file my $global_settings_from_file = LoadFile("$main::ww3_dir/conf/course_settings.yml"); - # Get the global/default settings $t->get_ok('/webwork3/api/global-settings')->content_type_is('application/json;charset=UTF-8')->status_is(200); @@ -61,23 +60,22 @@ is_deeply($global_settings_from_db, $global_settings_from_file, 'test that the g # get a single global/default setting $t->get_ok('/webwork3/api/global-setting/1')->content_type_is('application/json;charset=UTF-8')->status_is(200) - ->json_is('/setting_name' => $global_settings_from_file->[0]->{setting_name}) + ->json_is('/setting_name' => $global_settings_from_file->[0]->{setting_name}) ->json_is('/default_value' => $global_settings_from_file->[0]->{default_value}) - ->json_is('/description' => $global_settings_from_file->[0]->{description}); + ->json_is('/description' => $global_settings_from_file->[0]->{description}); # Get all of the course settings for Arithmetic from the csv file: my @course_settings = loadCSV("$main::ww3_dir/t/db/sample_data/course_settings.csv"); @course_settings = grep { $_->{course_name} eq 'Arithmetic' } @course_settings; - # pull out setting_name/value pairs for my $setting (@course_settings) { $setting = { setting_name => $setting->{setting_name}, - value => $setting->{setting_value} - } -}; + value => $setting->{setting_value} + }; +} # Get all course settings for a course (Arithmetic- course_id: 4) @@ -87,9 +85,9 @@ my $course_settings_from_db = $t->tx->res->json; for my $setting (@$course_settings_from_db) { $setting = { setting_name => $setting->{setting_name}, - value => $setting->{value} - } -}; + value => $setting->{value} + }; +} is_deeply($course_settings_from_db, \@course_settings, 'Ensure that the course settings are correct.'); @@ -97,14 +95,13 @@ is_deeply($course_settings_from_db, \@course_settings, 'Ensure that the course s my $reduced_scoring = firstval { $_->{setting_name} eq 'reduced_scoring_value' } @$global_settings; -$t->put_ok("/webwork3/api/courses/4/settings/$reduced_scoring->{setting_id}" => json =>{ - value => 0.5 -})->content_type_is('application/json;charset=UTF-8')->status_is(200) -->json_is('/value' => 0.5); +$t->put_ok( + "/webwork3/api/courses/4/settings/$reduced_scoring->{setting_id}" => json => { + value => 0.5 + } +)->content_type_is('application/json;charset=UTF-8')->status_is(200)->json_is('/value' => 0.5); $t->delete_ok("/webwork3/api/courses/4/settings/$reduced_scoring->{setting_id}") - ->content_type_is('application/json;charset=UTF-8')->status_is(200) - ->json_is('/value' => 0.5); - + ->content_type_is('application/json;charset=UTF-8')->status_is(200)->json_is('/value' => 0.5); done_testing; diff --git a/tests/stores/settings.spec.ts b/tests/stores/settings.spec.ts index 0104ebcc..344968bd 100644 --- a/tests/stores/settings.spec.ts +++ b/tests/stores/settings.spec.ts @@ -19,7 +19,7 @@ import { api } from 'boot/axios'; import { useSessionStore } from 'src/stores/session'; import { useSettingsStore } from 'src/stores/settings'; -import { DBCourseSetting, ParseableDBCourseSetting, ParseableGlobalSetting, SettingValueType +import { CourseSetting, DBCourseSetting, ParseableDBCourseSetting, ParseableGlobalSetting, SettingValueType } from 'src/common/models/settings'; import { cleanIDs, loadCSV } from '../utils'; @@ -101,6 +101,21 @@ describe('Test the settings store', () => { settings_store.getCourseSetting('non_existant_setting'); }).toThrowError('The setting with name: \'non_existant_setting\' does not exist.'); }); + + test('Get all course settings for a given category', () => { + const settings_store = useSettingsStore(); + const settings_from_db = settings_store.getSettingsByCategory('general'); + const settings_from_file = default_settings + .filter(setting => setting.category === 'general') + .map(setting => new CourseSetting(setting)); + // merge in the course setting overrides. + settings_from_file.forEach(setting => { + const db_setting = arith_settings.find(a_setting => setting.setting_name === a_setting.setting_name); + if (db_setting) setting.value = db_setting.value; + }); + + expect(cleanIDs(settings_from_db)).toStrictEqual(cleanIDs(settings_from_file)); + }); }); describe('Update a Course Setting', () => { From 75f17adbf3a3b3cac9c4ca229023d3294e8b662d Mon Sep 17 00:00:00 2001 From: Peter Staab Date: Tue, 2 Aug 2022 09:01:30 -0400 Subject: [PATCH 34/35] FIX: errors after merge --- .eslintrc.js | 1 - lib/WeBWorK3/Utils/Settings.pm | 1 - t/db/002_course_settings.t | 3 +-- t/mojolicious/015_course_settings.t | 7 ++++--- 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index f856be86..e9741088 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -26,7 +26,6 @@ const baseRules = { 'keyword-spacing': ['error'], 'space-before-blocks': ['error', 'always'], 'arrow-spacing': ['error'], - 'template-curly-spacing': ['error', 'never'], // allow console and debugger during development only 'no-console': process.env.NODE_ENV === 'development' ? 'off' : 'error', diff --git a/lib/WeBWorK3/Utils/Settings.pm b/lib/WeBWorK3/Utils/Settings.pm index df62936a..56009eec 100644 --- a/lib/WeBWorK3/Utils/Settings.pm +++ b/lib/WeBWorK3/Utils/Settings.pm @@ -38,7 +38,6 @@ sub getDefaultCourseSettings () { return LoadFile(Mojo::Home->new->detect->child('conf', 'course_defaults.yml')); } - =pod =head2 isValidSetting diff --git a/t/db/002_course_settings.t b/t/db/002_course_settings.t index a6ec2262..5a6f3c33 100644 --- a/t/db/002_course_settings.t +++ b/t/db/002_course_settings.t @@ -23,8 +23,7 @@ use DB::Schema; use WeBWorK3::Utils::Settings qw/isInteger isTimeString isTimeDuration isDecimal mergeCourseSettings isValidSetting/; - -use TestUtils qw/removeIDs loadSchema/; +use TestUtils qw/removeIDs loadSchema loadCSV/; # Load the database my $config_file = "$main::ww3_dir/conf/webwork3-test.yml"; diff --git a/t/mojolicious/015_course_settings.t b/t/mojolicious/015_course_settings.t index dc564356..1406ee13 100644 --- a/t/mojolicious/015_course_settings.t +++ b/t/mojolicious/015_course_settings.t @@ -15,16 +15,17 @@ BEGIN { } use lib "$main::ww3_dir/lib"; +use lib "$main::ww3_dir/t/lib"; use Clone qw/clone/; use YAML::XS qw/LoadFile/; use List::MoreUtils qw/firstval/; -use DB::TestUtils qw/loadCSV removeIDs/; +use TestUtils qw/loadCSV removeIDs/; # Load the config file. -my $config_file = "$main::ww3_dir/conf/ww3-dev.yml"; -$config_file = "$main::ww3_dir/conf/ww3-dev.dist.yml" unless (-e $config_file); +my $config_file = "$main::ww3_dir/conf/webwork3-test.yml"; +$config_file = "$main::ww3_dir/conf/webwork3-test.dist.yml" unless (-e $config_file); # the YAML true/false will be loaded a JSON booleans. local $YAML::XS::Boolean = "JSON::PP"; From de68081179fa1a4b87d218ccec41c762f1bd96bd Mon Sep 17 00:00:00 2001 From: Peter Staab Date: Tue, 2 Aug 2022 09:20:17 -0400 Subject: [PATCH 35/35] FIX: perl linting error --- lib/WeBWorK3/Utils/Settings.pm | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/WeBWorK3/Utils/Settings.pm b/lib/WeBWorK3/Utils/Settings.pm index 56009eec..6272f3bc 100644 --- a/lib/WeBWorK3/Utils/Settings.pm +++ b/lib/WeBWorK3/Utils/Settings.pm @@ -166,6 +166,7 @@ sub validateMultilist ($setting, $value) { throw DB::Exception::InvalidCourseFieldType->throw( message => "The values for $setting->{setting_name} must be a subset of the options field") unless scalar(@diff) == 0; + return 1; } # Test for an integer.