From caa5017d0b3128ef0e52dca1c4c10d18a8f790e5 Mon Sep 17 00:00:00 2001
From: Peter Staab
Date: Sat, 6 Aug 2022 15:08:59 -0400
Subject: [PATCH 01/11] 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/GlobalSetting.pm | 117 +++
lib/DB/Schema/ResultSet/Course.pm | 265 +++++-
lib/DB/Utils.pm | 6 +-
lib/WeBWorK3.pm | 15 +-
lib/WeBWorK3/Controller/Settings.pm | 60 +-
lib/WeBWorK3/Utils/Settings.pm | 251 ++----
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 | 44 +-
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 | 48 +-
tests/unit-tests/settings.spec.ts | 827 ++++++++++++++++++
24 files changed, 2592 insertions(+), 637 deletions(-)
rename conf/{course_defaults.yml => course_settings.yml} (72%)
create mode 100644 lib/DB/Schema/Result/CourseSetting.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 1ed1642d..7d877f30 100644
--- a/lib/DB/Exception.pm
+++ b/lib/DB/Exception.pm
@@ -18,6 +18,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 97eee89d..8d070cbe 100644
--- a/lib/DB/Schema/Result/Course.pm
+++ b/lib/DB/Schema/Result/Course.pm
@@ -74,8 +74,8 @@ __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');
=head2 C
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/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 08294bc0..fc969947 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 qw/DB::Exception::CourseNotFound DB::Exception::CourseExists/;
+use DB::Utils qw/getCourseInfo getUserInfo getSettingInfo/;
-#use 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);
@@ -254,6 +255,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
@@ -262,7 +335,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
@@ -270,28 +346,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
- my $course_settings = getDefaultCourseValues();
- my $settings_from_db = { $course->course_settings->get_inflated_columns };
- return mergeCourseSettings($course_settings, $settings_from_db);
+=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 = $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 04e037d9..95659b21 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 updatePermissions/;
+ getPoolInfo getProblemInfo getPoolProblemInfo getSettingInfo removeLoginParams/;
use Clone qw/clone/;
use List::Util qw/first/;
@@ -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 cb8163a0..6d6ef53e 100644
--- a/lib/WeBWorK3.pm
+++ b/lib/WeBWorK3.pm
@@ -216,10 +216,17 @@ sub problemRoutes ($app, $course_routes) {
return;
}
-sub settingsRoutes ($app, $course_routes) {
- $course_routes->get('/default_settings')->to('Settings#getDefaultCourseSettings');
- $course_routes->get('/settings')->to('Settings#getCourseSettings');
- $course_routes->put('/setting')->to('Settings#updateCourseSetting');
+sub settingsRoutes ($self) {
+ $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/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 3756fc5e..e9544b0e 100644
--- a/lib/WeBWorK3/Utils/Settings.pm
+++ b/lib/WeBWorK3/Utils/Settings.pm
@@ -8,11 +8,8 @@ no warnings qw/experimental::signatures/;
use YAML::XS qw/LoadFile/;
require Exporter;
-use base qw/Exporter/;
-our @EXPORT_OK = qw/checkSettings getDefaultCourseSettings getDefaultCourseValues
- mergeCourseSettings validateSettingsConfFile validateCourseSettings
- validateSingleCourseSetting validateSettingConfig
- isInteger isTimeString isTimeDuration isDecimal/;
+use base qw(Exporter);
+our @EXPORT_OK = qw/isValidSetting mergeCourseSettings isInteger isTimeString isTimeDuration isDecimal/;
use Exception::Class qw(
DB::Exception::UndefinedCourseField
@@ -20,7 +17,9 @@ use Exception::Class qw(
DB::Exception::InvalidCourseFieldType
);
-use WeBWorK3;
+use DateTime::TimeZone;
+use JSON::PP;
+use Array::Utils qw/array_minus/;
my @allowed_fields = qw/var category subcategory doc doc2 default type options/;
my @required_fields = qw/var doc type default/;
@@ -36,138 +35,16 @@ sub getDefaultCourseSettings () {
}
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
+=head2 isValidSetting
-=cut
-
-sub validateSettingsConfFile () {
- #my @all_settings = getDefaultCourseSettings();
- for my $setting (@{ getDefaultCourseSettings() }) {
- validateSettingConfig($setting);
- }
- return 1;
-}
-
-=pod
-
-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
@@ -178,18 +55,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;
}
@@ -197,49 +72,64 @@ 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) {
+=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 => qq/The options field for the type list in $setting->{var} is missing/)
+ 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")
+ unless ref($setting->{options}) eq 'ARRAY';
DB::Exception::InvalidCourseFieldType->throw(
message => qq/The options field for $setting->{var} is not an ARRAYREF/)
@@ -248,16 +138,38 @@ sub validateList ($setting) {
# 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} };
-
+ ? grep { $_ eq $value } map { $_->{value} } @{ $setting->{options} }
+ : grep { $_ eq $value } @{ $setting->{options} };
DB::Exception::InvalidCourseFieldType->throw(
- message => qq/The default for variable $setting->{var} needs to be one of the given options/)
+ 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+$/;
}
@@ -272,6 +184,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 4780502e..7ffd6314 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 54642896..a746e811 100644
--- a/src/common/models/parsers.ts
+++ b/src/common/models/parsers.ts
@@ -97,6 +97,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
@@ -104,6 +107,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 @@
- {{ setting.doc }}
-
+ {{ setting?.doc }}
+
{{ setting.doc2 }}
@@ -10,69 +10,47 @@
-
-
-
-
+
+
+
+
|
| |
-
diff --git a/src/stores/settings.ts b/src/stores/settings.ts
index 94dc6dc6..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(course_id: number): Promise {
- const response = await api.get(`courses/${course_id}/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 72334a12..bfb22446 100644
--- a/t/db/002_course_settings.t
+++ b/t/db/002_course_settings.t
@@ -16,15 +16,15 @@ use lib "$main::ww3_dir/t/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 TestUtils qw/removeIDs loadSchema/;
+use DB::TestUtils qw/removeIDs loadCSV/;
# Load the database
my $config_file = "$main::ww3_dir/conf/webwork3-test.yml";
@@ -77,64 +77,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';
@@ -142,119 +123,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 03bd1146..db2a3f9a 100755
--- a/t/db/build_db.pl
+++ b/t/db/build_db.pl
@@ -28,8 +28,11 @@ BEGIN
my $verbose = 1;
# Load the configuration for the database settings.
-my $config_file = "$main::ww3_dir/conf/webwork3-test.yml";
-$config_file = "$main::ww3_dir/conf/webwork3-test.dist.yml" unless (-e $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 = LoadFile($config_file);
# Load the Permissions file
@@ -73,20 +76,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;
@@ -342,6 +365,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 db7eaa12..f17e6771 100644
--- a/tests/unit-tests/parsing.spec.ts
+++ b/tests/unit-tests/parsing.spec.ts
@@ -2,9 +2,11 @@
import { parseNonNegInt, parseBoolean, parseEmail, parseUsername, EmailParseException,
NonNegIntException, BooleanParseException, UsernameParseException,
- parseNonNegDecimal, NonNegDecimalException } from 'src/common/models/parsers';
+ parseUserRole, parseNonNegDecimal, NonNegDecimalException, isTime, isTimeDuration
+} from 'src/common/models/parsers';
+
+describe('Testing Parsers and Regular Expressions', () => {
-describe('Testing parsing functions', () => {
test('parsing nonnegative integers', () => {
expect(parseNonNegInt(1)).toBe(1);
expect(parseNonNegInt('1')).toBe(1);
@@ -65,4 +67,46 @@ describe('Testing parsing functions', () => {
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);
+ });
+
});
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 d9bb5c6dc4dea391f01e228c20a2c440fc8e0a2f Mon Sep 17 00:00:00 2001
From: Peter Staab
Date: Tue, 2 Aug 2022 08:45:15 -0400
Subject: [PATCH 02/11] 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 | 4 +-
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 +++-
14 files changed, 246 insertions(+), 173 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 fc969947..19a05cd5 100644
--- a/lib/DB/Schema/ResultSet/Course.pm
+++ b/lib/DB/Schema/ResultSet/Course.pm
@@ -356,18 +356,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;
}
@@ -409,13 +410,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
@@ -445,7 +449,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);
@@ -460,32 +466,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
@@ -516,7 +522,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 6d6ef53e..a0771007 100644
--- a/lib/WeBWorK3.pm
+++ b/lib/WeBWorK3.pm
@@ -217,8 +217,7 @@ sub problemRoutes ($app, $course_routes) {
}
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 e9544b0e..6f83be1f 100644
--- a/lib/WeBWorK3/Utils/Settings.pm
+++ b/lib/WeBWorK3/Utils/Settings.pm
@@ -35,7 +35,7 @@ sub getDefaultCourseSettings () {
}
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
@@ -88,8 +88,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(
@@ -128,7 +128,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';
DB::Exception::InvalidCourseFieldType->throw(
@@ -160,7 +161,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 @@
- {{ setting?.doc }}
-
+ {{ setting.description }}
+
- {{ setting.doc2 }}
+ {{ setting.doc }}
|
@@ -11,46 +11,66 @@
-
-
-
-
+
+
+
+
|
diff --git a/src/layouts/MenuBar.vue b/src/layouts/MenuBar.vue
index 4aae826d..00354a4d 100644
--- a/src/layouts/MenuBar.vue
+++ b/src/layouts/MenuBar.vue
@@ -101,9 +101,7 @@ const changeCourse = (course_id: number) => {
}
};
-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/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 @@
-
-
+
+
@@ -30,35 +30,18 @@
-
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 bfb22446..c2928e90 100644
--- a/t/db/002_course_settings.t
+++ b/t/db/002_course_settings.t
@@ -79,7 +79,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',
@@ -90,7 +90,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',
@@ -101,7 +101,7 @@ throws_ok {
throws_ok {
isValidSetting({
- setting_name => 'my_setting',
+ setting_name => 'my_setting',
type => 'int',
category => 'general',
default_value => 0
@@ -111,7 +111,7 @@ throws_ok {
throws_ok {
isValidSetting({
- setting_name => 'my_setting',
+ setting_name => 'my_setting',
description => 'this is a setting',
type => 'nonnegint',
category => 'general',
@@ -124,7 +124,7 @@ throws_ok {
throws_ok {
isValidSetting({
- setting_name => 'my_setting',
+ setting_name => 'my_setting',
description => 'this is a setting',
type => 'time',
category => 'general',
@@ -135,7 +135,7 @@ throws_ok {
throws_ok {
isValidSetting({
- setting_name => 'my_setting',
+ setting_name => 'my_setting',
description => 'this is a setting',
type => 'integer',
category => 'general',
@@ -146,7 +146,7 @@ throws_ok {
throws_ok {
isValidSetting({
- setting_name => 'my_setting',
+ setting_name => 'my_setting',
description => 'this is a setting',
type => 'time_duration',
category => 'general',
@@ -157,7 +157,7 @@ throws_ok {
throws_ok {
isValidSetting({
- setting_name => 'my_setting',
+ setting_name => 'my_setting',
description => 'this is a setting',
type => 'decimal',
category => 'general',
@@ -182,8 +182,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) {
@@ -217,19 +216,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'
}
);
@@ -239,9 +255,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 }
);
@@ -250,20 +266,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' }
);
@@ -272,9 +288,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' }
);
@@ -283,9 +299,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' }
);
@@ -294,9 +310,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' ] }
);
@@ -305,9 +321,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' }
);
@@ -318,13 +334,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 db2a3f9a..79c5e168 100755
--- a/t/db/build_db.pl
+++ b/t/db/build_db.pl
@@ -101,10 +101,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 6db7fd13b39b97352a672743f48180a118690233 Mon Sep 17 00:00:00 2001
From: Peter Staab
Date: Sat, 6 Aug 2022 15:20:39 -0400
Subject: [PATCH 03/11] FIX: errors after rebasing.
---
lib/WeBWorK3/Utils/Settings.pm | 5 +++--
t/db/002_course_settings.t | 2 +-
t/db/build_db.pl | 4 ++--
t/mojolicious/015_course_settings.t | 7 ++++---
4 files changed, 10 insertions(+), 8 deletions(-)
diff --git a/lib/WeBWorK3/Utils/Settings.pm b/lib/WeBWorK3/Utils/Settings.pm
index 6f83be1f..de1d27a7 100644
--- a/lib/WeBWorK3/Utils/Settings.pm
+++ b/lib/WeBWorK3/Utils/Settings.pm
@@ -21,8 +21,8 @@ use DateTime::TimeZone;
use JSON::PP;
use Array::Utils qw/array_minus/;
-my @allowed_fields = qw/var category subcategory doc doc2 default type options/;
-my @required_fields = qw/var doc type default/;
+my @allowed_fields = qw/setting_name category subcategory description doc default_value type options/;
+my @required_fields = qw/setting_name description type default_value/;
=head1 loadDefaultCourseSettings
@@ -169,6 +169,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.
diff --git a/t/db/002_course_settings.t b/t/db/002_course_settings.t
index c2928e90..b3c971dc 100644
--- a/t/db/002_course_settings.t
+++ b/t/db/002_course_settings.t
@@ -24,7 +24,7 @@ use DB::Schema;
use WeBWorK3::Utils::Settings qw/isInteger isTimeString isTimeDuration isDecimal mergeCourseSettings
isValidSetting/;
-use DB::TestUtils qw/removeIDs loadCSV/;
+use TestUtils qw/removeIDs loadCSV/;
# Load the database
my $config_file = "$main::ww3_dir/conf/webwork3-test.yml";
diff --git a/t/db/build_db.pl b/t/db/build_db.pl
index 79c5e168..1e81482c 100755
--- a/t/db/build_db.pl
+++ b/t/db/build_db.pl
@@ -28,8 +28,8 @@ BEGIN
my $verbose = 1;
# 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);
+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";
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 b8df850aa21fa34858e7b2192425f37a991d05ef Mon Sep 17 00:00:00 2001
From: Peter Staab
Date: Tue, 9 Aug 2022 10:43:43 -0400
Subject: [PATCH 04/11] FIX: update code/tests after rebase with main.
---
conf/permissions.dist.yml | 4 +++-
lib/DB/Utils.pm | 4 ++--
lib/WeBWorK3.pm | 11 ++++-------
t/db/build_db.pl | 17 +++++++++--------
t/mojolicious/002_courses.t | 6 ------
tests/unit-tests/parsing.spec.ts | 9 +--------
6 files changed, 19 insertions(+), 32 deletions(-)
diff --git a/conf/permissions.dist.yml b/conf/permissions.dist.yml
index cd310b4a..b24fdaf4 100644
--- a/conf/permissions.dist.yml
+++ b/conf/permissions.dist.yml
@@ -166,7 +166,9 @@ db_permissions:
allowed_roles: ['*']
getCourseSettings:
allowed_roles: ['*']
- updateCourseSettings:
+ updateCourseSetting:
+ allowed_roles: ['course_admin', 'instructor']
+ deleteCourseSetting:
allowed_roles: ['course_admin', 'instructor']
# This defines the permisions for each role for the frontend/UI layer.
diff --git a/lib/DB/Utils.pm b/lib/DB/Utils.pm
index 95659b21..ee5786ed 100644
--- a/lib/DB/Utils.pm
+++ b/lib/DB/Utils.pm
@@ -6,8 +6,8 @@ use feature 'signatures';
no warnings qw/experimental::signatures/;
require Exporter;
-use base qw/Exporter/;
-our @EXPORT_OK = qw/getCourseInfo getUserInfo getSetInfo updateAllFields
+use base qw(Exporter);
+our @EXPORT_OK = qw/getCourseInfo getUserInfo getSetInfo updateAllFields updatePermissions
getPoolInfo getProblemInfo getPoolProblemInfo getSettingInfo removeLoginParams/;
use Clone qw/clone/;
diff --git a/lib/WeBWorK3.pm b/lib/WeBWorK3.pm
index a0771007..190bd55c 100644
--- a/lib/WeBWorK3.pm
+++ b/lib/WeBWorK3.pm
@@ -216,16 +216,13 @@ sub problemRoutes ($app, $course_routes) {
return;
}
-sub settingsRoutes ($self) {
+sub settingsRoutes ($self, $course_routes) {
$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/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');
+ $course_routes->get('/settings')->to('Settings#getCourseSettings');
+ $course_routes->put('/settings/:setting_id')->to('Settings#updateCourseSetting');
+ $course_routes->delete('/settings/:setting_id')->to('Settings#deleteCourseSetting');
return;
}
diff --git a/t/db/build_db.pl b/t/db/build_db.pl
index 1e81482c..aeff449e 100755
--- a/t/db/build_db.pl
+++ b/t/db/build_db.pl
@@ -57,14 +57,15 @@ BEGIN
# The permissions need to be loaded into the DB before the rest of the script is run.
updatePermissions($config_file, $role_perm_file);
-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 $role_rs = $schema->resultset('Role');
+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 $role_rs = $schema->resultset('Role');
+my $global_setting_rs = $schema->resultset('GlobalSetting');
my $strp_date = DateTime::Format::Strptime->new(pattern => '%F', on_error => 'croak');
diff --git a/t/mojolicious/002_courses.t b/t/mojolicious/002_courses.t
index bf84f35a..b470ed53 100644
--- a/t/mojolicious/002_courses.t
+++ b/t/mojolicious/002_courses.t
@@ -113,12 +113,6 @@ $t->post_ok('/webwork3/api/login' => json => { username => 'lisa', password => '
# an instructor can get information about the given course.
$t->get_ok('/webwork3/api/courses/4')->status_is(200)->json_is('/course_name' => 'Arithmetic');
-# and also the settings for the course.
-
-$t->get_ok('/webwork3/api/courses/4/default_settings')->status_is(200)
- ->content_type_is('application/json;charset=UTF-8');
-$t->get_ok('/webwork3/api/courses/4/settings')->status_is(200)->content_type_is('application/json;charset=UTF-8');
-
# The user with role instructor should not have permissions for the following routes.
$t->post_ok('/webwork3/api/courses' => json => $new_course)->status_is(403)->json_is('/has_permission' => 0);
diff --git a/tests/unit-tests/parsing.spec.ts b/tests/unit-tests/parsing.spec.ts
index f17e6771..bf1ba48f 100644
--- a/tests/unit-tests/parsing.spec.ts
+++ b/tests/unit-tests/parsing.spec.ts
@@ -2,7 +2,7 @@
import { parseNonNegInt, parseBoolean, parseEmail, parseUsername, EmailParseException,
NonNegIntException, BooleanParseException, UsernameParseException,
- parseUserRole, parseNonNegDecimal, NonNegDecimalException, isTime, isTimeDuration
+ parseNonNegDecimal, NonNegDecimalException, isTime, isTimeDuration
} from 'src/common/models/parsers';
describe('Testing Parsers and Regular Expressions', () => {
@@ -68,13 +68,6 @@ describe('Testing Parsers and Regular Expressions', () => {
});
- 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);
From 60d0405a919a78771cff74440489d1637a826578 Mon Sep 17 00:00:00 2001
From: Peter Staab
Date: Wed, 10 Aug 2022 13:02:13 -0400
Subject: [PATCH 05/11] WIP: settings view on the UI should now work for all
setting types.
---
conf/course_settings.yml | 4 +-
lib/DB/Schema/ResultSet/Course.pm | 8 +-
lib/WeBWorK3.pm | 7 +-
lib/WeBWorK3/Controller/Settings.pm | 13 +++
lib/WeBWorK3/Utils/Settings.pm | 7 +-
src/components/instructor/SingleSetting.vue | 111 ++++++++++++++++----
t/mojolicious/015_course_settings.t | 12 ++-
7 files changed, 130 insertions(+), 32 deletions(-)
diff --git a/conf/course_settings.yml b/conf/course_settings.yml
index f6d6a2fe..73d305c5 100644
--- a/conf/course_settings.yml
+++ b/conf/course_settings.yml
@@ -401,8 +401,8 @@
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_value: ''
+ type: boolean
+ default_value: false
-
setting_name: use_base_10_log
category: problem
diff --git a/lib/DB/Schema/ResultSet/Course.pm b/lib/DB/Schema/ResultSet/Course.pm
index 19a05cd5..b17e73c0 100644
--- a/lib/DB/Schema/ResultSet/Course.pm
+++ b/lib/DB/Schema/ResultSet/Course.pm
@@ -450,8 +450,6 @@ A single course setting as either a hashref or a CgetCourse(info => getCourseInfo($args{info}), as_result_set => 1);
@@ -466,13 +464,13 @@ sub updateCourseSetting ($self, %args) {
my $params = {
course_id => $course->course_id,
setting_id => $global_setting->{setting_id},
- value => { 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->{$_}; }
+ delete $global_setting->{$_} for (qw/setting_id course_id/);
- isValidSetting($global_setting, $params->{value}->{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});
diff --git a/lib/WeBWorK3.pm b/lib/WeBWorK3.pm
index 190bd55c..25f5659a 100644
--- a/lib/WeBWorK3.pm
+++ b/lib/WeBWorK3.pm
@@ -217,9 +217,10 @@ sub problemRoutes ($app, $course_routes) {
}
sub settingsRoutes ($self, $course_routes) {
- $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');
+ my $global_settings = $self->routes->any('/webwork3/api/global-settings')->requires(authenticated => 1);
+ $global_settings->get('/')->to('Settings#getGlobalSettings');
+ $global_settings->get('/:setting_id')->to('Settings#getGlobalSetting');
+ $global_settings->post('/check-timezone')->to('Settings#checkTimeZone');
$course_routes->get('/settings')->to('Settings#getCourseSettings');
$course_routes->put('/settings/:setting_id')->to('Settings#updateCourseSetting');
$course_routes->delete('/settings/:setting_id')->to('Settings#deleteCourseSetting');
diff --git a/lib/WeBWorK3/Controller/Settings.pm b/lib/WeBWorK3/Controller/Settings.pm
index 303db1dd..f937bc46 100644
--- a/lib/WeBWorK3/Controller/Settings.pm
+++ b/lib/WeBWorK3/Controller/Settings.pm
@@ -3,6 +3,10 @@ package WeBWorK3::Controller::Settings;
use warnings;
use strict;
+use Mojo::JSON qw/true false/;
+use DateTime::TimeZone;
+use Try::Tiny;
+
=head1 Description
These are the methods that call the database for course settings
@@ -61,4 +65,13 @@ sub deleteCourseSetting ($c) {
return;
}
+sub checkTimeZone ($c) {
+ try {
+ DateTime::TimeZone->new(name => $c->req->json->{timezone});
+ $c->render(json => {valid_timezone => true });
+ } catch {
+ $c->render(json => {valid_timezone => false });
+ };
+}
+
1;
diff --git a/lib/WeBWorK3/Utils/Settings.pm b/lib/WeBWorK3/Utils/Settings.pm
index de1d27a7..a162c368 100644
--- a/lib/WeBWorK3/Utils/Settings.pm
+++ b/lib/WeBWorK3/Utils/Settings.pm
@@ -56,7 +56,9 @@ This checks if the setting given the type, value and list of options (if needed)
=cut
sub isValidSetting ($setting, $value = undef) {
- return 0 if !defined $setting->{type};
+ DB::Exception::ParametersNeeded->throw(
+ message => 'The field \'type\' must be defined for the setting'
+ ) unless defined $setting->{type};
# If $value is not passed in, use the default_value for the setting
my $val = $value // $setting->{default_value};
@@ -161,11 +163,12 @@ 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);
+ my @diff = array_minus(@$value, @{ $setting->{options} });
throw DB::Exception::InvalidCourseFieldType->throw(
message => "The values for $setting->{setting_name} must be a subset of the options field")
unless scalar(@diff) == 0;
diff --git a/src/components/instructor/SingleSetting.vue b/src/components/instructor/SingleSetting.vue
index 8f6e805e..15624daa 100644
--- a/src/components/instructor/SingleSetting.vue
+++ b/src/components/instructor/SingleSetting.vue
@@ -1,40 +1,55 @@
{{ setting.description }}
-
-
- {{ setting.doc }}
-
-
+
|
+
-
+
+
+
-
-
+
+
|
+ |
+
+
diff --git a/t/mojolicious/015_course_settings.t b/t/mojolicious/015_course_settings.t
index 1406ee13..14f0c863 100644
--- a/t/mojolicious/015_course_settings.t
+++ b/t/mojolicious/015_course_settings.t
@@ -60,7 +60,7 @@ for my $setting (@$global_settings_from_db) {
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)
+$t->get_ok('/webwork3/api/global-settings/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});
@@ -105,4 +105,14 @@ $t->put_ok(
$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);
+# Check for valid and invalid timezones
+
+$t->post_ok('/webwork3/api/global-settings/check-timezone' => json => {timezone => 'America/Chicago'})
+ ->content_type_is('application/json;charset=UTF-8')->status_is(200)
+ ->json_is('/valid_timezone' => true);
+
+$t->post_ok('/webwork3/api/global-settings/check-timezone' => json => {timezone => 'Amrica/Chicago'})
+ ->status_is(200)->json_is('/valid_timezone' => false);
+
+
done_testing;
From 4312f569bf39f112ff4ed28503091e887c27c1f8 Mon Sep 17 00:00:00 2001
From: Peter Staab
Date: Wed, 10 Aug 2022 13:09:52 -0400
Subject: [PATCH 06/11] perltidy
perltidy
---
lib/WeBWorK3/Controller/Settings.pm | 4 ++--
lib/WeBWorK3/Utils/Settings.pm | 5 ++---
src/components/instructor/SingleSetting.vue | 2 +-
t/mojolicious/015_course_settings.t | 10 ++++------
4 files changed, 9 insertions(+), 12 deletions(-)
diff --git a/lib/WeBWorK3/Controller/Settings.pm b/lib/WeBWorK3/Controller/Settings.pm
index f937bc46..1120a4f5 100644
--- a/lib/WeBWorK3/Controller/Settings.pm
+++ b/lib/WeBWorK3/Controller/Settings.pm
@@ -68,9 +68,9 @@ sub deleteCourseSetting ($c) {
sub checkTimeZone ($c) {
try {
DateTime::TimeZone->new(name => $c->req->json->{timezone});
- $c->render(json => {valid_timezone => true });
+ $c->render(json => { valid_timezone => true });
} catch {
- $c->render(json => {valid_timezone => false });
+ $c->render(json => { valid_timezone => false });
};
}
diff --git a/lib/WeBWorK3/Utils/Settings.pm b/lib/WeBWorK3/Utils/Settings.pm
index a162c368..3223f6ea 100644
--- a/lib/WeBWorK3/Utils/Settings.pm
+++ b/lib/WeBWorK3/Utils/Settings.pm
@@ -56,9 +56,8 @@ This checks if the setting given the type, value and list of options (if needed)
=cut
sub isValidSetting ($setting, $value = undef) {
- DB::Exception::ParametersNeeded->throw(
- message => 'The field \'type\' must be defined for the setting'
- ) unless defined $setting->{type};
+ DB::Exception::ParametersNeeded->throw(message => 'The field \'type\' must be defined for the setting')
+ unless defined $setting->{type};
# If $value is not passed in, use the default_value for the setting
my $val = $value // $setting->{default_value};
diff --git a/src/components/instructor/SingleSetting.vue b/src/components/instructor/SingleSetting.vue
index 15624daa..deb856e4 100644
--- a/src/components/instructor/SingleSetting.vue
+++ b/src/components/instructor/SingleSetting.vue
@@ -143,7 +143,7 @@ watch(() => multilist_value.value, async () => {
.helptext {
border: 1px solid black;
border-radius: 5px;
- padding: 5px 0px;
+ padding: 5px 0;
background-color: lightyellow;
}
diff --git a/t/mojolicious/015_course_settings.t b/t/mojolicious/015_course_settings.t
index 14f0c863..85092752 100644
--- a/t/mojolicious/015_course_settings.t
+++ b/t/mojolicious/015_course_settings.t
@@ -107,12 +107,10 @@ $t->delete_ok("/webwork3/api/courses/4/settings/$reduced_scoring->{setting_id}")
# Check for valid and invalid timezones
-$t->post_ok('/webwork3/api/global-settings/check-timezone' => json => {timezone => 'America/Chicago'})
- ->content_type_is('application/json;charset=UTF-8')->status_is(200)
- ->json_is('/valid_timezone' => true);
-
-$t->post_ok('/webwork3/api/global-settings/check-timezone' => json => {timezone => 'Amrica/Chicago'})
- ->status_is(200)->json_is('/valid_timezone' => false);
+$t->post_ok('/webwork3/api/global-settings/check-timezone' => json => { timezone => 'America/Chicago' })
+ ->content_type_is('application/json;charset=UTF-8')->status_is(200)->json_is('/valid_timezone' => true);
+$t->post_ok('/webwork3/api/global-settings/check-timezone' => json => { timezone => 'Amrica/Chicago' })->status_is(200)
+ ->json_is('/valid_timezone' => false);
done_testing;
From 24b4a2ca65e8ec2aa25daef6e2c97672e851b674 Mon Sep 17 00:00:00 2001
From: Peter Staab
Date: Thu, 11 Aug 2022 06:01:50 -0400
Subject: [PATCH 07/11] TEST: updated tests to include checking permissions
---
t/db/002_course_settings.t | 7 +++++-
t/mojolicious/015_course_settings.t | 34 +++++++++++++++++++++++++----
2 files changed, 36 insertions(+), 5 deletions(-)
diff --git a/t/db/002_course_settings.t b/t/db/002_course_settings.t
index b3c971dc..6c5ea73f 100644
--- a/t/db/002_course_settings.t
+++ b/t/db/002_course_settings.t
@@ -182,7 +182,12 @@ 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.');
+# sort each of these for comparison
+my @global_settings = sort { $a->{setting_name} cmp $b->{setting_name} } @$global_settings;
+my @global_settings_from_file = sort { $a->{setting_name} cmp $b->{setting_name} } @$global_settings_from_file;
+
+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) {
diff --git a/t/mojolicious/015_course_settings.t b/t/mojolicious/015_course_settings.t
index 85092752..db1e53e7 100644
--- a/t/mojolicious/015_course_settings.t
+++ b/t/mojolicious/015_course_settings.t
@@ -20,6 +20,7 @@ use lib "$main::ww3_dir/t/lib";
use Clone qw/clone/;
use YAML::XS qw/LoadFile/;
use List::MoreUtils qw/firstval/;
+use Mojo::JSON qw/true false/;
use TestUtils qw/loadCSV removeIDs/;
@@ -33,10 +34,10 @@ 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);
+# Authenticate with an instructor.
+$t->post_ok('/webwork3/api/login' => json => { username => 'lisa', password => 'lisa' })->status_is(200)
+ ->content_type_is('application/json;charset=UTF-8')->json_is('/logged_in' => 1)
+ ->json_is('/user/username' => 'lisa')->json_is('/user/is_admin' => false);
# Load the global settings from the file
my $global_settings_from_file = LoadFile("$main::ww3_dir/conf/course_settings.yml");
@@ -113,4 +114,29 @@ $t->post_ok('/webwork3/api/global-settings/check-timezone' => json => { timezone
$t->post_ok('/webwork3/api/global-settings/check-timezone' => json => { timezone => 'Amrica/Chicago' })->status_is(200)
->json_is('/valid_timezone' => false);
+# Check to make sure that a student has appropriate access (ralph is a student in Arithmetic-course_id: 4)
+
+$t->post_ok('/webwork3/api/logout')->status_is(200);
+$t->post_ok('/webwork3/api/login' => json => { username => 'ralph', password => 'ralph' })->status_is(200);
+
+# A student should have access to the global settings;
+$t->get_ok('/webwork3/api/global-settings')->content_type_is('application/json;charset=UTF-8')->status_is(200);
+$t->get_ok('/webwork3/api/global-settings/1')->content_type_is('application/json;charset=UTF-8')->status_is(200);
+
+# A student should also have access to the course setting overrides for a course they are enrolled in.
+$t->get_ok('/webwork3/api/courses/4/settings')->content_type_is('application/json;charset=UTF-8')->status_is(200);
+
+# But not from a course they are not enrolled in
+$t->get_ok('/webwork3/api/courses/5/settings')->content_type_is('application/json;charset=UTF-8')->status_is(403);
+
+# A student shouldn't be able to update a course setting
+$t->put_ok(
+ "/webwork3/api/courses/4/settings/$reduced_scoring->{setting_id}" => json => {
+ value => 0.5
+ }
+)->status_is(403);
+
+# Nor delete a course setting
+$t->delete_ok("/webwork3/api/courses/4/settings/$reduced_scoring->{setting_id}")->status_is(403);
+
done_testing;
From 2d7409c10fce1b2de4f23163a70858d823fdb345 Mon Sep 17 00:00:00 2001
From: Peter Staab
Date: Thu, 11 Aug 2022 06:33:38 -0400
Subject: [PATCH 08/11] WIP: allow default settings to be defined in an
override file.
WIP: allow default settings to be defined in an override file.
---
.gitignore | 1 +
conf/{course_settings.yml => course_settings.dist.yml} | 0
lib/WeBWorK3/Controller/Settings.pm | 1 +
t/db/002_course_settings.t | 4 +++-
t/db/build_db.pl | 3 +++
tests/stores/settings.spec.ts | 4 +++-
6 files changed, 11 insertions(+), 2 deletions(-)
rename conf/{course_settings.yml => course_settings.dist.yml} (100%)
diff --git a/.gitignore b/.gitignore
index a758c2df..d94e236e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -31,6 +31,7 @@ conf/webwork3*.yml
conf/apache2/webwork3-apache2.conf
conf/apache2/webwork3.service
conf/apache2/renderer.service
+conf/course_settings.yml
# Devel::Cover
cover_db/
diff --git a/conf/course_settings.yml b/conf/course_settings.dist.yml
similarity index 100%
rename from conf/course_settings.yml
rename to conf/course_settings.dist.yml
diff --git a/lib/WeBWorK3/Controller/Settings.pm b/lib/WeBWorK3/Controller/Settings.pm
index 1120a4f5..81953523 100644
--- a/lib/WeBWorK3/Controller/Settings.pm
+++ b/lib/WeBWorK3/Controller/Settings.pm
@@ -72,6 +72,7 @@ sub checkTimeZone ($c) {
} catch {
$c->render(json => { valid_timezone => false });
};
+ return;
}
1;
diff --git a/t/db/002_course_settings.t b/t/db/002_course_settings.t
index 6c5ea73f..4b7b82b9 100644
--- a/t/db/002_course_settings.t
+++ b/t/db/002_course_settings.t
@@ -180,7 +180,9 @@ for my $setting (@$global_settings) {
# 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");
+my $settings_file = "$main::ww3_dir/conf/course_settings.yml";
+$settings_file = "$main::ww3_dir/conf/course_settings.dist.yml" unless -r $settings_file;
+my $global_settings_from_file = LoadFile($settings_file);
# sort each of these for comparison
my @global_settings = sort { $a->{setting_name} cmp $b->{setting_name} } @$global_settings;
diff --git a/t/db/build_db.pl b/t/db/build_db.pl
index aeff449e..f9a7eb15 100755
--- a/t/db/build_db.pl
+++ b/t/db/build_db.pl
@@ -85,7 +85,10 @@ sub addCourses {
sub addSettings {
say 'adding default settings' if $verbose;
+
my $settings_file = "$main::ww3_dir/conf/course_settings.yml";
+ $settings_file = "$main::ww3_dir/conf/course_settings.dist.yml" unless -r $settings_file;
+ say $settings_file;
die "The default settings file: '$settings_file' does not exist or is not readable"
unless -r $settings_file;
my $course_settings = LoadFile($settings_file);
diff --git a/tests/stores/settings.spec.ts b/tests/stores/settings.spec.ts
index 344968bd..c6094030 100644
--- a/tests/stores/settings.spec.ts
+++ b/tests/stores/settings.spec.ts
@@ -37,7 +37,9 @@ describe('Test the settings store', () => {
setActivePinia(pinia);
// Load the default settings
- const file = fs.readFileSync('conf/course_settings.yml', 'utf8');
+ let settings_file = 'conf/course_settings.yml';
+ if (!fs.existsSync(settings_file)) settings_file = 'conf/course_settings.dist.yml';
+ const file = fs.readFileSync(settings_file, 'utf8');
default_settings = parse(file) as ParseableGlobalSetting[];
// Fetch the course settings from the CSV file
From d3ee1bb68802623dfd76e144e0e07b5601fa8889 Mon Sep 17 00:00:00 2001
From: Peter Staab
Date: Thu, 11 Aug 2022 13:39:45 -0400
Subject: [PATCH 09/11] WIP: use non-neg ints in db for time_durations and
human readability on UI.
---
lib/DB/Utils.pm | 27 +++++++-
lib/WeBWorK3/Utils/Settings.pm | 2 +-
src/common/models/parsers.ts | 55 +++++++++++++++-
src/common/models/settings.ts | 4 +-
src/components/instructor/SingleSetting.vue | 19 +++++-
t/db/002_course_settings.t | 21 ++++++
t/db/build_db.pl | 17 ++++-
t/db/sample_data/course_settings.csv | 1 +
t/mojolicious/015_course_settings.t | 4 +-
tests/unit-tests/settings.spec.ts | 73 ++++++++++++++++++++-
10 files changed, 210 insertions(+), 13 deletions(-)
diff --git a/lib/DB/Utils.pm b/lib/DB/Utils.pm
index ee5786ed..155e2e1d 100644
--- a/lib/DB/Utils.pm
+++ b/lib/DB/Utils.pm
@@ -8,10 +8,10 @@ no warnings qw/experimental::signatures/;
require Exporter;
use base qw(Exporter);
our @EXPORT_OK = qw/getCourseInfo getUserInfo getSetInfo updateAllFields updatePermissions
- getPoolInfo getProblemInfo getPoolProblemInfo getSettingInfo removeLoginParams/;
+ getPoolInfo getProblemInfo getPoolProblemInfo getSettingInfo removeLoginParams
+ convertTimeDuration/;
use Clone qw/clone/;
-use List::Util qw/first/;
use Scalar::Util qw/reftype/;
use YAML::XS qw/LoadFile/;
@@ -193,4 +193,27 @@ sub updatePermissions ($ww3_conf, $role_perm_file) {
return;
}
+=pod
+=head2 convertTimeDuration
+
+This subroutine converts time durations stored as a string in human-readable format
+to a number of seconds.
+
+=cut
+
+sub convertTimeDuration ($time_duration) {
+ if ($time_duration =~ /^(\d+)\s(sec)s?$/) {
+ return $1;
+ } elsif ($time_duration =~ /^(\d+)\s(min(ute)?)s?$/) {
+ return $1*60;
+ } elsif ($time_duration =~ /^(\d+)\s(h(ou)?r)s?$/) {
+ return $1*60*60;
+ } elsif ($time_duration =~ /^(\d+)\s(day)s?$/) {
+ return $1*60*60*24;
+ } elsif ($time_duration =~ /^(\d+)\s(week)s?$/) {
+ return $1*60*60*24*7;
+ }
+}
+
+
1;
diff --git a/lib/WeBWorK3/Utils/Settings.pm b/lib/WeBWorK3/Utils/Settings.pm
index 3223f6ea..817aa01e 100644
--- a/lib/WeBWorK3/Utils/Settings.pm
+++ b/lib/WeBWorK3/Utils/Settings.pm
@@ -103,7 +103,7 @@ sub isValidSetting ($setting, $value = undef) {
} elsif ($setting->{type} eq 'time_duration') {
DB::Exception::InvalidCourseFieldType->throw(
message => qq/The variable $setting->{setting_name} has value $val and must be a time duration/)
- unless isTimeDuration($val);
+ unless $val =~ /^\d+$/;
} 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);
diff --git a/src/common/models/parsers.ts b/src/common/models/parsers.ts
index a746e811..7a04340c 100644
--- a/src/common/models/parsers.ts
+++ b/src/common/models/parsers.ts
@@ -99,7 +99,8 @@ 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;
+// This a regexp for time durations separated by commas.
+export const time_duration_re = /^(((\d+)\s(sec|second|min|minute|day|week|hr|hour)s?),?\s?)+$/i;
// Checking functions
@@ -110,7 +111,7 @@ 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
+// Parsing functions
export function parseNonNegInt(val: string | number) {
if (isNonNegInt(val)) return parseInt(`${val}`);
@@ -162,3 +163,53 @@ export function parseString(_value: string | number | boolean) {
return _value;
}
}
+
+/**
+ * Converts a time_duration type setting to a human-readable one.
+ * TODO: use localization for this.
+ * @params td - time duration in seconds.
+ */
+
+export const humanReadableTimeDuration = (td: number): string => {
+ const times = {
+ week: Math.floor(td / 604800),
+ day: Math.floor(td % 604800 / 86400),
+ hour: Math.floor(td % 86400 / 3600),
+ min: Math.floor(td % 3600 / 60),
+ sec: td % 60
+ };
+
+ return Object.entries(times).reduce((prev: string, [key, value]) => prev +
+ // if the time value is non zero, and there is already something in prev, add a comma
+ (prev != '' && value > 0 ? ', ' : '') +
+ // pluralize.
+ (value > 0 ? `${value} ${key}${value === 1 ? '' : 's'}` : ''), '');
+};
+
+/**
+ * Convert a time_duration as a string (possibility separated by commas) to a number of seconds.
+ */
+
+export const convertTimeDuration = (dur: string): number => {
+ const times = dur.split(/,\s/);
+ let time_duration = 0;
+ times.forEach(t => {
+ const match_sec = /^(\d+)\s(sec(ond)?)s?$/.exec(t);
+ const match_min = /^(\d+)\s(min(ute)?)s?$/.exec(t);
+ const match_hr = /^(\d+)\s(h(ou)?r)s?$/.exec(t);
+ const match_day = /^(\d+)\s(day)s?$/.exec(t);
+ const match_week = /^(\d+)\s(week)s?$/.exec(t);
+ if (match_sec) {
+ time_duration += parseInt(match_sec[0]);
+ } else if (match_min) {
+ time_duration += parseInt(match_min[0]) * 60;
+ } else if (match_hr) {
+ time_duration += parseInt(match_hr[0]) * 3600;
+ } else if (match_day) {
+ time_duration += parseInt(match_day[0]) * 86400;
+ } else if (match_week) {
+ time_duration += parseInt(match_week[0]) * 604800;
+ }
+ });
+ return time_duration;
+};
diff --git a/src/common/models/settings.ts b/src/common/models/settings.ts
index 74ea3224..cba3c18c 100644
--- a/src/common/models/settings.ts
+++ b/src/common/models/settings.ts
@@ -2,7 +2,7 @@
/* These are related to Course Settings */
import { Model } from '.';
-import { isTime, isTimeDuration } from './parsers';
+import { isNonNegInt, isTime } from './parsers';
export enum SettingType {
int = 'int',
@@ -132,7 +132,7 @@ const validSettingValue = (setting: GlobalSetting | CourseSetting, v: SettingVal
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.time_duration: return typeof(v) === 'number' && isNonNegInt(v);
case SettingType.timezone: return typeof(v) === 'string';
default: return false;
diff --git a/src/components/instructor/SingleSetting.vue b/src/components/instructor/SingleSetting.vue
index deb856e4..8b197088 100644
--- a/src/components/instructor/SingleSetting.vue
+++ b/src/components/instructor/SingleSetting.vue
@@ -28,7 +28,7 @@
@@ -48,7 +48,7 @@ import InputWithBlur from 'src/components/common/InputWithBlur.vue';
import { logger } from 'src/boot/logger';
import { useSettingsStore } from 'src/stores/settings';
-import { isTimeDuration } from 'src/common/models/parsers';
+import { convertTimeDuration, humanReadableTimeDuration, isTimeDuration } from 'src/common/models/parsers';
import { api } from 'src/boot/axios';
const props = defineProps<{
@@ -69,6 +69,9 @@ const option_value = ref({ value: '', label: '' });
const multilist_value = ref([]);
const options = ref([]);
+// Needed for time_duration
+const time_duration_value = ref('');
+
// Determine if the help in settings.doc is shown.
const show_help = ref(false);
@@ -77,6 +80,11 @@ const checkTimeDuration = (val: string) => isTimeDuration(val) || 'This must be
const valid_timezone = ref(true);
+// Convert the time_duration to human readable format:
+if (course_setting.value.type === 'time_duration') {
+ time_duration_value.value = humanReadableTimeDuration(course_setting.value.value as number);
+}
+
// These are for type list/multilist
if (course_setting.value.options) {
options.value = course_setting.value.options.map((opt: string | OptionType) =>
@@ -137,6 +145,13 @@ watch(() => multilist_value.value, async () => {
await updateCourseSetting();
}
});
+
+watch(() => time_duration_value.value, async () => {
+ if (time_duration_value.value) {
+ course_setting.value.value = convertTimeDuration(time_duration_value.value);
+ await updateCourseSetting();
+ }
+});