# The LearningOnline Network with CAPA
# .helper XML handler to implement the LON-CAPA helper
#
# $Id: lonhelper.pm,v 1.204 2022/06/27 20:35:51 raeburn Exp $
#
# Copyright Michigan State University Board of Trustees
#
# This file is part of the LearningOnline Network with CAPA (LON-CAPA).
#
# LON-CAPA is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# LON-CAPA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with LON-CAPA; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# /home/httpd/html/adm/gpl.txt
#
# http://www.lon-capa.org/
#
=pod
=head1 NAME
lonhelper - implements helper framework
=head1 SYNOPSIS
lonhelper implements the helper framework for LON-CAPA, and provides
many generally useful components for that framework.
Helpers are little programs which present the user with a sequence of
simple choices, instead of one monolithic multi-dimensional
choice. They are also referred to as "wizards", "druids", and
other potentially trademarked or semantically-loaded words.
=head1 OVERVIEWX
Helpers are well-established UI widgets that users
feel comfortable with. It can take a complicated multidimensional problem the
user has and turn it into a series of bite-sized one-dimensional questions.
For developers, helpers provide an easy way to bundle little bits of functionality
for the user, without having to write the tedious state-maintenence code.
Helpers are defined as XML documents, placed in the /home/httpd/html/adm/helpers
directory and having the .helper file extension. For examples, see that directory.
All classes are in the Apache::lonhelper namespace.
=head1 lonhelper XML file formatX
A helper consists of a top-level tag which contains a series of states.
Each state contains one or more state elements, which are what the user sees, like
messages, resource selections, or date queries.
The helper tag is required to have one attribute, "title", which is the name
of the helper itself, such as "Parameter helper". The helper tag may optionally
have a "requiredpriv" attribute, specifying the privilege a user must have
to use the helper, or get denied access. See loncom/auth/rolesplain.tab for
useful privs. You may add the modifier &S at the end of the three letter priv
if you want to grant access to users for whom the corresponding privilege is
section-specific. The default is full access, which is often wrong!
=head2 State tags
State tags are required to have an attribute "name", which is the symbolic
name of the state and will not be directly seen by the user. The helper is
required to have one state named "START", which is the state the helper
will start with. By convention, this state should clearly describe what
the helper will do for the user, and may also include the first information
entry the user needs to do for the helper.
State tags are also required to have an attribute "title", which is the
human name of the state, and will be displayed as the header on top of
the screen for the user.
State tags may also optionally have an attribute "help" which should be
the filename of a help file, this will add a blue ? to the title.
=head2 Example Helper Skeleton
An example of the tags so far:
Of course this does nothing. In order for the helper to do something, it is
necessary to put actual elements into the helper. Documentation for each
of these elements follows.
=head1 Creating a Helper With Code, Not XML
In some situations, such as the printing helper (see lonprintout.pm),
writing the helper in XML would be too complicated, because of scope
issues or the fact that the code actually outweighs the XML. It is
possible to create a helper via code, though it is a little odd.
Creating a helper via code is more like issuing commands to create
a helper then normal code writing. For instance, elements will automatically
be added to the last state created, so it's important to create the
states in the correct order.
First, create a new helper:
use Apache::lonhelper;
my $helper = Apache::lonhelper::new->("Helper Title");
Next you'll need to manually add states to the helper:
Apache::lonhelper::state->new("STATE_NAME", "State's Human Title");
You don't need to save a reference to it because all elements up until
the next state creation will automatically be added to this state.
Elements are created by populating the $paramHash in
Apache::lonhelper::paramhash. To prevent namespace issues, retrieve
a reference to that has with getParamHash:
my $paramHash = Apache::lonhelper::getParamHash();
You will need to do this for each state you create.
Populate the $paramHash with the parameters for the element you wish
to add next; the easiest way to find out what those entries are is
to read the code. Some common ones are 'variable' to record the variable
to store the results in, and NEXTSTATE to record a next state transition.
Then create your element:
$paramHash->{MESSAGETEXT} = "This is a message.";
Apache::lonhelper::message->new();
The creation will take the $paramHash and bless it into a
Apache::lonhelper::message object. To create the next element, you need
to get a reference to the new, empty $paramHash:
$paramHash = Apache::lonhelper::getParamHash();
and you can repeat creating elements that way. You can add states
and elements as needed.
See lonprintout.pm, subroutine printHelper for an example of this, where
we dynamically add some states to prevent security problems, for instance.
Normally the machinery in the XML format is sufficient; dynamically
adding states can easily be done by wrapping the state in a
tag. This should only be used when the code dominates the XML content,
the code is so complicated that it is difficult to get access to
all of the information you need because of scoping issues, or would-be or
blocks using the {DATA} mechanism results in hard-to-read
and -maintain code. (See course.initialization.helper for a borderline
case.)
It is possible to do some of the work with an XML fragment parsed by
lonxml; again, see lonprintout.pm for an example. In that case it is
imperative that you call B
before parsing XML fragments and B
when you are done. See lonprintout.pm for examples of this usage in the
printHelper subroutine.
=head2 Localization
The helper framework tries to handle as much localization as
possible. The text is always run through
Apache::lonlocal::normalize_string, so be sure to run the keys through
that function for maximum usefulness and robustness.
=cut
package Apache::lonhelper;
use Apache::Constants qw(:common);
use Apache::File;
use Apache::lonxml;
use Apache::lonlocal;
use Apache::lonnet;
use Apache::longroup;
use Apache::lonselstudent;
use LONCAPA;
# Register all the tags with the helper, so the helper can
# push and pop them
my @helperTags;
sub register {
my ($namespace, @tags) = @_;
for my $tag (@tags) {
push @helperTags, [$namespace, $tag];
}
}
BEGIN {
Apache::lonxml::register('Apache::lonhelper',
('helper'));
register('Apache::lonhelper', ('state'));
}
# Since all helpers are only three levels deep (helper tag, state tag,
# substate type), it's easier and more readble to explicitly track
# those three things directly, rather then futz with the tag stack
# every time.
my $helper;
my $state;
my $substate;
# To collect parameters, the contents of the subtags are collected
# into this paramHash, then passed to the element object when the
# end of the element tag is located.
my $paramHash;
# Note from Jeremy 5-8-2003: It is *vital* that the real handler be called
# as a subroutine from the handler, or very mysterious things might happen.
# I don't know exactly why, but it seems that the scope where the Apache
# server enters the perl handler is treated differently from the rest of
# the handler. This also seems to manifest itself in the debugger as entering
# the perl handler in seemingly random places (sometimes it starts in the
# compiling phase, sometimes in the handler execution phase where it runs
# the code and stepping into the "1;" the module ends with goes into the handler,
# sometimes starting directly with the handler); I think the cause is related.
# In the debugger, this means that breakpoints are ignored until you step into
# a function and get out of what must be a "faked up scope" in the Apache->
# mod_perl connection. In this code, it was manifesting itself in the existence
# of two separate file-scoped $helper variables, one set to the value of the
# helper in the helper constructor, and one referenced by the handler on the
# "$helper->process()" line. Using the debugger, one could actually
# see the two different $helper variables, as hashes at completely
# different addresses. The second was therefore never set, and was still
# undefined when I tried to call process on it.
# By pushing the "real handler" down into the "real scope", everybody except the
# actual handler function directly below this comment gets the same $helper and
# everybody is happy.
# The upshot of all of this is that for safety when a handler is using
# file-scoped variables in LON-CAPA, the handler should be pushed down one
# call level, as I do here, to ensure that the top-level handler function does
# not get a different file scope from the rest of the code.
sub handler {
my $r = shift;
return real_handler($r);
}
# For debugging purposes, one can send a second parameter into this
# function, the 'uri' of the helper you wish to have rendered, and
# call this from other handlers.
sub real_handler {
my $r = shift;
my $uri = shift;
if (!defined($uri)) { $uri = $r->uri(); }
$env{'request.uri'} = $uri;
my $filename = $r->dir_config('lonDocRoot').$uri;
my $fh = Apache::File->new($filename);
my $file;
read $fh, $file, 100000000;
# Send header, don't cache this page
if ($env{'browser.mathml'}) {
&Apache::loncommon::content_type($r,'text/xml');
} else {
&Apache::loncommon::content_type($r,'text/html');
}
$r->send_http_header;
return OK if $r->header_only;
$r->rflush();
# Discard result, we just want the objects that get created by the
# xml parsing
&Apache::lonxml::xmlparse($r, 'helper', $file);
my $allowed = $helper->allowedCheck();
if (!$allowed) {
my ($priv,$modifier) = split(/\&/,$helper->{REQUIRED_PRIV});
$env{'user.error.msg'} = $env{'request.uri'}.':'.$priv.
":0:0:Permission denied to access this helper.";
return HTTP_NOT_ACCEPTABLE;
}
$helper->process();
$r->print($helper->display());
return OK;
}
sub registerHelperTags {
for my $tagList (@helperTags) {
Apache::lonxml::register($tagList->[0], $tagList->[1]);
}
}
sub unregisterHelperTags {
for my $tagList (@helperTags) {
Apache::lonxml::deregister($tagList->[0], $tagList->[1]);
}
}
sub start_helper {
my ($target,$token,$tagstack,$parstack,$parser,$safeeval,$style)=@_;
if ($target ne 'helper') {
return '';
}
registerHelperTags();
Apache::lonhelper::helper->new($token->[2]{'title'}, $token->[2]{'requiredpriv'});
return '';
}
sub end_helper {
my ($target,$token,$tagstack,$parstack,$parser,$safeeval,$style)=@_;
if ($target ne 'helper') {
return '';
}
unregisterHelperTags();
return '';
}
sub start_state {
my ($target,$token,$tagstack,$parstack,$parser,$safeeval,$style)=@_;
if ($target ne 'helper') {
return '';
}
Apache::lonhelper::state->new($token->[2]{'name'},
$token->[2]{'title'},
$token->[2]{'help'});
return '';
}
# Use this to get the param hash from other files.
sub getParamHash {
return $paramHash;
}
# Use this to get the helper, if implementing elements in other files
# (like lonprintout.pm)
sub getHelper {
return $helper;
}
# don't need this, so ignore it
sub end_state {
return '';
}
1;
package Apache::lonhelper::helper;
use Digest::MD5 qw(md5_hex);
use HTML::Entities();
use Apache::loncommon;
use Apache::File;
use Apache::lonlocal;
use Apache::lonnet;
use LONCAPA;
sub new {
my $proto = shift;
my $class = ref($proto) || $proto;
my $self = {};
$self->{TITLE} = shift;
$self->{REQUIRED_PRIV} = shift;
# If there is a state from the previous form, use that. If there is no
# state, use the start state parameter.
if (defined $env{"form.CURRENT_STATE"})
{
$self->{STATE} = $env{"form.CURRENT_STATE"};
}
else
{
$self->{STATE} = "START";
}
$self->{TOKEN} = $env{'form.TOKEN'};
# If a token was passed, we load that in. Otherwise, we need to create a
# new storage file
# Tried to use standard Tie'd hashes, but you can't seem to take a
# reference to a tied hash and write to it. I'd call that a wart.
if ($self->{TOKEN}) {
# Validate the token before trusting it
if ($self->{TOKEN} !~ /^[a-f0-9]{32}$/) {
# Not legit. Return nothing and let all hell break loose.
# User shouldn't be doing that!
return undef;
}
# Get the hash.
$self->{FILENAME} = $Apache::lonnet::tmpdir . md5_hex($self->{TOKEN}); # Note the token is not the literal file
my $file = Apache::File->new($self->{FILENAME});
my $contents = <$file>;
# Now load in the contents
for my $value (split (/&/, $contents)) {
my ($name, $value) = split(/=/, $value);
$value =~ s/%([a-fA-F0-9][a-fA-F0-9])/pack("C", hex($1))/eg;
$self->{VARS}->{$name} = $value;
}
$file->close();
} else {
# Only valid if we're just starting.
if ($self->{STATE} ne 'START') {
return undef;
}
# Must create the storage
$self->{TOKEN} = md5_hex($env{'user.name'} . $env{'user.domain'} .
time() . rand());
$self->{FILENAME} = $Apache::lonnet::tmpdir . md5_hex($self->{TOKEN});
}
# OK, we now have our persistent storage.
if (defined $env{"form.RETURN_PAGE"})
{
$self->{RETURN_PAGE} = $env{"form.RETURN_PAGE"};
}
else
{
$self->{RETURN_PAGE} = $ENV{REFERER};
}
$self->{STATES} = {};
$self->{DONE} = 0;
# Used by various helpers for various things; see lonparm.helper
# for an example.
$self->{DATA} = {};
$helper = $self;
# Establish the $paramHash
$paramHash = {};
bless($self, $class);
return $self;
}
# Private function; returns a string to construct the hidden fields
# necessary to have the helper track state.
sub _saveVars {
my $self = shift;
my $result = "";
$result .= '\n";
$result .= '\n";
$result .= '\n";
return $result;
}
# Private function: Create the querystring-like representation of the stored
# data to write to disk.
sub _varsInFile {
my $self = shift;
my @vars = ();
for my $key (keys(%{$self->{VARS}})) {
push(@vars, &escape($key) . '=' . &escape($self->{VARS}->{$key}));
}
return join ('&', @vars);
}
# Use this to declare variables.
# FIXME: Document this
sub declareVar {
my $self = shift;
my $var = shift;
if (!defined($self->{VARS}->{$var})) {
$self->{VARS}->{$var} = '';
}
my $envname = 'form.' . $var . '_forminput';
if (defined($env{$envname})) {
if (ref($env{$envname})) {
$self->{VARS}->{$var} = join('|||', @{$env{$envname}});
} else {
$self->{VARS}->{$var} = $env{$envname};
}
}
}
sub allowedCheck {
my $self = shift;
if (!defined($self->{REQUIRED_PRIV})) {
return 1;
}
my ($priv,$modifier) = split(/\&/,$self->{REQUIRED_PRIV});
my $allowed = &Apache::lonnet::allowed($priv,$env{'request.course.id'});
if ((!$allowed) && ($modifier eq 'S') && ($env{'request.course.sec'} ne '')) {
$allowed = &Apache::lonnet::allowed($priv,$env{'request.course.id'}.'/'.
$env{'request.course.sec'});
}
return $allowed;
}
sub changeState {
my $self = shift;
$self->{STATE} = shift;
}
sub registerState {
my $self = shift;
my $state = shift;
my $stateName = $state->name();
$self->{STATES}{$stateName} = $state;
}
sub process {
my $self = shift;
# Phase 1: Post processing for state of previous screen (which is actually
# the "current state" in terms of the helper variables), if it wasn't the
# beginning state.
if ($self->{STATE} ne "START" || $env{"form.SUBMIT"} eq &mt("Next")) {
my $prevState = $self->{STATES}{$self->{STATE}};
$prevState->postprocess();
}
# Note, to handle errors in a state's input that a user must correct,
# do not transition in the postprocess, and force the user to correct
# the error.
# Phase 2: Preprocess current state
my $startState = $self->{STATE};
my $state = $self->{STATES}->{$startState};
# For debugging, print something here to determine if you're going
# to an undefined state.
if (!defined($state)) {
return;
}
$state->preprocess();
# Phase 3: While the current state is different from the previous state,
# keep processing.
while ( $startState ne $self->{STATE} &&
defined($self->{STATES}->{$self->{STATE}}) )
{
$startState = $self->{STATE};
$state = $self->{STATES}->{$startState};
$state->preprocess();
}
return;
}
# 1: Do the post processing for the previous state.
# 2: Do the preprocessing for the current state.
# 3: Check to see if state changed, if so, postprocess current and move to next.
# Repeat until state stays stable.
# 4: Render the current state to the screen as an HTML page.
sub display {
my $self = shift;
my $footer = shift;
my $state = $self->{STATES}{$self->{STATE}};
my $result = "";
if (!defined($state)) {
$result = "Error: state '$state' not defined!";
return $result;
}
# Phase 4: Display.
my $stateTitle=&mt($state->title());
my $stateHelp= $state->help();
my $browser_searcher_js =
'';
# Breadcrumbs
my $brcrum = [{'href' => '',
'text' => 'Helper'}];
# FIXME: Dynamically add context sensitive breadcrumbs
# depending on the caller,
# e.g. printing, parametrization, etc.
# FIXME: Add breadcrumbs to reflect current helper state
$result .= &Apache::loncommon::start_page($self->{TITLE},
$browser_searcher_js,
{'bread_crumbs' => $brcrum,});
my $previous = HTML::Entities::encode(&mt("Back"), '<>&"');
my $next = HTML::Entities::encode(&mt("Next"), '<>&"');
# FIXME: This should be parameterized, not concatenated - Jeremy
if (!$state->overrideForm()) { $result.='
'; # '';
}
$result .= '
'.$stateTitle.$stateHelp.'
';
# $result .= '
';
# Top buttons
$result .= $buttons;
# Main content of current helper screen
if (!$state->overrideForm()) {
$result .= $self->_saveVars();
}
$result .= $state->render();
# Bottom buttons
$result .= $buttons;
#foreach my $key (keys(%{$self->{VARS}})) {
# $result .= "|$key| -> " . $self->{VARS}->{$key} . " ";
#}
# $result .= '