From 68e6dcaeaf9bde9c266b5822d9fde68a37945b66 Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Sat, 11 Jul 2026 12:03:30 -0500 Subject: [PATCH] Make Caliper an optionally loaded plugin that interfaces with webwork2 via hooks. This means that the Caliper code does not need to be directly in the primary webwork2 code, and those that don't use Caliper don't have to even have it checking to see if it is enabled. Just leave the `Mojolicious::WeBWorK::Plugin::Caliper` plugin commented out in `webwork2.mojolicious.yml`. If you want to allow the usage of Caliper on your server, then uncomment that plugin. Caliper can be configured per course in `course.conf` files if the plugin is enabled. This `hook` approach is also extendable. Other plugins, even those not directly in the webwork2 repository could utilize these hooks. The hooks added in this pull request are when user logs in or out, when an answer is submitted in a regular assignment, and when a page change, preview, or submission occurs in a test. Additional hooks could also be added for further extendability with plugins in the future. The `$caliper{base_url}` option no longer exists. Those using Caliper just need to make sure that the `$server_root_url` and `$webwork_url` are set correctly in `site.conf`. The point is that anyone using webwork2 should do that anyway, so the `$caliper{base_url}` setting was redundant. The Caliper packages are heavily updated. The packages all use signatures. Instead of passing both the course environment and database handle everywhere, just pass the controller which has both of those. In addition, doing so gives access to do things better. For example, the environment variable usage (`HTTP_X_FORWARDED_FOR`, `REMOTE_ADDR`, `HTTP_CLIENT_IP`, `HTTP_USER_AGENT`, and `HTTP_HOST` in `Caliper/Entity.pm` and `HTTP_REFERER` in `Caliper/Event.pm`) was clearly broken and not working since the switch to Mojolicious. Since the controller is now available in those places the things those environment variables used to provide with modperl and apache can now be obtained from the controller. `Mojo::UserAgent` is used instead of `HTTP::Async`. This is the only place that the outdated and unmaintained `HTTP::Async` package is used. So drop that dependency. The UUIDs used now use `create_uuid_as_string` from the `UUID::Tiny` package, instead of the `Data::UUID` package and essentially `Data::UUID->new->create_str`. This is done for two reasons. First, this is the only use of this dependency. So that is another dependency not needed by webwork2. Second, the Caliper specification recommends using version 4 UUIDs and `Data::UUID` does not generate version 4 UUIDs, but `UUID::Tiny` does. The problem source is no longer sent in Caliper events. That really should never have been done. That is a large amount of data and is not needed. This means that even those that do not use Caliper have to endure the additional server load required to transmit that back from the rendering process to the main process. It also makes the Caliper events much larger in size than they should be. The source file is still sent and that really should be all that is needed. The source can be looked up in the file. Other than not sending the problem source, everything else sent by the Caliper implementation is the same. So the implementation still meets the 1EdTech Caliper specification as much as it did before (the problem source is certainly not something the specification insists on or is even considered in the specification). See https://www.imsglobal.org/spec/caliper/v1p2. --- Dockerfile | 18 +- DockerfileStage1 | 2 - bin/check_modules.pl | 11 - conf/localOverrides.conf.dist | 47 +- conf/webwork2.mojolicious.dist.yml | 5 + lib/Caliper/Actor.pm | 43 +- lib/Caliper/Entity.pm | 449 +++++++++----------- lib/Caliper/Event.pm | 53 +-- lib/Caliper/ResourceIri.pm | 86 ++-- lib/Caliper/Sensor.pm | 152 +++---- lib/Mojolicious/WeBWorK/Plugin/Caliper.pm | 206 +++++++++ lib/WeBWorK/Authen.pm | 28 +- lib/WeBWorK/ContentGenerator/GatewayQuiz.pm | 69 +-- lib/WeBWorK/Utils/ProblemProcessing.pm | 63 +-- lib/WeBWorK/Utils/Rendering.pm | 3 - 15 files changed, 560 insertions(+), 675 deletions(-) create mode 100644 lib/Mojolicious/WeBWorK/Plugin/Caliper.pm diff --git a/Dockerfile b/Dockerfile index fcb98918ea..8e677956ac 100644 --- a/Dockerfile +++ b/Dockerfile @@ -109,7 +109,6 @@ RUN apt-get update \ libgd-perl \ libhtml-scrubber-perl \ libhtml-template-perl \ - libhttp-async-perl \ libio-compress-perl \ libiterator-perl \ libiterator-util-perl \ @@ -133,7 +132,6 @@ RUN apt-get update \ libnet-ip-perl \ libnet-ldap-perl \ libnet-oauth-perl \ - libossp-uuid-perl \ libpadwalker-perl \ libpandoc-wrapper-perl \ libpath-class-perl \ @@ -231,18 +229,18 @@ COPY --from=base /opt/base/pg $APP_ROOT/pg RUN echo "PATH=$PATH:$APP_ROOT/webwork2/bin" >> /root/.bashrc \ && mkdir /run/webwork2 /etc/ssl/local \ && cd $APP_ROOT/webwork2/ \ - && chown www-data DATA ../courses logs tmp /etc/ssl/local /run/webwork2 \ - && chmod -R u+w DATA ../courses logs tmp /run/webwork2 /etc/ssl/local \ + && chown www-data DATA ../courses logs tmp /etc/ssl/local /run/webwork2 \ + && chmod -R u+w DATA ../courses logs tmp /run/webwork2 /etc/ssl/local \ && echo "en_US ISO-8859-1\nen_US.UTF-8 UTF-8" > /etc/locale.gen \ - && /usr/sbin/locale-gen \ - && echo "locales locales/default_environment_locale select en_US.UTF-8\ndebconf debconf/frontend select Noninteractive" > /tmp/preseed.txt \ - && debconf-set-selections /tmp/preseed.txt \ + && /usr/sbin/locale-gen \ + && echo "locales locales/default_environment_locale select en_US.UTF-8\ndebconf debconf/frontend select Noninteractive" > /tmp/preseed.txt \ + && debconf-set-selections /tmp/preseed.txt \ && rm -f /etc/localtime /etc/timezone && echo "Etc/UTC" > /etc/timezone \ - && dpkg-reconfigure -f noninteractive tzdata \ + && dpkg-reconfigure -f noninteractive tzdata \ && cd $WEBWORK_ROOT/htdocs \ - && npm install \ + && npm install \ && cd $PG_ROOT/htdocs \ - && npm install + && npm install # ================================================================== # Phase 7 - Final setup and prepare docker-entrypoint.sh diff --git a/DockerfileStage1 b/DockerfileStage1 index d6ab207151..609996fe8b 100644 --- a/DockerfileStage1 +++ b/DockerfileStage1 @@ -71,7 +71,6 @@ RUN apt-get update \ libgd-perl \ libhtml-scrubber-perl \ libhtml-template-perl \ - libhttp-async-perl \ libio-compress-perl \ libiterator-perl \ libiterator-util-perl \ @@ -94,7 +93,6 @@ RUN apt-get update \ libnet-ip-perl \ libnet-ldap-perl \ libnet-oauth-perl \ - libossp-uuid-perl \ libpadwalker-perl \ libpandoc-wrapper-perl \ libpath-class-perl \ diff --git a/bin/check_modules.pl b/bin/check_modules.pl index 0ea9acf800..e2f4ba2112 100755 --- a/bin/check_modules.pl +++ b/bin/check_modules.pl @@ -129,12 +129,6 @@ =head1 DESCRIPTION ubuntu => 'libdata-structure-util-perl' } }, - 'Data::UUID' => { - package => { - ubuntu => 'libossp-uuid-perl', - rhel => 'perl-Data-UUID' - } - }, 'Date::Format' => { package => { ubuntu => 'libtimedate-perl', @@ -283,11 +277,6 @@ =head1 DESCRIPTION rhel => 'perl-HTML-Parser' } }, - 'HTTP::Async' => { - package => { - ubuntu => 'libhttp-async-perl' - } - }, 'IO::File' => { package => { ubuntu => 'perl-base', diff --git a/conf/localOverrides.conf.dist b/conf/localOverrides.conf.dist index 8c72492d7c..af2a7ab977 100644 --- a/conf/localOverrides.conf.dist +++ b/conf/localOverrides.conf.dist @@ -738,28 +738,35 @@ $mail{feedbackRecipients} = [ # Webwork Caliper ################################################################################ -# enable/disable Caliper for install -#$caliper{enabled} = 0; -# base_url should ideally be hard coded to a persistent url pointing to the webwork root -# (important to keep it consistent over time) -#$caliper{base_url} = 'https://webwork.elearning.ubc.ca/webwork2/'; -# LRS endpoint -#$caliper{host} = 'http://caliper.example.host.org/api/endpoint'; +# Set this to 1 to enable Caliper. Note that the +# Mojolicious::WeBWorK::Plugin::Caliper plugin must also be enabled in +# conf/webwork2.mojolicous.yml in order for this to work, and all of the +# $caliper settings below must be uncommented and set except the +# $caliper{custom_actor_generator}. Note that this and all of the settings below +# can also be configured per course in the course.conf files. +$caliper{enabled} = 0; + +# LRS endpoint to which the Caliper telemetry data will be sent. +#$caliper{host} = 'https://caliper.example.host.org/api/endpoint'; + # LRS endpoint Bearer API key #$caliper{api_key} = '1234567890abcdefg'; -# log file for caliper errors -#$caliper{errorlog} = $webworkDirs{logs} . "/caliper_errors.log"; -# customized Caliper actor. Useful if persistent identifiers for students are available in WebWork -# $caliper{custom_actor_generator} = sub { -# my ($ce, $db, $user) = @_; -# # set caliper id as needed -# my $caliper_id = 'http://www.ubc.ca/' . $user->user_id(); -# return { -# 'id' => $caliper_id, -# 'type' => 'Person', -# 'name' => $user->first_name() . " " . $user->last_name(), -# }; -# }; + +# Log file for caliper errors +#$caliper{errorlog} = "$webworkDirs{logs}/caliper_errors.log"; + +# Custom Caliper actor generator. This is useful if persistent identifiers for +# students are available in WebWork. +#$caliper{custom_actor_generator} = sub { +# my ($ce, $db, $user) = @_; +# # set caliper id as needed +# my $caliper_id = 'http://yourschool.edu/' . $user->user_id; +# return { +# id => $caliper_id, +# type => 'Person', +# name => $user->first_name . ' ' . $user->last_name, +# }; +#}; ############################################################################### # Test settings diff --git a/conf/webwork2.mojolicious.dist.yml b/conf/webwork2.mojolicious.dist.yml index c96103c411..7fabe81fc9 100644 --- a/conf/webwork2.mojolicious.dist.yml +++ b/conf/webwork2.mojolicious.dist.yml @@ -259,3 +259,8 @@ hardcopy: # external websites with usernames and passwords embedded in them such as for # PreTeXt textbooks. allow_unsecured_rpc: 0 + +plugins: + # Uncomment the following to enable Caliper. Also set the Caliper settings in + # localOverrides.conf (or per course in course.conf files). + #- Mojolicious::WeBWorK::Plugin::Caliper: {} diff --git a/lib/Caliper/Actor.pm b/lib/Caliper/Actor.pm index db00f75231..de03c90e36 100644 --- a/lib/Caliper/Actor.pm +++ b/lib/Caliper/Actor.pm @@ -1,46 +1,25 @@ package Caliper::Actor; - -##### Library Imports ##### -use strict; -use warnings; -use WeBWorK::CourseEnvironment; -use WeBWorK::DB; -use Data::Dumper; +use Mojo::Base -signatures; use Caliper::ResourceIri; -sub generate_anonymous_actor { +sub generate_default_actor ($c, $user) { return { - 'id' => 'http://purl.imsglobal.org/caliper/Person', - 'type' => 'Person', + id => Caliper::ResourceIri->new($c->ce)->actor_homepage($user->user_id), + type => 'Person', + name => $user->first_name . ' ' . $user->last_name }; } -sub generate_default_actor { - my ($ce, $db, $user) = @_; - my $resource_iri = Caliper::ResourceIri->new($ce); - - return { - 'id' => $resource_iri->actor_homepage($user->user_id()), - 'type' => 'Person', - 'name' => $user->first_name() . " " . $user->last_name(), - }; -} +sub generate_actor ($c, $user_id) { + return { id => 'http://purl.imsglobal.org/caliper/Person', type => 'Person' } unless defined $user_id; -sub generate_actor { - my ($ce, $db, $user_id) = @_; + my $user = $c->db->getUser($user_id); - if (!defined($user_id)) { - return Caliper::Entity::generate_anonymous_actor(); - } else { - my $user = $db->getUser($user_id); + return $c->ce->{caliper}{custom_actor_generator}($c->ce, $c->db, $user) + if ref $c->ce->{caliper}{custom_actor_generator} eq 'CODE'; - if (defined($ce->{caliper}{custom_actor_generator})) { - return $ce->{caliper}{custom_actor_generator}($ce, $db, $user); - } else { - return generate_default_actor($ce, $db, $user); - } - } + return generate_default_actor($c, $user); } 1; diff --git a/lib/Caliper/Entity.pm b/lib/Caliper/Entity.pm index 182fd8cf59..5e57f0814e 100644 --- a/lib/Caliper/Entity.pm +++ b/lib/Caliper/Entity.pm @@ -1,401 +1,332 @@ package Caliper::Entity; - -##### Library Imports ##### -use strict; -use warnings; +use Mojo::Base -signatures; use Digest::SHA qw(sha1_base64); -use WeBWorK::CourseEnvironment; -use WeBWorK::DB; use WeBWorK::Utils::Tags; use WeBWorK::Utils::Sets qw(grade_set grade_gateway); use Caliper::ResourceIri; use Caliper::Sensor; use Caliper::Actor; -sub webwork_app { - my ($ce, $db) = @_; - my $resource_iri = Caliper::ResourceIri->new($ce); - - # $ce doesn't have WW_VERSION when doing login/logout for some reason - my $seed_ce = WeBWorK::CourseEnvironment->new; - my $ww_version = $seed_ce->{WW_VERSION} || "unknown"; - +sub webwork_app ($c) { return { - 'id' => $resource_iri->webwork(), - 'type' => 'SoftwareApplication', - 'name' => 'WeBWorK', - 'version' => $ww_version, + id => Caliper::ResourceIri->new($c->ce)->webwork, + type => 'SoftwareApplication', + name => 'WeBWorK', + version => $c->ce->{WW_VERSION} }; } -sub session { - my ($ce, $db, $actor, $session_key) = @_; - my $resource_iri = Caliper::ResourceIri->new($ce); +sub session ($c, $actor, $session_key) { my $session_key_hash = sha1_base64($session_key); - return { - 'id' => $resource_iri->user_session($session_key_hash), - 'type' => 'Session', - 'user' => $actor, - 'client' => Caliper::Entity::client($ce, $db, $session_key_hash), + id => Caliper::ResourceIri->new($c->ce)->user_session($session_key_hash), + type => 'Session', + user => $actor, + client => Caliper::Entity::client($c, $session_key_hash) }; } -sub client { - my ($ce, $db, $session_key_hash) = @_; - my $resource_iri = Caliper::ResourceIri->new($ce); - - my $ip_address = ''; - if ($ENV{HTTP_X_FORWARDED_FOR}) { - $ip_address = $ENV{HTTP_X_FORWARDED_FOR}; - } elsif ($ENV{REMOTE_ADDR}) { - $ip_address = $ENV{REMOTE_ADDR}; - } elsif ($ENV{HTTP_CLIENT_IP}) { - $ip_address = $ENV{HTTP_CLIENT_IP}; - } - +sub client ($c, $session_key_hash) { return { - 'id' => $resource_iri->user_client($session_key_hash), - 'type' => 'SoftwareApplication', - 'userAgent' => $ENV{HTTP_USER_AGENT}, - 'ipAddress' => $ip_address, - 'host' => $ENV{HTTP_HOST}, + id => Caliper::ResourceIri->new($c->ce)->user_client($session_key_hash), + type => 'SoftwareApplication', + userAgent => $c->req->headers->user_agent, + ipAddress => $c->tx->remote_address, + host => $c->req->url->to_abs->host }; } -sub membership { - my ($ce, $db, $actor, $user_id) = @_; - my $resource_iri = Caliper::ResourceIri->new($ce); +sub membership ($c, $actor, $user_id) { + my $resource_iri = Caliper::ResourceIri->new($c->ce); - my $user = $db->getUser($user_id); - my $permission = $db->getPermissionLevel($user_id); + my $user = $c->db->getUser($user_id); + my $permission = $c->db->getPermissionLevel($user_id); - my $roles = []; - my $status = ''; + my $roles = []; - if ($user->status() ne 'D') { - $status = 'Active'; - } else { - $status = 'Inactive'; - } - - if ($permission->permission() == $ce->{userRoles}{admin}) { + if ($permission->permission == $c->ce->{userRoles}{admin}) { push @$roles, 'Administrator'; - } elsif ($permission->permission() == $ce->{userRoles}{professor}) { + } elsif ($permission->permission == $c->ce->{userRoles}{professor}) { push @$roles, 'Instructor'; - } elsif ($permission->permission() == $ce->{userRoles}{ta}) { + } elsif ($permission->permission == $c->ce->{userRoles}{ta}) { push @$roles, 'Instructor'; push @$roles, 'Instructor#TeachingAssistant'; - } elsif ($permission->permission() == $ce->{userRoles}{grade_proctor}) { + } elsif ($permission->permission == $c->ce->{userRoles}{grade_proctor}) { push @$roles, 'Instructor'; push @$roles, 'Instructor#Grader'; - } elsif ($permission->permission() == $ce->{userRoles}{login_proctor}) { + } elsif ($permission->permission == $c->ce->{userRoles}{login_proctor}) { push @$roles, 'Instructor'; push @$roles, 'Instructor#GuestInstructor'; - } elsif ($permission->permission() == $ce->{userRoles}{student}) { + } elsif ($permission->permission == $c->ce->{userRoles}{student}) { push @$roles, 'Learner'; } # guest and nobody aren't tracked return { - 'id' => $resource_iri->user_membership($user_id), - 'type' => 'Membership', - 'member' => $actor, - 'organization' => $resource_iri->course(), - 'roles' => $roles, - 'status' => $status, + id => $resource_iri->user_membership($user_id), + type => 'Membership', + member => $actor, + organization => $resource_iri->course, + roles => $roles, + status => $user->status ne 'D' ? 'Active' : 'Inactive' }; } -sub course { - my ($ce, $db) = @_; - my $resource_iri = Caliper::ResourceIri->new($ce); - +sub course ($c) { my $course_entity = { - 'id' => $resource_iri->course(), - 'type' => 'CourseOffering', + id => Caliper::ResourceIri->new($c->ce)->course, + type => 'CourseOffering' }; - - if ($db->settingExists('courseTitle')) { - $course_entity->{'name'} = $db->getSettingValue('courseTitle'); - } - + $course_entity->{name} = $c->db->getSettingValue('courseTitle') if $c->db->settingExists('courseTitle'); return $course_entity; } -sub problem_set { - my ($ce, $db, $set_id) = @_; - my $resource_iri = Caliper::ResourceIri->new($ce); +sub problem_set ($c, $set_id) { + my $resource_iri = Caliper::ResourceIri->new($c->ce); - my $problem_set = $db->getGlobalSet($set_id); + my $problem_set = $c->db->getGlobalSet($set_id); my $items = []; - my @problem_ids = $db->listGlobalProblems($set_id); + my @problem_ids = $c->db->listGlobalProblems($set_id); for my $problem_id (@problem_ids) { push( @$items, { - 'id' => $resource_iri->problem($set_id, $problem_id), - 'type' => 'AssessmentItem', + id => $resource_iri->problem($set_id, $problem_id), + type => 'AssessmentItem' } ); } my $problem_set_entity = { - 'id' => $resource_iri->problem_set($set_id), - 'type' => 'Assessment', - 'isPartOf' => Caliper::Entity::course($ce, $db), - 'name' => $set_id, - 'items' => $items, - 'dateToStartOn' => Caliper::Sensor::formatted_timestamp($problem_set->open_date()), - 'dateToSubmit' => Caliper::Sensor::formatted_timestamp($problem_set->due_date()), - 'extensions' => { - 'answer_date' => $problem_set->answer_date(), - 'reduced_scoring_date' => $problem_set->reduced_scoring_date(), - 'visible' => $problem_set->visible(), - 'enable_reduced_scoring' => $problem_set->enable_reduced_scoring(), - 'description' => $problem_set->description(), - 'restricted_release' => $problem_set->restricted_release(), - 'restricted_status' => $problem_set->restricted_status(), - 'attempts_per_version' => $problem_set->attempts_per_version(), - 'time_interval' => $problem_set->time_interval(), - 'versions_per_interval' => $problem_set->versions_per_interval(), - 'version_time_limit' => $problem_set->version_time_limit(), - 'version_creation_time' => $problem_set->version_creation_time(), - 'problem_randorder' => $problem_set->problem_randorder(), - 'version_last_attempt_time' => $problem_set->version_last_attempt_time(), - 'problems_per_page' => $problem_set->problems_per_page(), - 'hide_score' => $problem_set->hide_score(), - 'hide_score_by_problem' => $problem_set->hide_score_by_problem(), - 'hide_work' => $problem_set->hide_work(), - 'time_limit_cap' => $problem_set->time_limit_cap(), - 'restrict_ip' => $problem_set->restrict_ip(), - 'relax_restrict_ip' => $problem_set->relax_restrict_ip(), - 'restricted_login_proctor' => $problem_set->restricted_login_proctor(), - 'hide_hint' => $problem_set->hide_hint(), - 'restrict_prob_progression' => $problem_set->restrict_prob_progression(), + id => $resource_iri->problem_set($set_id), + type => 'Assessment', + isPartOf => Caliper::Entity::course($c), + name => $set_id, + items => $items, + dateToStartOn => Caliper::Sensor::formatted_timestamp($problem_set->open_date), + dateToSubmit => Caliper::Sensor::formatted_timestamp($problem_set->due_date), + extensions => { + answer_date => $problem_set->answer_date, + reduced_scoring_date => $problem_set->reduced_scoring_date, + visible => $problem_set->visible, + enable_reduced_scoring => $problem_set->enable_reduced_scoring, + description => $problem_set->description, + restricted_release => $problem_set->restricted_release, + restricted_status => $problem_set->restricted_status, + attempts_per_version => $problem_set->attempts_per_version, + time_interval => $problem_set->time_interval, + versions_per_interval => $problem_set->versions_per_interval, + version_time_limit => $problem_set->version_time_limit, + version_creation_time => $problem_set->version_creation_time, + problem_randorder => $problem_set->problem_randorder, + version_last_attempt_time => $problem_set->version_last_attempt_time, + problems_per_page => $problem_set->problems_per_page, + hide_score => $problem_set->hide_score, + hide_score_by_problem => $problem_set->hide_score_by_problem, + hide_work => $problem_set->hide_work, + time_limit_cap => $problem_set->time_limit_cap, + restrict_ip => $problem_set->restrict_ip, + relax_restrict_ip => $problem_set->relax_restrict_ip, + restricted_login_proctor => $problem_set->restricted_login_proctor, + hide_hint => $problem_set->hide_hint, + restrict_prob_progression => $problem_set->restrict_prob_progression } }; - if (defined($problem_set->description()) && $problem_set->description() ne '') { - $problem_set_entity->{'description'} = $problem_set->description(); - } + $problem_set_entity->{description} = $problem_set->description + if defined $problem_set->description && $problem_set->description ne ''; return $problem_set_entity; } -sub problem { - my ($ce, $db, $set_id, $problem_id) = @_; - my $resource_iri = Caliper::ResourceIri->new($ce); +sub problem ($c, $set_id, $problem_id) { + my $problem = $c->db->getGlobalProblem($set_id, $problem_id); - my $problem = $db->getGlobalProblem($set_id, $problem_id); + my $tags = { keywords => [] }; - my $keywords = []; - my $unblessed_tags; - - # Problem groups use a "group:problemGroupName" pseudo source file (see - # WeBWorK::Utils::Instructor::assignProblemToUserSetVersion) which is not a real file, so attempting to read tags - # from it would die. - if ($problem->source_file() !~ /^group:/) { - my $templateDir = $ce->{courseDirs}->{templates}; - my $tags = WeBWorK::Utils::Tags->new($templateDir . '/' . $problem->source_file()); - $keywords = $tags->{'keywords'}; - $_ =~ s/(^[\s"']+)|([\s"']+$)//g for @$keywords; - - $unblessed_tags = {%$tags}; + if ($problem->source_file !~ /^group:/) { + $tags = WeBWorK::Utils::Tags->new($c->ce->{courseDirs}{templates} . '/' . $problem->source_file); + $_ =~ s/(^[\s"']+)|([\s"']+$)//g for @{ $tags->{keywords} }; } return { - 'id' => $resource_iri->problem($set_id, $problem_id), - 'type' => 'AssessmentItem', - 'name' => 'Problem ' . $problem_id, - 'isPartOf' => Caliper::Entity::problem_set($ce, $db, $set_id), - 'keywords' => $keywords, - 'extensions' => { - 'source_file' => $problem->source_file(), - 'value' => $problem->value(), - 'max_attempts' => $problem->max_attempts(), - 'att_to_open_children' => $problem->att_to_open_children(), - 'counts_parent_grade' => $problem->counts_parent_grade(), - 'showMeAnother' => $problem->showMeAnother(), - 'showMeAnotherCount' => $problem->showMeAnotherCount(), - 'showHintsAfter' => $problem->showHintsAfter(), - 'prPeriod' => $problem->prPeriod(), - 'prCount' => $problem->prCount(), - 'flags' => $problem->flags(), - 'tags' => $unblessed_tags, - }, + id => Caliper::ResourceIri->new($c->ce)->problem($set_id, $problem_id), + type => 'AssessmentItem', + name => "Problem $problem_id", + isPartOf => Caliper::Entity::problem_set($c, $set_id), + keywords => $tags->{keywords}, + extensions => { + source_file => $problem->source_file, + value => $problem->value, + max_attempts => $problem->max_attempts, + att_to_open_children => $problem->att_to_open_children, + counts_parent_grade => $problem->counts_parent_grade, + showMeAnother => $problem->showMeAnother, + showMeAnotherCount => $problem->showMeAnotherCount, + showHintsAfter => $problem->showHintsAfter, + prPeriod => $problem->prPeriod, + prCount => $problem->prCount, + flags => $problem->flags, + tags => \%$tags + } }; } -sub problem_user { - my ($ce, $db, $set_id, $version_id, $problem_id, $user_id, $pg) = @_; - my $resource_iri = Caliper::ResourceIri->new($ce); - +sub problem_user ($c, $set_id, $version_id, $problem_id, $user_id, $pg) { my $problem_user = $version_id - ? $db->getMergedProblemVersion($user_id, $set_id, $version_id, $problem_id) - : $db->getMergedProblem($user_id, $set_id, $problem_id); - - my $templateDir = $ce->{courseDirs}->{templates}; - my $tags = WeBWorK::Utils::Tags->new($templateDir . '/' . $problem_user->source_file()); - my $keywords = $tags->{'keywords'}; - $_ =~ s/(^[\s"']+)|([\s"']+$)//g for @$keywords; + ? $c->db->getMergedProblemVersion($user_id, $set_id, $version_id, $problem_id) + : $c->db->getMergedProblem($user_id, $set_id, $problem_id); - my %tags_ref = %$tags; - my $unblessed_tags = \%tags_ref; + my $tags = WeBWorK::Utils::Tags->new($c->ce->{courseDirs}{templates} . '/' . $problem_user->source_file); + $_ =~ s/(^[\s"']+)|([\s"']+$)//g for @{ $tags->{keywords} }; my $correct_answers = []; - foreach my $ans_id (@{ $pg->{flags}->{ANSWER_ENTRY_ORDER} // [] }) { - push @$correct_answers, $pg->{'answers'}->{$ans_id}->{'correct_value'}; + for my $ans_id (@{ $pg->{flags}->{ANSWER_ENTRY_ORDER} // [] }) { + push @$correct_answers, $pg->{answers}->{$ans_id}->{correct_value}; } return { - 'id' => $resource_iri->problem_user($set_id, $problem_id, $user_id), - 'type' => 'AssessmentItem', - 'name' => 'Problem ' . $problem_id, - 'isPartOf' => Caliper::Entity::problem($ce, $db, $set_id, $problem_id), - 'keywords' => $keywords, - 'extensions' => { - 'correct_answers' => $correct_answers, - 'source_file' => $problem_user->source_file(), - 'value' => $problem_user->value(), - 'max_attempts' => $problem_user->max_attempts(), - 'att_to_open_children' => $problem_user->att_to_open_children(), - 'counts_parent_grade' => $problem_user->counts_parent_grade(), - 'showMeAnother' => $problem_user->showMeAnother(), - 'showMeAnotherCount' => $problem_user->showMeAnotherCount(), - 'showHintsAfter' => $problem_user->showHintsAfter(), - 'prPeriod' => $problem_user->prPeriod(), - 'prCount' => $problem_user->prCount(), - 'flags' => $problem_user->flags(), - 'tags' => $unblessed_tags, - 'problem_seed' => $problem_user->problem_seed(), - 'source_text' => $problem_user->status(), - 'problem_source_code' => $pg->{problem_source_code}, - 'problem_html_text' => $pg->{'body_text'}, - 'status' => $problem_user->status(), - 'attempted' => $problem_user->attempted(), - 'last_answer' => $problem_user->last_answer(), - 'num_correct' => $problem_user->num_correct(), - 'num_incorrect' => $problem_user->num_incorrect(), - 'sub_status' => $problem_user->sub_status(), + id => Caliper::ResourceIri->new($c->ce)->problem_user($set_id, $problem_id, $user_id), + type => 'AssessmentItem', + name => "Problem $problem_id", + isPartOf => Caliper::Entity::problem($c, $set_id, $problem_id), + keywords => $tags->{keywords}, + extensions => { + correct_answers => $correct_answers, + source_file => $problem_user->source_file, + value => $problem_user->value, + max_attempts => $problem_user->max_attempts, + att_to_open_children => $problem_user->att_to_open_children, + counts_parent_grade => $problem_user->counts_parent_grade, + showMeAnother => $problem_user->showMeAnother, + showMeAnotherCount => $problem_user->showMeAnotherCount, + showHintsAfter => $problem_user->showHintsAfter, + prPeriod => $problem_user->prPeriod, + prCount => $problem_user->prCount, + flags => $problem_user->flags, + tags => \%$tags, + problem_seed => $problem_user->problem_seed, + source_text => $problem_user->status, + problem_html_text => $pg->{'body_text'}, + status => $problem_user->status, + attempted => $problem_user->attempted, + last_answer => $problem_user->last_answer, + num_correct => $problem_user->num_correct, + num_incorrect => $problem_user->num_incorrect, + sub_status => $problem_user->sub_status } }; } -sub answer { - my ($ce, $db, $set_id, $version_id, $problem_id, $user_id, $pg, $start_time, $end_time) = @_; - my $resource_iri = Caliper::ResourceIri->new($ce); - +sub answer ($c, $set_id, $version_id, $problem_id, $user_id, $pg, $start_time, $end_time) { my $last_answer_id = - $db->latestProblemPastAnswer($user_id, ($version_id ? "$set_id,v$version_id" : $set_id), $problem_id); - my $last_answer = $db->getPastAnswer($last_answer_id); - my @answers = split(/\t/, $last_answer->answer_string()); + $c->db->latestProblemPastAnswer($user_id, ($version_id ? "$set_id,v$version_id" : $set_id), $problem_id); + my $last_answer = $c->db->getPastAnswer($last_answer_id); + my @answers = split(/\t/, $last_answer->answer_string); my $pg_answers_hash = {}; - foreach my $key (keys %{ $pg->{'answers'} }) { - my %answer_ref = %{ $pg->{'answers'}->{$key} }; + for my $key (keys %{ $pg->{answers} }) { + my %answer_ref = %{ $pg->{answers}->{$key} }; my $unblessed_answer = \%answer_ref; $pg_answers_hash->{$key} = $unblessed_answer; } return { - 'id' => $resource_iri->answer($set_id, $problem_id, $user_id), - 'type' => 'FillinBlankResponse', - 'attempt' => Caliper::Entity::answer_attempt( - $ce, $db, $set_id, $version_id, $problem_id, $user_id, $pg, $start_time, $end_time + id => Caliper::ResourceIri->new($c->ce)->answer($set_id, $problem_id, $user_id), + type => 'FillinBlankResponse', + attempt => Caliper::Entity::answer_attempt( + $c, $set_id, $version_id, $problem_id, $user_id, $pg, $start_time, $end_time ), - 'values' => \@answers, - 'extensions' => { - 'source_file' => $last_answer->source_file(), - 'scores' => $last_answer->scores(), - 'comment' => $last_answer->comment_string(), - 'pg_answers_hash' => $pg_answers_hash, + values => \@answers, + extensions => { + source_file => $last_answer->source_file, + scores => $last_answer->scores, + comment => $last_answer->comment_string, + pg_answers_hash => $pg_answers_hash } }; } -sub answer_attempt { - my ($ce, $db, $set_id, $version_id, $problem_id, $user_id, $pg, $start_time, $end_time) = @_; - my $resource_iri = Caliper::ResourceIri->new($ce); +sub answer_attempt ($c, $set_id, $version_id, $problem_id, $user_id, $pg, $start_time, $end_time) { + my $resource_iri = Caliper::ResourceIri->new($c->ce); my $problem_user = $version_id - ? $db->getMergedProblemVersion($user_id, $set_id, $version_id, $problem_id) - : $db->getMergedProblem($user_id, $set_id, $problem_id); + ? $c->db->getMergedProblemVersion($user_id, $set_id, $version_id, $problem_id) + : $c->db->getMergedProblem($user_id, $set_id, $problem_id); my $last_answer_id = - $db->latestProblemPastAnswer($user_id, ($version_id ? "$set_id,v$version_id" : $set_id), $problem_id); - my $last_answer = $db->getPastAnswer($last_answer_id); - my $attempt = $version_id ? $version_id : scalar $db->listProblemPastAnswers($user_id, $set_id, $problem_id); + $c->db->latestProblemPastAnswer($user_id, ($version_id ? "$set_id,v$version_id" : $set_id), $problem_id); + my $last_answer = $c->db->getPastAnswer($last_answer_id); + my $attempt = $version_id ? $version_id : scalar $c->db->listProblemPastAnswers($user_id, $set_id, $problem_id); my $score = $problem_user->status || 0; $score = 0 if ($score > 1 || $score < 0); my $answer_attempt = { - 'id' => $resource_iri->answer_attempt($set_id, $problem_id, $user_id, $last_answer->answer_id()), - 'type' => 'Attempt', - 'assignee' => Caliper::Actor::generate_actor($ce, $db, $user_id), - 'assignable' => $resource_iri->problem_user($set_id, $problem_id, $user_id), - 'count' => $attempt + 0, #ensure int - 'dateCreated' => Caliper::Sensor::formatted_timestamp($last_answer->timestamp()), - 'extensions' => { - 'attempt_score' => $score, - } + id => $resource_iri->answer_attempt($set_id, $problem_id, $user_id, $last_answer->answer_id), + type => 'Attempt', + assignee => Caliper::Actor::generate_actor($c, $user_id), + assignable => $resource_iri->problem_user($set_id, $problem_id, $user_id), + count => $attempt + 0, # Make sure this is an int + dateCreated => Caliper::Sensor::formatted_timestamp($last_answer->timestamp), + extensions => { attempt_score => $score } }; if ($start_time) { - $answer_attempt->{'startedAtTime'} = Caliper::Sensor::formatted_timestamp($start_time); + $answer_attempt->{startedAtTime} = Caliper::Sensor::formatted_timestamp($start_time); if ($end_time) { - $answer_attempt->{'endedAtTime'} = Caliper::Sensor::formatted_timestamp($end_time); - $answer_attempt->{'duration'} = Caliper::Sensor::formatted_duration($end_time - $start_time); + $answer_attempt->{endedAtTime} = Caliper::Sensor::formatted_timestamp($end_time); + $answer_attempt->{duration} = Caliper::Sensor::formatted_duration($end_time - $start_time); } } return $answer_attempt; } -sub problem_set_attempt { - my ($ce, $db, $set_id, $version_id, $user_id, $start_time, $end_time) = @_; - my $resource_iri = Caliper::ResourceIri->new($ce); +sub problem_set_attempt ($c, $set_id, $version_id, $user_id, $start_time, $end_time) { + my $resource_iri = Caliper::ResourceIri->new($c->ce); my $problem_set_user = - $version_id ? $db->getMergedSetVersion($user_id, $set_id, $version_id) : $db->getMergedSet($user_id, $set_id); + $version_id + ? $c->db->getMergedSetVersion($user_id, $set_id, $version_id) + : $c->db->getMergedSet($user_id, $set_id); my $attempt = 0; if ($version_id) { $attempt = $version_id; } else { - my @problem_ids = $db->listGlobalProblems($set_id); + my @problem_ids = $c->db->listGlobalProblems($set_id); for my $problem_id (@problem_ids) { - $attempt += scalar $db->listProblemPastAnswers($user_id, $set_id, $problem_id); + $attempt += scalar $c->db->listProblemPastAnswers($user_id, $set_id, $problem_id); } } - my $score = grade_set($db, $problem_set_user, $user_id, $version_id ? 1 : 0); - my $extensions = { 'attempt_score' => $score, }; + my $score = grade_set($c->db, $problem_set_user, $user_id, $version_id ? 1 : 0); + my $extensions = { attempt_score => $score, }; if ($version_id) { - $extensions->{'gateway_score'} = grade_gateway($db, $problem_set_user->set_id, $user_id); + $extensions->{gateway_score} = grade_gateway($c->db, $problem_set_user->set_id, $user_id); } my $problem_set_attempt = { - 'id' => $resource_iri->problem_set_attempt($set_id, $user_id, $attempt), - 'type' => 'Attempt', - 'assignee' => Caliper::Actor::generate_actor($ce, $db, $user_id), - 'assignable' => $resource_iri->problem_set($set_id), - 'count' => $attempt + 0, #ensure int - 'extensions' => $extensions, + id => $resource_iri->problem_set_attempt($set_id, $user_id, $attempt), + type => 'Attempt', + assignee => Caliper::Actor::generate_actor($c, $user_id), + assignable => $resource_iri->problem_set($set_id), + count => $attempt + 0, # Make sure this is an int + extensions => $extensions }; if ($start_time) { - $problem_set_attempt->{'startedAtTime'} = Caliper::Sensor::formatted_timestamp($start_time); + $problem_set_attempt->{startedAtTime} = Caliper::Sensor::formatted_timestamp($start_time); if ($end_time) { - $problem_set_attempt->{'endedAtTime'} = Caliper::Sensor::formatted_timestamp($end_time); - $problem_set_attempt->{'duration'} = Caliper::Sensor::formatted_duration($end_time - $start_time); + $problem_set_attempt->{endedAtTime} = Caliper::Sensor::formatted_timestamp($end_time); + $problem_set_attempt->{duration} = Caliper::Sensor::formatted_duration($end_time - $start_time); } } diff --git a/lib/Caliper/Event.pm b/lib/Caliper/Event.pm index 35b1f69eeb..8c4773ceb8 100644 --- a/lib/Caliper/Event.pm +++ b/lib/Caliper/Event.pm @@ -1,47 +1,26 @@ package Caliper::Event; +use Mojo::Base -signatures; -##### Library Imports ##### -use strict; -use warnings; -use WeBWorK::CourseEnvironment; -use WeBWorK::DB; -use Data::Dumper; -use Data::UUID; +use UUID::Tiny ':std'; use Caliper::Actor; +use Caliper::Entity; use Caliper::Sensor; -# Constructor -sub add_defaults { - my ($c, $event_hash) = @_; - my $ce = $c->ce; - my $db = $c->db; - my $ug = Data::UUID->new; +sub add_defaults ($c, $event_hash) { + my $user_id = $c->param('user'); + my $actor = Caliper::Actor::generate_actor($c, $user_id); - my $user_id = $c->param('user'); - my $session_key = $c->param('key'); - my $uuid = $ug->create_str; - my $actor = Caliper::Actor::generate_actor($ce, $db, $user_id); - - if (!exists($event_hash->{'@context'})) { - $event_hash->{'@context'} = 'http://purl.imsglobal.org/ctx/caliper/v1p2'; - } - $event_hash->{'id'} = 'urn:uuid:' . $uuid; - $event_hash->{'actor'} = $actor; - $event_hash->{'session'} = Caliper::Entity::session($ce, $db, $actor, $session_key); - $event_hash->{'edApp'} = Caliper::Entity::webwork_app($ce, $db); - $event_hash->{'group'} = Caliper::Entity::course($ce, $db); - $event_hash->{'membership'} = Caliper::Entity::membership($ce, $db, $actor, $user_id); - if (!exists($event_hash->{'eventTime'})) { - $event_hash->{'eventTime'} = Caliper::Sensor::formatted_timestamp(time()); - } - - if (!exists($event_hash->{'extensions'})) { - $event_hash->{'extensions'} = (); - } - if (defined($ENV{HTTP_REFERER})) { - $event_hash->{'extensions'}{'referer'} = $ENV{HTTP_REFERER}; - } + $event_hash->{'@context'} = 'http://purl.imsglobal.org/ctx/caliper/v1p2' unless exists $event_hash->{'@context'}; + $event_hash->{id} = 'urn:uuid:' . create_uuid_as_string(UUID_V4); + $event_hash->{actor} = $actor; + $event_hash->{session} = Caliper::Entity::session($c, $actor, $c->param('key') // ''); + $event_hash->{edApp} = Caliper::Entity::webwork_app($c); + $event_hash->{group} = Caliper::Entity::course($c); + $event_hash->{membership} = Caliper::Entity::membership($c, $actor, $user_id); + $event_hash->{eventTime} = Caliper::Sensor::formatted_timestamp(time) unless exists $event_hash->{eventTime}; + $event_hash->{extensions} = () unless exists $event_hash->{extensions}; + $event_hash->{extensions}{referer} = $c->req->headers->referer if defined $c->req->headers->referer; return; } diff --git a/lib/Caliper/ResourceIri.pm b/lib/Caliper/ResourceIri.pm index f73d5df2f3..95e232e6d3 100644 --- a/lib/Caliper/ResourceIri.pm +++ b/lib/Caliper/ResourceIri.pm @@ -1,101 +1,65 @@ package Caliper::ResourceIri; +use Mojo::Base -signatures; -##### Library Imports ##### -use strict; -use warnings; -use WeBWorK::CourseEnvironment; -use WeBWorK::DB; -use Data::Dumper; - -# Constructor -sub new { - my ($class, $ce) = @_; - - # need to use $seed_ce in case of logout - my $seed_ce = WeBWorK::CourseEnvironment->new; - my $base_url = $seed_ce->{server_root_url} . $seed_ce->{webwork_url}; - if (defined($seed_ce->{caliper}{base_url}) && $seed_ce->{caliper}{base_url} ne '') { - $base_url = $seed_ce->{caliper}{base_url}; - } - if (substr($base_url, -1, 1) ne "/") { - $base_url .= "/"; - } - - my $self = { - ce => $ce, - base_url => $base_url, - }; - bless $self, $class; - return $self; +sub new ($class, $ce) { + my $base_url = $ce->{server_root_url} . $ce->{webwork_url}; + $base_url .= '/' if substr($base_url, -1, 1) ne '/'; + return bless { ce => $ce, base_url => $base_url }, $class; } -sub getBaseUrl { - my $self = shift; +sub getBaseUrl ($self) { return $self->{base_url}; } -sub webwork { - my $self = shift; - return $self->getBaseUrl(); +sub webwork ($self) { + return $self->getBaseUrl; } -sub course { - my $self = shift; - return $self->getBaseUrl() . $self->{ce}->{"courseName"} . '/'; +sub course ($self) { + return $self->getBaseUrl . $self->{ce}{courseName} . '/'; } -sub actor_homepage { - my ($self, $user_id) = @_; - return $self->course() . 'users/' . $user_id; +sub actor_homepage ($self, $user_id) { + return $self->course . 'users/' . $user_id; } -sub user_session { - my ($self, $session_key_hash) = @_; - return $self->getBaseUrl() . 'session/' . $session_key_hash; +sub user_session ($self, $session_key_hash) { + return $self->getBaseUrl . 'session/' . $session_key_hash; } -sub user_client { - my ($self, $session_key_hash) = @_; +sub user_client ($self, $session_key_hash) { return $self->user_session($session_key_hash) . '/client'; } -sub user_membership { - my ($self, $user_id) = @_; - return $self->course() . 'instructor/users2/?visible_users=' . $user_id; +sub user_membership ($self, $user_id) { + return $self->course . 'instructor/users2/?visible_users=' . $user_id; } -sub problem_set { - my ($self, $set_id) = @_; - return $self->course() . $set_id . '/'; +sub problem_set ($self, $set_id) { + return $self->course . $set_id . '/'; } -sub problem_set_user { - my ($self, $set_id, $user_id) = @_; +sub problem_set_user ($self, $set_id, $user_id) { return $self->problem_set($set_id) . '?effectiveUser=' . $user_id; } -sub problem { - my ($self, $set_id, $problem_id) = @_; +sub problem ($self, $set_id, $problem_id) { return $self->problem_set($set_id) . $problem_id . '/'; } -sub problem_user { - my ($self, $set_id, $problem_id, $user_id) = @_; +sub problem_user ($self, $set_id, $problem_id, $user_id) { return $self->problem($set_id, $problem_id) . '?effectiveUser=' . $user_id; } -sub answer { - my ($self, $set_id, $problem_id, $user_id) = @_; +sub answer ($self, $set_id, $problem_id, $user_id) { return $self->problem($set_id, $problem_id) . 'answer/' . '?effectiveUser=' . $user_id; } -sub answer_attempt { - my ($self, $set_id, $problem_id, $user_id, $answer_id) = @_; +sub answer_attempt ($self, $set_id, $problem_id, $user_id, $answer_id) { return $self->answer($set_id, $problem_id, $user_id) . '&answer_id=' . $answer_id; } -sub problem_set_attempt { - my ($self, $set_id, $user_id, $attempt) = @_; +sub problem_set_attempt ($self, $set_id, $user_id, $attempt) { return $self->problem_set_user($set_id, $user_id) . '&attempt=' . $attempt; } diff --git a/lib/Caliper/Sensor.pm b/lib/Caliper/Sensor.pm index 9913b2f0a2..7c89d072cd 100644 --- a/lib/Caliper/Sensor.pm +++ b/lib/Caliper/Sensor.pm @@ -1,107 +1,99 @@ package Caliper::Sensor; +use Mojo::Base -signatures, -async_await; -##### Library Imports ##### -use strict; -use warnings; -use WeBWorK::CourseEnvironment; -use WeBWorK::DB; -use WeBWorK::Debug qw(debug); -use Data::Dumper; -use Mojo::JSON qw(encode_json); -use Time::HiRes qw/gettimeofday/; +use Mojo::UserAgent; +use Mojo::Promise; +use Time::HiRes qw(gettimeofday); use Date::Format; -use HTTP::Request::Common; -use HTTP::Async; - +use WeBWorK::Debug qw(debug); use Caliper::Event; use Caliper::ResourceIri; -# Constructor -sub new { - my ($class, $ce) = @_; +sub new ($class, $c) { my $self = { - ce => $ce, - enabled => $ce->{caliper}{enabled}, - host => $ce->{caliper}{host}, - api_key => $ce->{caliper}{api_key} + c => $c, + enabled => $c->ce->{caliper}{enabled}, + host => $c->ce->{caliper}{host}, + api_key => $c->ce->{caliper}{api_key} }; bless $self, $class; return $self; } -sub caliperEnabled { - my $self = shift; +sub caliperEnabled ($self) { return $self->{enabled} && exists $self->{host} && exists $self->{api_key}; } -sub sendEvent { - my ($self, $c, $event_hash) = @_; - - return $self->sendEvents($c, [$event_hash]); +async sub sendEvent ($self, $event_hash) { + return await $self->sendEvents([$event_hash]); } -sub sendEvents { - my ($self, $c, $array_of_events) = @_; - return 0 unless $self->caliperEnabled(); +async sub sendEvents ($self, $array_of_events) { + return 0 unless $self->caliperEnabled; + my $c = $self->{c}; for my $event_hash (@$array_of_events) { Caliper::Event::add_defaults($c, $event_hash); } - my $ce = $c->ce; - my $resource_iri = Caliper::ResourceIri->new($ce); - my $async = HTTP::Async->new; - $async->timeout(5); - $async->max_request_time(10); + my $ce = $c->ce; + my $ua = Mojo::UserAgent->new; + $ua->inactivity_timeout(5); + $ua->request_timeout(10); - # chunk events to prevent size issues (send a maximum of 3 events at a time) + # Chunk events to prevent size issues (send a maximum of 3 events at a time). my $event_chunks = []; push(@$event_chunks, [ splice @$array_of_events, 0, 3 ]) while @$array_of_events; + my @promises; + for my $event_chunk (@$event_chunks) { - my $envelope = { - 'sensor' => $resource_iri->webwork(), - 'sendTime' => Caliper::Sensor::formatted_timestamp(time()), - 'dataVersion' => 'http://purl.imsglobal.org/ctx/caliper/v1p2', - 'data' => $event_chunk, - }; - - my $json_payload = encode_json($envelope); - # debug("Caliper event json_payload: " . $json_payload); - - my $HTTPRequest = HTTP::Request->new( - 'POST', - $self->{host}, - [ - 'Accept' => '*/*', - 'Authorization' => 'Bearer ' . $self->{api_key}, - 'Content-Type' => 'application/json', - ], - $json_payload + push( + @promises, + $ua->post_p( + $self->{host}, + { + Accept => '*/*', + Authorization => 'Bearer ' . $self->{api_key}, + 'Content-Type' => 'application/json', + }, + json => { + sensor => Caliper::ResourceIri->new($ce)->webwork, + sendTime => formatted_timestamp(time), + dataVersion => 'http://purl.imsglobal.org/ctx/caliper/v1p2', + data => $event_chunk + } + ) ); - $async->add($HTTPRequest); } - while (my $response = $async->wait_for_next_response) { - if (!$response->is_success) { - debug("Caliper event post failed. Error Message: " . $response->message); - debug($response->content); - $self->log_error("Caliper event post failed. Error Message: " - . $response->message + my @responses = await Mojo::Promise->all(@promises)->catch(sub { + my $err = shift; + $self->log_error(ref $err ? $err->message : $err); + return; + }); + + for my $response (@responses) { + my $result = $response->[0]->result; + if (!$result->is_success) { + debug('Caliper event post failed. Error Message: ' . $result->message); + debug($result->body); + $self->log_error('Caliper event post failed. Error Message: ' + . $result->message . "\nResponse Content: " - . $response->content); + . $result->body); } else { - debug("Caliper event post success. Success Message: " . $response->message); - debug($response->content); + debug('Caliper event post success. Success Message: ' . $result->message); + debug($result->body); } } + return; } -sub log_error { - my ($self, $error_message) = @_; - my $ce = $self->{ce}; +sub log_error ($self, $error_message) { + my $ce = $self->{c}->ce; my $logfile = $ce->{caliper}{errorlog}; my ($sec, $msec) = gettimeofday; @@ -110,11 +102,11 @@ sub log_error { # create if necessary unless (-e $logfile) { - open my $fc, ">", $logfile; + open my $fc, '>', $logfile; close $fc; } # append message - if (open my $f, ">>", $logfile) { + if (open my $f, '>>', $logfile) { print $f $msg; close $f; } else { @@ -123,28 +115,20 @@ sub log_error { return; } -sub formatted_timestamp { - my ($time_value) = @_; - # Note: webwork epoch timestamps do not include milliseconds - return POSIX::strftime("%Y-%m-%dT%H:%M:%S.000Z", gmtime($time_value)); +sub formatted_timestamp ($time_value) { + return POSIX::strftime('%Y-%m-%dT%H:%M:%S.000Z', gmtime($time_value)); } -sub formatted_duration { - my ($duration) = @_; - - # generate the time portion of a ISO 8601 formatted duration +sub formatted_duration ($duration) { + # Generate the time portion of a ISO 8601 formatted duration. my $seconds = $duration % 60; my $minutes = int($duration / 60) % 60; my $hours = int($duration / 3600); - my $output = "PT"; - if ($hours > 0) { - $output .= $hours . "H"; - } - if ($hours > 0 || $minutes > 0) { - $output .= $minutes . "M"; - } - $output .= $seconds . "S"; + my $output = 'PT'; + $output .= $hours . 'H' if $hours > 0; + $output .= $minutes . 'M' if $hours > 0 || $minutes > 0; + $output .= $seconds . 'S'; return $output; } diff --git a/lib/Mojolicious/WeBWorK/Plugin/Caliper.pm b/lib/Mojolicious/WeBWorK/Plugin/Caliper.pm new file mode 100644 index 0000000000..4f69cfb177 --- /dev/null +++ b/lib/Mojolicious/WeBWorK/Plugin/Caliper.pm @@ -0,0 +1,206 @@ +package Mojolicious::WeBWorK::Plugin::Caliper; +use Mojo::Base 'Mojolicious::Plugin', -signatures, -async_await; + +use Caliper::Sensor; +use Caliper::Entity; + +sub register ($plugin, $app, $config) { + $app->hook( + user_login => async sub ($c) { + my $caliper_sensor = Caliper::Sensor->new($c); + return unless $caliper_sensor->caliperEnabled; + await $caliper_sensor->sendEvents([ { + type => 'SessionEvent', + action => 'LoggedIn', + profile => 'SessionProfile', + object => Caliper::Entity::webwork_app($c) + } ]); + return; + } + ); + + $app->hook( + user_logout => async sub ($c) { + my $caliper_sensor = Caliper::Sensor->new($c); + return unless $caliper_sensor->caliperEnabled; + await $caliper_sensor->sendEvents([ { + type => 'SessionEvent', + action => 'LoggedOut', + profile => 'SessionProfile', + object => Caliper::Entity::webwork_app($c) + } ]); + return; + } + ); + + $app->hook( + answer_submitted => async sub ($c) { + my $ce = $c->ce; + my $db = $c->db; + + my $caliper_sensor = Caliper::Sensor->new($c); + if ($caliper_sensor->caliperEnabled + && defined $ce->{courseFiles}{logs}{answer_log} + && !$c->authz->hasPermissions($c->param('effectiveUser'), 'dont_log_past_answers')) + { + # Make sure the transaction does not go out of scope so + # that the startTime parameter can be reset afterward. + my $tx = $c->render_later->tx; + + my $startTime = $c->param('startTime'); + my $endTime = time; + + await $caliper_sensor->sendEvents([ + { + type => 'AssessmentItemEvent', + action => 'Completed', + profile => 'AssessmentProfile', + object => Caliper::Entity::problem_user( + $c, + $c->{problem}->set_id, + 0, # Version is 0 for non-gateway problems. + $c->{problem}->problem_id, + $c->{problem}->user_id, + $c->{pg} + ), + generated => Caliper::Entity::answer( + $c, + $c->{problem}->set_id, + 0, # Version is 0 for non-gateway problems. + $c->{problem}->problem_id, + $c->{problem}->user_id, + $c->{pg}, + $startTime, + $endTime + ), + }, + { + type => 'AssessmentEvent', + action => 'Submitted', + profile => 'AssessmentProfile', + object => Caliper::Entity::problem_set($c, $c->{problem}->set_id), + generated => Caliper::Entity::problem_set_attempt( + $c, + $c->{problem}->set_id, + 0, # Version is 0 for non-gateway problems. + $c->{problem}->user_id, + $startTime, + $endTime + ), + }, + { + type => 'ToolUseEvent', + action => 'Used', + profile => 'ToolUseProfile', + object => Caliper::Entity::webwork_app($c) + } + ]); + + # Reset start time. + $c->param('startTime', ''); + } + + return; + } + ); + + $app->hook( + test_answers_submitted => async sub ($c) { + my $ce = $c->ce; + my $db = $c->db; + + my $caliper_sensor = Caliper::Sensor->new($c); + if ($caliper_sensor->caliperEnabled && defined $ce->{courseFiles}{logs}{answer_log}) { + # Make sure the transaction does not go out of scope so + # that the startTime parameter can be reset afterward. + my $tx = $c->render_later->tx; + + my $events = []; + + my $setID = $c->stash('setID') =~ s/,v\d+$//r; + + my $startTime = $c->param('startTime'); + my $endTime = int($c->submitTime); + if ($c->{submitAnswers} && $c->{will}{recordAnswers}) { + for my $i (0 .. $#{ $c->stash->{problems} }) { + my $problem = $c->stash->{problems}[ $c->stash->{probOrder}[$i] ]; + my $pg = $c->stash->{pg_results}[ $c->stash->{probOrder}[$i] ]; + push( + @$events, + { + type => 'AssessmentItemEvent', + action => 'Completed', + profile => 'AssessmentProfile', + object => Caliper::Entity::problem_user( + $c, $problem->set_id, $c->{set}->version_id, + $problem->problem_id, $problem->user_id, $pg + ), + generated => Caliper::Entity::answer( + $c, + $problem->set_id, + $c->{set}->version_id, + $problem->problem_id, + $problem->user_id, + $pg, + 0, + 0 # Don't track start/end time for gateway problems (multiple answers per page). + ), + } + ); + } + push( + @$events, + { + type => 'AssessmentEvent', + action => 'Submitted', + profile => 'AssessmentProfile', + object => Caliper::Entity::problem_set($c, $setID), + generated => Caliper::Entity::problem_set_attempt( + $c, $setID, + $c->{set}->version_id, + $c->param('effectiveUser'), + $startTime, $endTime + ), + } + ); + } else { + push( + @$events, + { + type => 'AssessmentEvent', + action => 'Paused', + profile => 'AssessmentProfile', + object => Caliper::Entity::problem_set($c, $setID), + generated => Caliper::Entity::problem_set_attempt( + $c, $setID, + $c->{set}->version_id, + $c->param('effectiveUser'), + $startTime, $endTime + ), + } + ); + } + push( + @$events, + { + type => 'ToolUseEvent', + action => 'Used', + profile => 'ToolUseProfile', + object => Caliper::Entity::webwork_app($c) + } + ); + + await $caliper_sensor->sendEvents($events); + + # Reset start time. + $c->param('startTime', ''); + } + + return; + } + ); + + return; +} + +1; diff --git a/lib/WeBWorK/Authen.pm b/lib/WeBWorK/Authen.pm index f1f72f2546..5b0b3924f1 100644 --- a/lib/WeBWorK/Authen.pm +++ b/lib/WeBWorK/Authen.pm @@ -45,8 +45,6 @@ use WeBWorK::Utils qw(x runtime_use utf8Crypt cryptPassword); use WeBWorK::Utils::Logs qw(writeCourseLog); use WeBWorK::Utils::TOTP; use WeBWorK::Localize; -use Caliper::Sensor; -use Caliper::Entity; use constant GENERIC_ERROR_MESSAGE => x('Invalid user ID or password.'); @@ -202,18 +200,7 @@ sub verify { $c->stash(authen_error => $self->{error}) if $self->{error} && $self->{error} =~ /\S/; } - my $caliper_sensor = Caliper::Sensor->new($c->ce); - if ($caliper_sensor->caliperEnabled && $self->{was_verified} && $self->{initial_login}) { - $caliper_sensor->sendEvents( - $c, - [ { - 'type' => 'SessionEvent', - 'action' => 'LoggedIn', - 'profile' => 'SessionProfile', - 'object' => Caliper::Entity::webwork_app() - } ] - ); - } + $c->app->plugins->emit_hook(user_login => $c) if $self->{initial_login} && $self->{was_verified}; debug("END VERIFY"); debug("result $self->{was_verified}"); @@ -964,18 +951,7 @@ sub killSession { my $c = $self->{c}; my $ce = $c->{ce}; - my $caliper_sensor = Caliper::Sensor->new($ce); - if ($caliper_sensor->caliperEnabled) { - $caliper_sensor->sendEvents( - $c, - [ { - 'type' => 'SessionEvent', - 'action' => 'LoggedOut', - 'profile' => 'SessionProfile', - 'object' => Caliper::Entity::webwork_app() - } ] - ); - } + $c->app->plugins->emit_hook(user_logout => $c); $self->forget_verification; $self->killCookie; diff --git a/lib/WeBWorK/ContentGenerator/GatewayQuiz.pm b/lib/WeBWorK/ContentGenerator/GatewayQuiz.pm index f18b4ca492..2ac78ada2a 100644 --- a/lib/WeBWorK/ContentGenerator/GatewayQuiz.pm +++ b/lib/WeBWorK/ContentGenerator/GatewayQuiz.pm @@ -23,8 +23,6 @@ use WeBWorK::DB::Utils qw(global2user fake_set fake_set_version f use WeBWorK::Debug qw(debug); use PGrandom; use WeBWorK::Authen::LTI::GradePassback qw(passbackGradeOnSubmit); -use Caliper::Sensor; -use Caliper::Entity; # Disable links for gateway tests. sub can ($c, $arg) { @@ -1136,72 +1134,7 @@ async sub pre_header_initialize ($c) { } } - my $caliper_sensor = Caliper::Sensor->new($c->ce); - if ($caliper_sensor->caliperEnabled() && defined $answer_log) { - my $events = []; - - my $startTime = $c->param('startTime'); - my $endTime = int($c->submitTime); - if ($c->{submitAnswers} && $will{recordAnswers}) { - for my $i (0 .. $#problems) { - my $problem = $problems[ $probOrder[$i] ]; - my $pg = $pg_results[ $probOrder[$i] ]; - my $completed_question_event = { - 'type' => 'AssessmentItemEvent', - 'action' => 'Completed', - 'profile' => 'AssessmentProfile', - 'object' => Caliper::Entity::problem_user( - $c->ce, $db, $problem->set_id(), $versionID, $problem->problem_id(), - $problem->user_id(), $pg - ), - 'generated' => Caliper::Entity::answer( - $c->ce, - $db, - $problem->set_id(), - $versionID, - $problem->problem_id(), - $problem->user_id(), - $pg, - 0, # don't track start/end time for gateway problems (multiple answers per page) - 0 # don't track start/end time for gateway problems (multiple answers per page) - ), - }; - push @$events, $completed_question_event; - } - my $submitted_set_event = { - 'type' => 'AssessmentEvent', - 'action' => 'Submitted', - 'profile' => 'AssessmentProfile', - 'object' => Caliper::Entity::problem_set($c->ce, $db, $setID), - 'generated' => Caliper::Entity::problem_set_attempt( - $c->ce, $db, $setID, $versionID, $effectiveUserID, $startTime, $endTime - ), - }; - push @$events, $submitted_set_event; - } else { - my $paused_set_event = { - 'type' => 'AssessmentEvent', - 'action' => 'Paused', - 'profile' => 'AssessmentProfile', - 'object' => Caliper::Entity::problem_set($c->ce, $db, $setID), - 'generated' => Caliper::Entity::problem_set_attempt( - $c->ce, $db, $setID, $versionID, $effectiveUserID, $startTime, $endTime - ), - }; - push @$events, $paused_set_event; - } - my $tool_use_event = { - 'type' => 'ToolUseEvent', - 'action' => 'Used', - 'profile' => 'ToolUseProfile', - 'object' => Caliper::Entity::webwork_app(), - }; - push @$events, $tool_use_event; - $caliper_sensor->sendEvents($c, $events); - - # Reset start time - $c->param('startTime', ''); - } + $c->app->plugins->emit_hook('test_answers_submitted' => $c); } debug('end answer processing'); diff --git a/lib/WeBWorK/Utils/ProblemProcessing.pm b/lib/WeBWorK/Utils/ProblemProcessing.pm index 6aa4d0d302..9f4a3241a9 100644 --- a/lib/WeBWorK/Utils/ProblemProcessing.pm +++ b/lib/WeBWorK/Utils/ProblemProcessing.pm @@ -18,8 +18,6 @@ use WeBWorK::Utils::DateTime qw(before after); use WeBWorK::Utils::JITAR qw(jitar_id_to_seq jitar_problem_adjusted_status); use WeBWorK::Utils::Logs qw(writeLog writeCourseLog); use WeBWorK::Authen::LTI::GradePassback qw(passbackGradeOnSubmit); -use Caliper::Sensor; -use Caliper::Entity; our @EXPORT_OK = qw( process_and_log_answer @@ -149,66 +147,7 @@ async sub process_and_log_answer ($c) { . $pureProblem->num_correct . "\t" . $pureProblem->num_incorrect); - if ($ce->{caliper}{enabled} - && defined($answer_log) - && !$authz->hasPermissions($effectiveUser, 'dont_log_past_answers')) - { - my $caliper_sensor = Caliper::Sensor->new($ce); - my $startTime = $c->param('startTime'); - my $endTime = time(); - - my $completed_question_event = { - type => 'AssessmentItemEvent', - action => 'Completed', - profile => 'AssessmentProfile', - object => Caliper::Entity::problem_user( - $ce, - $db, - $problem->set_id(), - 0, #version is 0 for non-gateway problems - $problem->problem_id(), - $problem->user_id(), - $pg - ), - generated => Caliper::Entity::answer( - $ce, - $db, - $problem->set_id(), - 0, #version is 0 for non-gateway problems - $problem->problem_id(), - $problem->user_id(), - $pg, - $startTime, - $endTime - ), - }; - my $submitted_set_event = { - type => 'AssessmentEvent', - action => 'Submitted', - profile => 'AssessmentProfile', - object => Caliper::Entity::problem_set($ce, $db, $problem->set_id()), - generated => Caliper::Entity::problem_set_attempt( - $ce, - $db, - $problem->set_id(), - 0, #version is 0 for non-gateway problems - $problem->user_id(), - $startTime, - $endTime - ), - }; - my $tool_use_event = { - type => 'ToolUseEvent', - action => 'Used', - profile => 'ToolUseProfile', - object => Caliper::Entity::webwork_app(), - }; - $caliper_sensor->sendEvents($c, - [ $completed_question_event, $submitted_set_event, $tool_use_event ]); - - # reset start time - $c->param('startTime', ''); - } + $c->app->plugins->emit_hook('answer_submitted' => $c); # Send the score for this set to the LMS if enabled. if ($ce->{LTIGradeMode}) { diff --git a/lib/WeBWorK/Utils/Rendering.pm b/lib/WeBWorK/Utils/Rendering.pm index bdad7c6164..394cd3702d 100644 --- a/lib/WeBWorK/Utils/Rendering.pm +++ b/lib/WeBWorK/Utils/Rendering.pm @@ -272,9 +272,6 @@ sub renderPG ($c, $effectiveUser, $set, $problem, $psvn, $formFields, $translati $ret->{render_fail} = 1; } - # Save the problem source. This is used by Caliper::Entity. Why? - $ret->{problem_source_code} = $pg->{translator}{source} if ref $pg->{translator}; - $pg->free; return $ret; })->catch(sub ($err) {