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_defaults.yml b/conf/course_settings.dist.yml
similarity index 72%
rename from conf/course_defaults.yml
rename to conf/course_settings.dist.yml
index 06df7c77..73d305c5 100644
--- a/conf/course_defaults.yml
+++ b/conf/course_settings.dist.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: ''
+ type: boolean
+ default_value: false
-
- 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/conf/permissions.dist.yml b/conf/permissions.dist.yml
index cd310b4a..d9d61cea 100644
--- a/conf/permissions.dist.yml
+++ b/conf/permissions.dist.yml
@@ -163,10 +163,14 @@ db_permissions:
allowed_roles: ['course_admin', 'instructor']
Settings:
getDefaultCourseSettings:
- allowed_roles: ['*']
+ allowed_roles: ['course_admin', 'instructor','student']
getCourseSettings:
- allowed_roles: ['*']
- updateCourseSettings:
+ allowed_roles: ['course_admin', 'instructor','student']
+ getCourseSetting:
+ allowed_roles: ['course_admin', 'instructor','student']
+ 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/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..7f59f452
--- /dev/null
+++ b/lib/DB/Schema/Result/CourseSetting.pm
@@ -0,0 +1,78 @@
+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 of the global setting that the given setting is related to (foreign key)
+
+=item *
+
+C: the value of the setting as a JSON so different types of data can be stored.
+
+=back
+
+=cut
+
+__PACKAGE__->table('course_setting');
+
+__PACKAGE__->load_components(qw/FilterColumn Core/);
+
+__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,
+ },
+ global_setting_id => {
+ data_type => 'integer',
+ size => 16,
+ },
+ value => {
+ data_type => 'text',
+ is_nullable => 1
+ },
+);
+
+__PACKAGE__->filter_column(
+ value => {
+ filter_to_storage => sub {
+ return JSON::MaybeXS->new({ utf8 => 1, allow_nonref => 1 })->encode($_[1] // '');
+ },
+ filter_from_storage => sub {
+ return JSON::MaybeXS->new({ utf8 => 1, allow_nonref => 1 })->decode($_[1] // '');
+ }
+ }
+);
+
+__PACKAGE__->set_primary_key('course_setting_id');
+
+__PACKAGE__->add_unique_constraint([qw/course_id global_setting_id/]);
+
+__PACKAGE__->belongs_to(course => 'DB::Schema::Result::Course', 'course_id');
+
+__PACKAGE__->belongs_to(global_setting => 'DB::Schema::Result::GlobalSetting', 'global_setting_id');
+
+1;
diff --git a/lib/DB/Schema/Result/CourseSettings.pm b/lib/DB/Schema/Result/CourseSettings.pm
deleted file mode 100644
index eacdb07e..00000000
--- a/lib/DB/Schema/Result/CourseSettings.pm
+++ /dev/null
@@ -1,118 +0,0 @@
-package DB::Schema::Result::CourseSettings;
-use base qw/DBIx::Class::Core/;
-use strict;
-use warnings;
-
-=head1 DESCRIPTION
-
-This is the database schema for a CourseSetting.
-
-=head2 fields
-
-=over
-
-=item *
-
-C: database id (autoincrement integer)
-
-=item *
-
-C: database id of the course for the setting (foreign key)
-
-=item *
-
-C: a JSON object of general settings
-
-=item *
-
-C: a JSON object of optional settings
-
-=item *
-
-C: a JSON object that stores settings on the problem set level
-
-=item *
-
-C: a JSON object that stores settings on the problem level
-
-=item *
-
-C: a JSON object that stores settings for permissions
-
-=item *
-
-C: a JSON object that stores email settings
-
-=back
-
-=cut
-
-__PACKAGE__->table('course_settings');
-
-__PACKAGE__->load_components('InflateColumn::Serializer', 'Core');
-
-__PACKAGE__->add_columns(
- course_settings_id => {
- data_type => 'integer',
- size => 16,
- is_auto_increment => 1,
- },
- course_id => {
- data_type => 'integer',
- size => 16,
- },
- general => {
- data_type => 'text',
- size => 256,
- default_value => '{}',
- retrieve_on_insert => 1,
- serializer_class => 'JSON',
- serializer_options => { utf8 => 1 }
- },
- optional => {
- data_type => 'text',
- size => 256,
- default_value => '{}',
- retrieve_on_insert => 1,
- serializer_class => 'JSON',
- serializer_options => { utf8 => 1 }
- },
- problem_set => {
- data_type => 'text',
- size => 256,
- default_value => '{}',
- retrieve_on_insert => 1,
- serializer_class => 'JSON',
- serializer_options => { utf8 => 1 }
- },
- problem => {
- data_type => 'text',
- size => 256,
- default_value => '{}',
- retrieve_on_insert => 1,
- serializer_class => 'JSON',
- serializer_options => { utf8 => 1 }
- },
- permissions => {
- data_type => 'text',
- size => 256,
- default_value => '{}',
- retrieve_on_insert => 1,
- serializer_class => 'JSON',
- serializer_options => { utf8 => 1 }
- },
- email => {
- data_type => 'text',
- size => 256,
- default_value => '{}',
- retrieve_on_insert => 1,
- serializer_class => 'JSON',
- serializer_options => { utf8 => 1 }
- }
-);
-
-__PACKAGE__->set_primary_key('course_settings_id');
-
-__PACKAGE__->belongs_to(course => 'DB::Schema::Result::Course', 'course_id');
-
-1;
diff --git a/lib/DB/Schema/Result/GlobalSetting.pm b/lib/DB/Schema/Result/GlobalSetting.pm
new file mode 100644
index 00000000..3b8e201f
--- /dev/null
+++ b/lib/DB/Schema/Result/GlobalSetting.pm
@@ -0,0 +1,118 @@
+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 array 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(qw/InflateColumn::Serializer FilterColumn Core/);
+
+__PACKAGE__->add_columns(
+ global_setting_id => {
+ data_type => 'integer',
+ size => 16,
+ is_auto_increment => 1,
+ },
+ setting_name => {
+ data_type => 'varchar',
+ size => 256,
+ },
+ default_value => {
+ data_type => 'text',
+ default_value => '""'
+ },
+ description => {
+ data_type => 'text',
+ default_value => '',
+ },
+ doc => {
+ data_type => 'text',
+ is_nullable => 1,
+ },
+ type => {
+ data_type => 'varchar',
+ size => 64,
+ default_value => '',
+ },
+ options => {
+ data_type => 'text',
+ is_nullable => 1,
+ serializer_class => 'JSON',
+ serializer_options => { utf8 => 1 }
+ },
+ category => {
+ data_type => 'varchar',
+ size => 64,
+ default_value => ''
+ },
+ subcategory => {
+ data_type => 'varchar',
+ size => 64,
+ is_nullable => 1
+ }
+);
+use Data::Dumper;
+__PACKAGE__->filter_column(
+ default_value => {
+ filter_to_storage => sub {
+ return JSON::MaybeXS->new({ utf8 => 1, allow_nonref => 1 })->encode($_[1] // '');
+ },
+ filter_from_storage => sub {
+ return JSON::MaybeXS->new({ utf8 => 1, allow_nonref => 1 })->decode($_[1] // '');
+ }
+ }
+);
+
+__PACKAGE__->set_primary_key('global_setting_id');
+
+__PACKAGE__->has_many(course_settings => 'DB::Schema::Result::CourseSetting', 'global_setting_id');
+
+1;
diff --git a/lib/DB/Schema/ResultSet/Course.pm b/lib/DB/Schema/ResultSet/Course.pm
index 08294bc0..7059c356 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,72 @@ 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;
+ 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 global_setting_id $setting_info->{global_setting_id} is not found")
+ unless $global_setting;
+ return $global_setting if $args{as_result_set};
+ return { $global_setting->get_inflated_columns };
+}
+
=head2 getCourseSettings
This gets the Course Settings for a course
@@ -262,7 +329,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 +340,151 @@ 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};
+ return $args{merged}
+ ? [ map { { $_->get_inflated_columns, $_->global_setting->get_inflated_columns } } @settings_from_db ]
+ : [ map { { $_->get_inflated_columns } } @settings_from_db ];
+}
+
+=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 global setting with name: '" . $args{info}{setting_name} . "' is not defined.")
+ unless defined($global_setting);
+
+ my $course = $self->getCourse(info => getCourseInfo($args{info}), as_result_set => 1);
+ my $setting = $course->course_settings->find({ global_setting_id => $global_setting->global_setting_id });
+
+ DB::Exception::SettingNotFound->throw(
+ message => 'The course setting with '
+ . (
+ $args{info}{setting_name} ? " name: '$args{info}{setting_name}'"
+ : "global_setting_id of $args{info}{global_setting_id} is not found in the course "
+ )
+ . (
+ $args{info}{course_name} ? ("with name '" . $args{info}{course_name} . "'")
+ : "with course_id of $args{info}{course_id}"
+ )
+ ) unless defined($setting);
+
+ return $setting if $args{as_result_set};
+ return $args{merged}
+ ? { $setting->get_inflated_columns, $setting->global_setting->get_inflated_columns }
+ : { $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
- my $current_settings = { $course->course_settings->get_inflated_columns };
- my $updated_settings = mergeCourseSettings($current_settings, $args{settings});
+A single course setting as either a hashref or a C object.
+
+=cut
+
+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({ global_setting_id => $global_setting->{global_setting_id} });
+
+ my $params = {
+ course_id => $course->course_id,
+ global_setting_id => $global_setting->{global_setting_id},
+ value => $args{params}{value} =~ /^$/ ? undef : $args{params}{value}
+ };
+
+ isValidSetting($global_setting, $params->{value});
+
+ my $up_setting =
+ defined($course_setting) ? $course_setting->update($params) : $course->add_to_course_settings($params);
+
+ return $up_setting if $args{as_result_set};
+ return ($args{merged})
+ ? { $up_setting->get_inflated_columns, $up_setting->global_setting->get_inflated_columns }
+ : { $up_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 $cs = $course->course_settings->update($updated_settings);
- return mergeCourseSettings(getDefaultCourseValues(), { $cs->get_inflated_columns });
+sub deleteCourseSetting ($self, %args) {
+ $self->getCourseSetting(info => $args{info}, as_result_set => 1)->delete;
+ return;
}
1;
diff --git a/lib/DB/Utils.pm b/lib/DB/Utils.pm
index 04e037d9..2739cfa8 100644
--- a/lib/DB/Utils.pm
+++ b/lib/DB/Utils.pm
@@ -7,11 +7,11 @@ no warnings qw/experimental::signatures/;
require Exporter;
use base qw/Exporter/;
-our @EXPORT_OK = qw/getCourseInfo getUserInfo getSetInfo updateAllFields
- getPoolInfo getProblemInfo getPoolProblemInfo removeLoginParams updatePermissions/;
+our @EXPORT_OK = qw/getCourseInfo getUserInfo getSetInfo updateAllFields updatePermissions
+ getPoolInfo getProblemInfo getPoolProblemInfo getSettingInfo removeLoginParams
+ convertTimeDuration humanReadableTimeDuration/;
use Clone qw/clone/;
-use List::Util qw/first/;
use Scalar::Util qw/reftype/;
use YAML::XS qw/LoadFile/;
@@ -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 global_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.
@@ -189,4 +193,55 @@ 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;
+ } else {
+ return 0;
+ }
+}
+
+=pod
+=head2
+
+This coverts a number of seconds to a human-readable format
+
+=cut
+
+sub humanReadableTimeDuration ($td) {
+ my $times = {
+ week => int($td / 604800),
+ day => int($td % 604800 / 86400),
+ hour => int($td % 86400 / 3600),
+ min => int($td % 3600 / 60),
+ sec => $td % 60
+ };
+
+ my $time_duration = '';
+ # Order is important so the keys are defined.
+ for (qw/week day hour min sec/) {
+ my $val = $times->{$_};
+ $time_duration .= ($time_duration ne '' && $val ? ', ' : '')
+ # pluralize for more than 1.
+ . ($val > 0 ? "$val $_" . ($val == 1 ? '' : 's') : '');
+ }
+ return $time_duration;
+}
+
1;
diff --git a/lib/WeBWorK3.pm b/lib/WeBWorK3.pm
index cb8163a0..1be8432d 100644
--- a/lib/WeBWorK3.pm
+++ b/lib/WeBWorK3.pm
@@ -217,9 +217,14 @@ sub problemRoutes ($app, $course_routes) {
}
sub settingsRoutes ($app, $course_routes) {
- $course_routes->get('/default_settings')->to('Settings#getDefaultCourseSettings');
+ my $global_settings = $app->routes->any('/webwork3/api/global-settings')->requires(authenticated => 1);
+ $global_settings->get('/')->to('Settings#getGlobalSettings');
+ $global_settings->get('/:global_setting_id')->to('Settings#getGlobalSetting');
+ $global_settings->post('/check-timezone')->to('Settings#checkTimeZone');
$course_routes->get('/settings')->to('Settings#getCourseSettings');
- $course_routes->put('/setting')->to('Settings#updateCourseSetting');
+ $course_routes->get('/settings/:global_setting_id')->to('Settings#getCourseSetting');
+ $course_routes->put('/settings/:global_setting_id')->to('Settings#updateCourseSetting');
+ $course_routes->delete('/settings/:global_setting_id')->to('Settings#deleteCourseSetting');
return;
}
diff --git a/lib/WeBWorK3/Controller/Settings.pm b/lib/WeBWorK3/Controller/Settings.pm
index 7e959477..3772e459 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
@@ -10,40 +14,81 @@ 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/;
+sub getGlobalSettings ($c) {
+ $c->render(json => $c->schema->resultset('Course')->getGlobalSettings());
+ return;
+}
-# This reads the default settings from a file.
+sub getGlobalSetting ($c) {
+ $c->render(
+ json => $c->schema->resultset('Course')->getGlobalSetting(
+ info => {
+ global_setting_id => int($c->param('global_setting_id'))
+ }
+ )
+ );
+ 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 getCourseSettings ($c) {
+ $c->render(
+ json => $c->schema->resultset('Course')->getCourseSettings(
+ info => {
+ course_id => int($c->param('course_id')),
+ },
+ merged => 1
+ )
+ );
return;
}
-sub getCourseSettings ($self) {
- my $course_settings = $self->schema->resultset('Course')->getCourseSettings(
+sub getCourseSetting ($c) {
+ $c->render(
+ json => $c->schema->resultset('Course')->getCourseSetting(
+ info => {
+ course_id => int($c->param('course_id')),
+ global_setting_id => int($c->param('global_setting_id'))
+ }
+ )
+ );
+ return;
+}
+
+sub updateCourseSetting ($c) {
+ $c->render(
+ json => $c->schema->resultset('Course')->updateCourseSetting(
+ info => {
+ course_id => $c->param('course_id'),
+ global_setting_id => $c->param('global_setting_id')
+ },
+ params => $c->req->json
+ )
+ );
+ return;
+}
+
+sub deleteCourseSetting ($c) {
+ $c->schema->resultset('Course')->deleteCourseSetting(
info => {
- course_id => int($self->param('course_id')),
+ course_id => $c->param('course_id'),
+ global_setting_id => $c->param('global_setting_id')
}
);
- # 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 => { message => 'The course setting was successfully deleted.' });
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);
+# This is useful for checking if the string passed in is a valid timezone, instead of
+# having the UI download all possible timezones (bloated).
+
+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 });
+ };
return;
}
diff --git a/lib/WeBWorK3/Utils/Settings.pm b/lib/WeBWorK3/Utils/Settings.pm
index 3756fc5e..f2442bdd 100644
--- a/lib/WeBWorK3/Utils/Settings.pm
+++ b/lib/WeBWorK3/Utils/Settings.pm
@@ -9,21 +9,20 @@ 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/;
+our @EXPORT_OK = qw/isValidSetting mergeCourseSettings isInteger isTimeString isTimeDuration isDecimal/;
-use Exception::Class qw(
+use Exception::Class qw/
DB::Exception::UndefinedCourseField
- DB::Exception::InvalidCourseField
+ DB::Exception::RequiredCourseField
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/;
+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
@@ -36,140 +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;
-}
-
-=pod
-
-checkSettingsConfFile loads the course settings configuration file and checks for validity
-
-=cut
-
-sub validateSettingsConfFile () {
- #my @all_settings = getDefaultCourseSettings();
- for my $setting (@{ getDefaultCourseSettings() }) {
- validateSettingConfig($setting);
- }
- return 1;
-}
-
-=pod
-
-isValidCourseSettings checks if the course settings are valid including
-
-=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;
-}
+my @valid_types = qw/text list multilist boolean int decimal time date_time time_duration timezone/;
=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
+=head2 isValidSetting
-This checks the configuration for a single setting is valid. This includes
+This checks if the setting given the type, value and list of options (if needed). 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
=item Checks that the default value is appropriate for the type
@@ -178,68 +53,75 @@ 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 isValidSetting ($setting, $value = undef) {
+ DB::Exception::ParametersNeeded->throw(message => 'The field \'type\' must be defined for the setting')
+ unless defined $setting->{type};
-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});
-
- # 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}")
- if scalar(@fields) == 0;
- }
+ # If $value is not passed in, use the default_value for the setting
+ my $val = $value // $setting->{default_value};
# Check that each of the required fields is present in the 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}")
+ DB::Exception::RequiredCourseField->throw(
+ 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') {
+ DB::Exception::InvalidCourseFieldType->throw(
+ message => "The variable $setting->{setting_name} has value $val and must be a boolean.")
+ unless JSON::PP::is_bool($val);
+ } 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 => "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 => "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 => "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 => "The variable $setting->{setting_name} has value $val and must be a time duration.")
+ 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);
+ } else {
+ DB::Exception::InvalidCourseFieldType->throw(message => "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,32 +130,58 @@ 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(@$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;
+ return 1;
+}
+
+# Test for an integer.
sub isInteger ($in) {
- return $in =~ /^-?\d+$/;
+ return defined($in) && $in =~ /^-?\d+$/;
}
# Test for a 24-hour time string
sub isTimeString ($in) {
- return $in =~ /(^0?\d:[0-5]\d$)|(^1\d:[0-5]\d$)|(^2[0-3]:[0-5]\d$)/;
+ return defined($in) && $in =~ /(^0?\d:[0-5]\d$)|(^1\d:[0-5]\d$)|(^2[0-3]:[0-5]\d$)/;
}
# Test for a time duration which can have the unit: sec, min, day, week, hr, hour
sub isTimeDuration ($in) {
- return $in =~ /^(\d+)\s(sec|second|min|minute|day|week|hr|hour)s?$/i;
+ return defined($in) && $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+$)/;
+ return defined($in) && $in =~ /(^-?\d+(\.\d+)?$)|(^-?\.\d+$)/;
}
1;
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..f4dd7ffa 100644
--- a/src/common/models/parsers.ts
+++ b/src/common/models/parsers.ts
@@ -97,6 +97,10 @@ 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
+// 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
@@ -104,8 +108,10 @@ 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
+// Parsing functions
export function parseNonNegInt(val: string | number) {
if (isNonNegInt(val)) return parseInt(`${val}`);
@@ -157,3 +163,52 @@ export function parseString(_value: string | number | boolean) {
return _value;
}
}
+
+/**
+ * Converts a time_duration type setting to a human-readable one.
+ * @params td - time duration in seconds.
+ */
+// TODO: use localization for this.
+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 c048a907..6b737e14 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 { isNonNegInt, isTime } 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 {
+ global_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 _global_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 = ['global_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.global_setting_id != undefined) this.global_setting_id = params.global_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 global_setting_id() { return this._global_setting_id; }
+ set global_setting_id(v: number) { this._global_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) === 'number' && isNonNegInt(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;
+ global_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 _global_setting_id = 0;
+ private _value?: SettingValueType;
+
+ constructor(params: ParseableDBCourseSetting = {}) {
+ super();
+ this.set(params);
+ }
+
+ static ALL_FIELDS = ['course_setting_id', 'course_id', 'global_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.global_setting_id != undefined) this.global_setting_id = params.global_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 global_setting_id() { return this._global_setting_id; }
+ set global_setting_id(v: number) { this._global_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 {
+ global_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 _global_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 = ['global_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.global_setting_id != undefined) this.global_setting_id = params.global_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 global_setting_id() { return this._global_setting_id; }
+ set global_setting_id(v: number) { this._global_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..65a0588a 100644
--- a/src/components/instructor/SingleSetting.vue
+++ b/src/components/instructor/SingleSetting.vue
@@ -1,78 +1,164 @@
- {{ setting.doc }}
-
-
- {{ setting.doc2 }}
-
-
+ | {{ setting.description }}
+
|
+
+
+
+
+
-
-
-
-
+
+
|
+ |
-
+
+
diff --git a/src/layouts/MenuBar.vue b/src/layouts/MenuBar.vue
index 4aae826d..f9639126 100644
--- a/src/layouts/MenuBar.vue
+++ b/src/layouts/MenuBar.vue
@@ -73,8 +73,6 @@ import { endSession } from 'src/common/api-requests/session';
import { useSessionStore } from 'src/stores/session';
import { useSettingsStore } from 'src/stores/settings';
-import type { CourseSettingInfo } from 'src/common/models/settings';
-
defineEmits(['toggle-menu', 'toggle-sidebar']);
const session = useSessionStore();
const settings = useSettingsStore();
@@ -101,9 +99,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/Instructor.vue b/src/pages/instructor/Instructor.vue
index dca7a2da..9720b902 100644
--- a/src/pages/instructor/Instructor.vue
+++ b/src/pages/instructor/Instructor.vue
@@ -39,7 +39,7 @@ if (course_id !== session.course.course_id) {
await users.fetchGlobalCourseUsers(course_id);
await users.fetchCourseUsers(course_id);
await problem_sets.fetchProblemSets(course_id);
-await settings.fetchDefaultSettings(course_id)
+await settings.fetchGlobalSettings()
.then(() => settings.fetchCourseSettings(course_id))
.then(() => void setI18nLanguage(settings.getCourseSetting('language').value as string))
.catch((err) => logger.error(`${JSON.stringify(err)}`));
diff --git a/src/pages/instructor/Settings.vue b/src/pages/instructor/Settings.vue
index ccd3432c..ba0ed7e9 100644
--- a/src/pages/instructor/Settings.vue
+++ b/src/pages/instructor/Settings.vue
@@ -19,8 +19,8 @@
-
-
+
+
@@ -30,35 +30,18 @@
-
diff --git a/src/stores/settings.ts b/src/stores/settings.ts
index 94dc6dc6..9e19ef4d 100644
--- a/src/stores/settings.ts
+++ b/src/stores/settings.ts
@@ -2,79 +2,121 @@ 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, SettingValueType } 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.global_setting_id === global_setting.global_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.global_setting_id === global_setting?.global_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 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(): {(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(): { (category_name: string): CourseSetting[] } {
+ return (category_name: string): CourseSetting[] => {
+ return this.course_settings.filter(setting => setting.category === category_name);
+ };
+ }
+ },
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.global_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.global_setting_id === updated_setting.global_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.global_setting_id === updated_setting.global_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.global_setting_id == course_setting.global_setting_id);
+ if (i < 0) {
+ throw `The setting with name: '${course_setting.setting_name}' has not been defined for this course.`;
}
- return updated_setting;
+ await api.delete(`/courses/${course_id}/settings/${course_setting.global_setting_id}`);
+ this.db_course_settings.splice(i, 1);
},
+ /**
+ * 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..555847fa 100644
--- a/t/db/002_course_settings.t
+++ b/t/db/002_course_settings.t
@@ -16,15 +16,16 @@ 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::Utils qw/convertTimeDuration humanReadableTimeDuration/;
+use TestUtils qw/removeIDs loadCSV/;
# Load the database
my $config_file = "$main::ww3_dir/conf/webwork3-test.yml";
@@ -39,11 +40,11 @@ my $schema = DB::Schema->connect(
# Test for various types
-ok(isInteger(0), 'check type: integer');
-ok(isInteger(100), 'check type: integer');
-ok(isInteger(-30), 'check type: integer');
-ok(!isInteger(0.5), 'check type: not an integer');
-ok(!isInteger(-2.5), 'check type: not an integer');
+ok(isInteger(0), 'check type: 0 is an integer');
+ok(isInteger(100), 'check type: 100 is an integer');
+ok(isInteger(-30), 'check type: -30 is an integer');
+ok(!isInteger(0.5), 'check type: 0.5 is not an integer');
+ok(!isInteger(-2.5), 'check type: -2.5 not an integer');
ok(isTimeString('1:59'), 'check type: 24-hour time string');
ok(isTimeString('01:59'), 'check type: 24-hour time string');
@@ -77,64 +78,93 @@ 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');
+# Check that time duration conversion works as intended.
+is(convertTimeDuration('15 sec'), 15, 'convertTimeDuration: 15 sec');
+is(convertTimeDuration('15 secs'), 15, 'convertTimeDuration: 15 secs');
-# TODO: Test to make sure that all of the checks for the course configurations work.
+is(convertTimeDuration('15 min'), 900, 'convertTimeDuration: 15 min');
+is(convertTimeDuration('15 mins'), 900, 'convertTimeDuration: 15 mins');
+is(convertTimeDuration('15 minute'), 900, 'convertTimeDuration: 15 minute');
+is(convertTimeDuration('15 minutes'), 900, 'convertTimeDuration: 15 minutes');
-my $default_course_settings = getDefaultCourseSettings();
+is(convertTimeDuration('6 hour'), 21600, 'convertTimeDuration: 6 hour');
+is(convertTimeDuration('6 hours'), 21600, 'convertTimeDuration: 6 hours');
+is(convertTimeDuration('6 hr'), 21600, 'convertTimeDuration: 6 hr');
+is(convertTimeDuration('6 hrs'), 21600, 'convertTimeDuration: 6 hrs');
-# 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
-};
-is(validateSettingConfig($valid_setting), 1, 'course setting: valid setting');
+is(convertTimeDuration('3 day'), 259200, 'convertTimeDuration: 3 day');
+is(convertTimeDuration('3 days'), 259200, 'convertTimeDuration: 3 days');
-# Check various parts of the setting.
+is(convertTimeDuration('2 week'), 1209600, 'convertTimeDuration: 2 week');
+is(convertTimeDuration('2 weeks'), 1209600, 'convertTimeDuration: 2 weeks');
-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';
+# Check that the integer to time_duration string is working
+is(humanReadableTimeDuration(15), '15 secs', 'humanReadableTimeDuration(15)');
+is(humanReadableTimeDuration(45), '45 secs', 'humanReadableTimeDuration(45)');
-throws_ok {
- validateSettingConfig({
- var => 'my_setting',
- doc3 => 'this is a setting',
- type => 'integer',
- category => 'general',
- default => 0
- })
-}
-'DB::Exception::InvalidCourseField', 'course setting: course setting with illegal field';
+is(humanReadableTimeDuration(1 * 60), '1 min', 'humanReadableTimeDuration(1*60)');
+is(humanReadableTimeDuration(10 * 60), '10 mins', 'humanReadableTimeDuration(10*60)');
+is(humanReadableTimeDuration(10 * 60 + 45), '10 mins, 45 secs', 'humanReadableTimeDuration(10*60)');
+
+is(humanReadableTimeDuration(1 * 60 * 60), '1 hour', 'humanReadableTimeDuration(1*60*60)');
+is(humanReadableTimeDuration(5 * 60 * 60), '5 hours', 'humanReadableTimeDuration(5*60*60)');
+is(humanReadableTimeDuration(5 * 60 * 60 + 30 * 60), '5 hours, 30 mins', 'humanReadableTimeDuration(5*60*60+3*60)');
+
+is(humanReadableTimeDuration(1 * 24 * 60 * 60), '1 day', 'humanReadableTimeDuration(1*24*60*60)');
+is(humanReadableTimeDuration(4 * 24 * 60 * 60), '4 days', 'humanReadableTimeDuration(4*24*60*60)');
+is(
+ humanReadableTimeDuration(4 * 24 * 60 * 60 + 13 * 60 * 60),
+ '4 days, 13 hours',
+ 'humanReadableTimeDuration(4*24*60*60+13*60*60)'
+);
+
+is(humanReadableTimeDuration(1 * 7 * 24 * 60 * 60), '1 week', 'humanReadableTimeDuration(1*7*24*60*60)');
+is(humanReadableTimeDuration(3 * 7 * 24 * 60 * 60), '3 weeks', 'humanReadableTimeDuration(3*7*24*60*60)');
+is(
+ humanReadableTimeDuration(9 * 7 * 24 * 60 * 60 + 1 * 24 * 60 * 60),
+ '9 weeks, 1 day',
+ 'humanReadableTimeDuration(9*7*24*60*60 + 1*24*60*60)'
+);
+
+# Check that each of the given course_setting types are both valid and invalid.
+my $valid_setting = {
+ setting_name => 'my_setting',
+ description => 'this is a setting',
+ type => 'int',
+ category => 'general',
+ default_value => 0
+};
+ok(isValidSetting($valid_setting), 'course setting: valid setting');
+
+ok(
+ isValidSetting({
+ setting_name => 'my_setting',
+ description => 'the description',
+ doc3 => 'this is a setting',
+ type => 'int',
+ category => 'general',
+ default_value => 0
+ }),
+ 'course setting: course setting with invalid field is ignored.'
+);
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';
+'DB::Exception::RequiredCourseField', '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 +172,270 @@ 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 $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;
+my @global_settings_from_file = sort { $a->{setting_name} cmp $b->{setting_name} } @$global_settings_from_file;
+
+# 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");
+}
+
+# convert the database settings of type time_duration to human readable
+for (@global_settings) {
+ $_->{default_value} = humanReadableTimeDuration($_->{default_value}) if $_->{type} eq 'time_duration';
+}
+
+is_deeply(\@global_settings, \@global_settings_from_file,
+ 'default settings: db values are the same as the file values.');
# 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} });
+
+# 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.');
+
+# Compare the course settings with the file.
-is_deeply($course_settings, $default_course_values, 'course settings: default course_settings');
+# 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;
+
+my $arith_settings_from_db = $course_rs->getCourseSettings(info => { course_name => 'Arithmetic' }, merged => 1);
+for my $setting (@$arith_settings_from_db) {
+ removeIDs($setting);
+}
-# 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
+# Only compare the name/value of the settings and convert and time_durations
+for my $setting (@$arith_settings_from_db) {
+ $setting = {
+ setting_name => $setting->{setting_name},
+ value => $setting->{type} eq 'time_duration'
+ ? humanReadableTimeDuration($setting->{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' }
);
-my $current_course_values = mergeCourseSettings($default_course_values, $updated_general_setting);
-is_deeply($current_course_values, $updated_course_settings, 'course_settings: updated general setting');
+is(
+ 'This is my new course description',
+ $updated_setting->{value},
+ 'updateCourseSetting: successfully update a course setting'
+);
-# Update another general setting
-$updated_general_setting = { general => { hardcopy_theme => 'One Column' } };
+# Check that updating a boolean is a JSON boolean
-$updated_course_settings = $course_rs->updateCourseSettings(
- info => $new_course_info,
- settings => $updated_general_setting
+my $boolean_setting = $course_rs->updateCourseSetting(
+ info => {
+ course_id => $new_course->{course_id},
+ setting_name => 'enable_conditional_release'
+ },
+ params => { value => true }
);
-$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($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');
+
+# check that updating with an invalid field is ignored.
+
+my $setting_with_invalid_field = $course_rs->updateCourseSetting(
+ info => {
+ course_id => $new_course->{course_id},
+ setting_name => 'enable_conditional_release'
+ },
+ params => { non_existent_field => 11, value => true }
+);
+
+is_deeply($setting_with_invalid_field, $boolean_setting,
+ 'updateCourseSetting: ensure that passing an invalid setting field is ignored.');
+
+my $fetched_setting = $course_rs->getCourseSetting(
+ info => {
+ course_id => $new_course->{course_id},
+ setting_name => 'course_description'
+ }
+);
+
+is($fetched_setting->{value}, $updated_setting->{value}, 'getCourseSetting: fetch a single course setting');
+
+# Make sure invalid course settings throw exceptions.
+
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 => 'non_existant_setting'
+ },
+ params => { value => 3 }
+ );
}
-'DB::Exception::UndefinedCourseField', 'course settings: undefined course_setting field';
+'DB::Exception::SettingNotFound', 'updateCourseSetting: try to update a non-existant course 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 => 'language'
+ },
+ params => { value => 'Klingon' }
+ );
+}
+'DB::Exception::InvalidCourseFieldType', 'updateCourseSetting: try to update the list 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 => 'session_key_timeout'
+ },
+ params => { value => '45 years' }
+ );
+}
+'DB::Exception::InvalidCourseFieldType', 'updateCourseSetting: try to update a time_duration setting.';
-# TODO: Make sure that an invalid email list setting throws an exception
+throws_ok {
+ $course_rs->updateCourseSetting(
+ info => {
+ course_id => $new_course->{course_id},
+ setting_name => 'enable_reduced_scoring'
+ },
+ params => { value => 'true' }
+ );
+}
+'DB::Exception::InvalidCourseFieldType', 'updateCourseSetting: try to update a boolean setting.';
+
+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.';
+
+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
+
+$course_rs->deleteCourseSetting(
+ info => {
+ course_name => 'New Course',
+ setting_name => 'course_description'
+ }
+);
+
+# Then check that the setting was deleted.
+throws_ok {
+ $course_rs->getCourseSetting(info => { course_name => 'New Course', setting_name => 'course_description' });
+}
+'DB::Exception::SettingNotFound', 'deleteCourseSetting: the setting was successfully deleted.';
+
+my $deleted_setting2 = $course_rs->deleteCourseSetting(
+ info => {
+ course_name => 'New Course',
+ setting_name => 'enable_conditional_release'
+ }
+);
+
+throws_ok {
+ $course_rs->getCourseSetting(info => { course_name => 'New Course', setting_name => 'enable_conditional_release' });
+}
+'DB::Exception::SettingNotFound', '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 03bd1146..46142eb8 100755
--- a/t/db/build_db.pl
+++ b/t/db/build_db.pl
@@ -22,7 +22,7 @@ BEGIN
use Mojo::JSON qw/true false/;
use DB::Schema;
-use DB::Utils qw/updatePermissions/;
+use DB::Utils qw/updatePermissions convertTimeDuration/;
use TestUtils qw/loadCSV/;
my $verbose = 1;
@@ -30,6 +30,9 @@ BEGIN
# 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);
+
+# 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
@@ -54,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');
@@ -73,16 +77,48 @@ 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} };
+ $course_rs->create($course);
+ }
+ return;
+}
+use Data::Dumper;
+
+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;
+ 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) {
+
+ # If the setting is a time_duration, store it as a number of seconds in the db.
+ if ($setting->{type} eq 'time_duration') {
+ $setting->{default_value} = convertTimeDuration($setting->{default_value});
}
+ $global_setting_rs->create($setting);
+ }
- delete $course->{course_params};
- $course_rs->create($course);
+ 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;
+
+ # If the setting is a time_duration, store it as a number of seconds in the db.
+ if ($global_setting->type eq 'time_duration') {
+ $setting->{setting_value} = convertTimeDuration($setting->{setting_value});
+ }
+
+ $course->add_to_course_settings({
+ course_id => $course->course_id,
+ global_setting_id => $global_setting->global_setting_id,
+ value => $setting->{setting_value}
+ });
}
return;
}
@@ -342,6 +378,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..d17e5b39
--- /dev/null
+++ b/t/db/sample_data/course_settings.csv
@@ -0,0 +1,9 @@
+course_name,setting_name,setting_value
+Precalculus,institution,"Springfield CC"
+Precalculus,session_key_timeout,"20 mins"
+"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/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/t/mojolicious/015_course_settings.t b/t/mojolicious/015_course_settings.t
new file mode 100644
index 00000000..c123419c
--- /dev/null
+++ b/t/mojolicious/015_course_settings.t
@@ -0,0 +1,149 @@
+#!/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 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/;
+use DB::Utils qw/humanReadableTimeDuration/;
+
+# Load the 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";
+my $config = clone(LoadFile($config_file));
+
+my $t = Test::Mojo->new(WeBWorK3 => $config);
+
+# 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 $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);
+
+# 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->{global_setting_id};
+ for my $key (qw/subcategory options doc/) {
+ delete $setting->{$key} unless $setting->{$key};
+ }
+}
+# convert the database settings of type time_duration to human readable
+for (@$global_settings_from_db) {
+ $_->{default_value} = humanReadableTimeDuration($_->{default_value}) if $_->{type} eq 'time_duration';
+}
+
+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-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});
+
+# 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->{global_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->{global_setting_id}")
+ ->content_type_is('application/json;charset=UTF-8')->status_is(200);
+
+# 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);
+
+# 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->{global_setting_id}" => json => {
+ value => 0.5
+ }
+)->status_is(403);
+
+# Nor delete a course setting
+$t->delete_ok("/webwork3/api/courses/4/settings/$reduced_scoring->{global_setting_id}")->status_is(403);
+
+done_testing;
diff --git a/tests/stores/set_problems.spec.ts b/tests/stores/set_problems.spec.ts
index 0d4bfb2d..273dd4a1 100644
--- a/tests/stores/set_problems.spec.ts
+++ b/tests/stores/set_problems.spec.ts
@@ -27,7 +27,6 @@ import { Dictionary, generic } from 'src/common/models';
import { loadCSV, cleanIDs } from '../utils';
import { checkPassword } from 'src/common/api-requests/session';
-import { logger } from 'src/boot/logger';
const app = createApp({});
diff --git a/tests/stores/settings.spec.ts b/tests/stores/settings.spec.ts
new file mode 100644
index 00000000..c195cf96
--- /dev/null
+++ b/tests/stores/settings.spec.ts
@@ -0,0 +1,189 @@
+/**
+ * @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 { CourseSetting, DBCourseSetting, GlobalSetting, ParseableDBCourseSetting, ParseableGlobalSetting,
+ SettingValueType } from 'src/common/models/settings';
+
+import { cleanIDs, loadCSV } from '../utils';
+import { humanReadableTimeDuration } from 'src/common/models/parsers';
+import { SessionInfo } from 'src/common/models/session';
+import { AxiosError } from 'axios';
+
+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
+ 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
+ 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 an instructor of Arithmetic. (course_id: 4)
+ const response = await api.post('login', { username: 'lisa', password: 'lisa' });
+
+ // set the session course to the Arithmetic course (course_id: 4)
+ const session_store = useSessionStore();
+ session_store.updateSessionInfo(response.data as SessionInfo);
+ await session_store.fetchUserCourses();
+ session_store.setCourse(4);
+
+ const settings_store = useSettingsStore();
+ await settings_store.fetchGlobalSettings();
+ await settings_store.fetchCourseSettings(4);
+
+ });
+
+ describe('Check the global settings', () => {
+ let global_settings: GlobalSetting[];
+
+ test('Make sure the settings are valid.', () => {
+ const settings_store = useSettingsStore();
+ global_settings = settings_store.global_settings.map(s => s.clone());
+ global_settings.forEach(setting => {
+ expect(setting.isValid()).toBe(true);
+ });
+ });
+
+ test('Check the default settings', () => {
+ global_settings.forEach(setting => {
+ // convert the time_duration to human readable
+ if (setting.type === 'time_duration') {
+ setting.default_value = humanReadableTimeDuration(setting.default_value as number);
+ }
+ });
+ expect(cleanIDs(global_settings)).toStrictEqual(default_settings);
+ });
+
+ });
+
+ 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.global_setting_id);
+
+ const arith_settings_from_db = settings_store.course_settings
+ .filter(setting => arith_setting_ids.includes(setting.global_setting_id))
+ .map(setting => ({ setting_name: setting.setting_name, value: setting.value }));
+ expect(arith_settings_from_db).toStrictEqual(arith_settings);
+ });
+
+ 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.');
+ });
+
+ test('Get all course settings for a given category', () => {
+ const settings_store = useSettingsStore();
+ const settings_from_db = settings_store.getSettingsByCategory('general');
+ settings_from_db.forEach(setting => {
+ // convert the time_duration to human readable
+ if (setting.type === 'time_duration') {
+ setting.default_value = humanReadableTimeDuration(setting.default_value as number);
+ }
+ });
+ 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 a_setting = arith_settings.find(a_setting => setting.setting_name === a_setting.setting_name);
+ setting.value = a_setting?.value ? a_setting.value : setting.value ?? setting.default_value;
+ });
+
+ expect(cleanIDs(settings_from_db)).toStrictEqual(cleanIDs(settings_from_file));
+ });
+ });
+
+ 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,
+ global_setting_id: setting.global_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('Delete a setting', async () => {
+ const settings_store = useSettingsStore();
+ const setting = settings_store.getCourseSetting('course_description');
+ await settings_store.deleteCourseSetting(setting);
+
+ // Make sure it is not in store or the database.
+ const course_setting = settings_store.db_course_settings
+ .find(s => s.course_setting_id === setting.course_setting_id);
+ expect(course_setting).toBeUndefined();
+ await api.get(`/courses/${setting.course_id}/settings/${setting.global_setting_id}`)
+ .then(() => {
+ fail('Expected failure response');
+ })
+ .catch((e: AxiosError) => {
+ expect(e.response?.status).toBe(500);
+ expect((e.response?.data as {exception: string}).exception)
+ .toBe('DB::Exception::SettingNotFound');
+ });
+ });
+ });
+
+});
diff --git a/tests/stores/user_sets.spec.ts b/tests/stores/user_sets.spec.ts
index d5c40249..08f0de6d 100644
--- a/tests/stores/user_sets.spec.ts
+++ b/tests/stores/user_sets.spec.ts
@@ -11,7 +11,6 @@
import { createApp } from 'vue';
import { createPinia, setActivePinia } from 'pinia';
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
-import { api } from 'boot/axios';
import { useCourseStore } from 'src/stores/courses';
import { useProblemSetStore } from 'src/stores/problem_sets';
diff --git a/tests/unit-tests/parsing.spec.ts b/tests/unit-tests/parsing.spec.ts
index db7eaa12..bf1ba48f 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';
+ 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,39 @@ describe('Testing parsing functions', () => {
expect(() => {parseUsername('first last@site.com');}).toThrow(UsernameParseException);
});
+
+ 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..20e56009
--- /dev/null
+++ b/tests/unit-tests/settings.spec.ts
@@ -0,0 +1,895 @@
+// tests parsing and handling of users
+
+import { CourseSetting, DBCourseSetting, GlobalSetting, SettingType
+} from 'src/common/models/settings';
+import { convertTimeDuration, humanReadableTimeDuration } from 'src/common/models/parsers';
+
+describe('Testing Course Settings', () => {
+ const global_setting = {
+ global_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({
+ global_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.global_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 = ['global_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.global_setting_id = 10;
+ expect(global_setting.global_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({ global_setting_id: 25 });
+ expect(global_setting.global_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,
+ global_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,
+ global_setting_id: 199,
+ value: 'xyz'
+ });
+
+ expect(course_setting.course_setting_id).toBe(10);
+ expect(course_setting.course_id).toBe(34);
+ expect(course_setting.global_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', 'global_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.global_setting_id = 25;
+ expect(course_setting.global_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({ global_setting_id: 25 });
+ expect(course_setting.global_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 = {
+ global_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({
+ global_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.global_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 = ['global_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.global_setting_id = 25;
+ expect(course_setting.global_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_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({ global_setting_id: 25 });
+ expect(course_setting.global_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({ global_setting_id: 25 });
+ expect(course_setting.global_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({
+ global_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: 1234,
+ 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: '3 days' });
+ 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);
+ });
+
+ });
+
+ describe('Test converting of human readable time duration to number of seconds', () => {
+ test('Test time duration of seconds', () => {
+ expect(convertTimeDuration('1 sec')).toBe(1);
+ expect(convertTimeDuration('15 secs')).toBe(15);
+ });
+
+ test('Test time duration of mins', () => {
+ expect(convertTimeDuration('1 min')).toBe(60);
+ expect(convertTimeDuration('5 mins')).toBe(5 * 60);
+ expect(convertTimeDuration('5 mins, 30 secs')).toBe(5 * 60 + 30);
+ });
+
+ test('Test time duration of hours', () => {
+ expect(convertTimeDuration('1 hour')).toBe(1 * 60 * 60);
+ expect(convertTimeDuration('1 hr')).toBe(1 * 60 * 60);
+ expect(convertTimeDuration('5 hours')).toBe(5 * 60 * 60);
+ expect(convertTimeDuration('3 hrs')).toBe(3 * 60 * 60);
+ expect(convertTimeDuration('3 hrs, 10 mins, 15 seconds')).toBe(3 * 60 * 60 + 10 * 60 + 15);
+ });
+
+ test('Test time duration of days', () => {
+ expect(convertTimeDuration('1 day')).toBe(1 * 24 * 60 * 60);
+ expect(convertTimeDuration('3 days')).toBe(3 * 24 * 60 * 60);
+ expect(convertTimeDuration('3 days, 12 hours')).toBe(3 * 24 * 60 * 60 + 12 * 60 * 60);
+ });
+
+ test('Test time duration of weeks', () => {
+ expect(convertTimeDuration('1 week')).toBe(1 * 7 * 24 * 60 * 60);
+ expect(convertTimeDuration('2 weeks')).toBe(2 * 7 * 24 * 60 * 60);
+ expect(convertTimeDuration('2 weeks, 5 days')).toBe(2 * 7 * 24 * 60 * 60 + 5 * 24 * 60 * 60);
+ });
+
+ });
+
+ describe('Test conversion of num. seconds to human readable time durations', () => {
+ test('Test time duration of secs', () => {
+ expect(humanReadableTimeDuration(0)).toBe('');
+ expect(humanReadableTimeDuration(1)).toBe('1 sec');
+ expect(humanReadableTimeDuration(15)).toBe('15 secs');
+ });
+
+ test('Test time duration of mins', () => {
+ expect(humanReadableTimeDuration(60)).toBe('1 min');
+ expect(humanReadableTimeDuration(5 * 60)).toBe('5 mins');
+ expect(humanReadableTimeDuration(5 * 60 + 30)).toBe('5 mins, 30 secs');
+ });
+
+ test('Test time duration of hours', () => {
+ expect(humanReadableTimeDuration(3600)).toBe('1 hour');
+ expect(humanReadableTimeDuration(5 * 3600)).toBe('5 hours');
+ expect(humanReadableTimeDuration(5 * 3600 + 30 * 60)).toBe('5 hours, 30 mins');
+ });
+
+ test('Test time duration of days', () => {
+ expect(humanReadableTimeDuration(3600 * 24)).toBe('1 day');
+ expect(humanReadableTimeDuration(3 * 3600 * 24)).toBe('3 days');
+ expect(humanReadableTimeDuration(3 * 3600 * 24 + 6 * 3600)).toBe('3 days, 6 hours');
+ expect(humanReadableTimeDuration(3 * 3600 * 24 + 6 * 3600 + 30 * 60)).toBe('3 days, 6 hours, 30 mins');
+ });
+
+ test('Test time duration of weeks', () => {
+ expect(humanReadableTimeDuration(3600 * 24 * 7)).toBe('1 week');
+ expect(humanReadableTimeDuration(3600 * 24 * 7 * 2)).toBe('2 weeks');
+ expect(humanReadableTimeDuration(3600 * 24 * 7 * 2 + 3 * 3600 * 24)).toBe('2 weeks, 3 days');
+ });
+ });
+});