merge to 10219

This commit is contained in:
Colin Kuskie 2009-04-08 16:35:31 +00:00
parent ae28bf79c8
commit 4c1307e3d0
194 changed files with 8203 additions and 2134 deletions

View file

@ -0,0 +1,217 @@
package WebGUI::Asset::Wobject::Survey::ExpressionEngine;
=head1 NAME
Package WebGUI::Asset::Wobject::Survey::ExpressionEngine
=head1 DESCRIPTION
This class is used to process Survey gotoExpressions.
See L<run> for more details.
=cut
use strict;
use Params::Validate qw(:all);
use Safe;
use Data::Dumper;
use List::Util qw/sum/;
Params::Validate::validation_options( on_fail => sub { WebGUI::Error::InvalidParam->throw( error => shift ) } );
# We need these as semi-globals so that utility subs (which are shared with the safe compartment)
# can access them.
my $session;
my $values;
my $scores;
my $jump_count;
my $validate;
my $validTargets;
=head2 value
Utility sub that gives expressions access to recorded response values
value(question_variable) returns the recorded response value for the answer to question_variable
=cut
sub value($) {
my $key = shift;
my $value = $values->{$key};
$session->log->debug("[$key] resolves to [$value]");
return $value; # scalar variable, so no need to clone
}
=head2 score
Utility sub that gives expressions access to recorded response scores.
score(question_variable) returns the score for the answer selected for question_variable
score(section_variable) returns the summed score for the answers to all the questions in section_variable
=cut
sub score($) {
my $key = shift;
my $score = $scores->{$key};
$session->log->debug("[$key] resolves to [$score]");
return $score; # scalar variable, so no need to clone
}
=head2 jump
Utility sub shared with Safe compartment so that expressions can call individual jump tests.
Throws an exception containing the jump target when a jump matches, thus allowing L<run> to
catch the first successful jump.
=cut
sub jump(&$) {
my ( $sub, $target ) = @_;
$jump_count++;
# If $validTargets known, make sure target is valid
if ($validTargets && !exists $validTargets->{$target}) {
$session->log->debug("Invalid target [$target]");
if ($validate) {
die("Invalid jump target \"$target\""); # bail and report error
} else {
return; # skip jump but continue with expression
}
}
if ( $sub->() ) {
$session->log->debug("jump call #$jump_count is truthy");
die( { jump => $target } );
}
else {
$session->log->debug("jump call #$jump_count is falsey");
}
}
=head2 avg
Utility sub shared with Safe compartment to allows expressions to easily compute the average of a list
=cut
sub avg {
my @vals = @_;
return sum(@vals) / @vals;
}
=head2 run ( $session, $expression, $opts )
Class method.
Evaluates the given expression in a Safe compartment.
=head3 session
A WebGUI::Session
=head3 expression
The expression to run.
A gotoExpression is essentially a perl expression that gets evaluated in a Safe compartment.
To access Section/Question recorded response values, the expression calls L<value>.
To access Section/Question recorded response scores, the expression calls L<score>.
To trigger a jump, the expression calls L<jump>. The first truthy jump succeeds.
We also give expressions access to some useful utility subs such as avg(), and all of the
handy subs from List::Util (min, max, sum, etc..).
A very simple expression that checks if the response to s1q1 is 0 might look like:
jump { value(s1q1) == 0 } target
A more complicated gotoExpression with two possible jumps might look like:
jump { value(q1) > 5 and value(s2q1) =~ m/textmatch/ } target1;
jump { avg(value(q1), value(q2), value(q3)) > 10 } target2;
=head3 opts (optional)
Supported options are:
=over 3
=item * values
Hashref of values to make available to the expression via the L<value> utility sub
=item * scores
Hashref of scores to make available to the expression via the L<score> utility sub
=item* validTargets
A hashref of valid jump targets. If this is provided, all L<jump> calls will fail unless
the specified target is a key in the hashref.
=item * validate
Return errors rather than just logging them (useful for displaying survey validation errors to users)
=back
=cut
sub run {
my $class = shift;
my ( $s, $expression, $opts )
= validate_pos( @_, { isa => 'WebGUI::Session' }, { type => SCALAR }, { type => HASHREF, default => {} } );
# Init package globals
( $session, $values, $scores, $jump_count, $validate, $validTargets ) = ( $s, $opts->{values}, $opts->{scores}, 0, $opts->{validate}, $opts->{validTargets} );
if (!$session->config->get('enableSurveyExpressionEngine')) {
$session->log->debug('enableSurveyExpressionEngine config option disabled, skipping');
return;
}
# Create the Safe compartment
my $compartment = Safe->new();
# Share our utility subs with the compartment
$compartment->share('&value');
$compartment->share('&score');
$compartment->share('&jump');
$compartment->share('&avg');
# Give them all of List::Util too
$compartment->share_from('List::Util', ['&first', '&max', '&maxstr', '&min', '&minstr', '&reduce', '&shuffle', '&sum',]);
$session->log->debug("Expression is: \"$expression\"");
$compartment->reval($expression);
# See if we ran the engine just to check for errors
if ($opts->{validate}) {
if ($@ && ref $@ ne 'HASH') {
my $error = $@;
$error =~ s/(.*?) at .*/$1/s; # don't reveal too much
return $error;
}
return; # no validation errors
}
# A successful jump triggers a hashref containing the jump target to be thrown
if ( ref $@ && ref $@ eq 'HASH' && $@->{jump} ) {
my $jump = $@->{jump};
$session->log->debug("Returning [$jump]");
return $jump;
}
# Log all other errors (for example compile errors from bad expressions)
if ($@) {
$session->log->error($@);
}
# Return undef on failure
return;
}
1;

View file

@ -28,7 +28,7 @@ As a whole, this class represents the complete state of a user's response to a S
At the heart of this class is a perl hash that can be serialized
as JSON to the database to allow for storage and retrieval of the complete state
of a survey response.
Survey instances that allow users to record multiple responses will persist multiple
instances of this class to the database (one per distinct user response).
@ -40,7 +40,7 @@ number of questions answered (L<"questionsAnswered">) and the Survey start time
This package is not intended to be used by any other Asset in WebGUI.
=head2 surveyOrder
This data strucutre is an array (reference) of Survey addresses (see
L<WebGUI::Asset::Wobject::Survey::SurveyJSON/Address Parameter>), stored in the order
in which items are presented to the user.
@ -70,7 +70,7 @@ is stored in this hash reference.
Questions keys are constructed by hypenating the relevant L<"sIndex"> and L<"qIndex">.
Answer keys are constructed by hypenating the relevant L<"sIndex">, L<"qIndex"> and L<aIndex|"aIndexes">.
Question entries only contain a comment field:
{
...
@ -79,7 +79,7 @@ Question entries only contain a comment field:
}
...
}
Answers entries contain: value (the recorded value), time and comment fields.
{
@ -98,6 +98,7 @@ use strict;
use JSON;
use Params::Validate qw(:all);
use List::Util qw(shuffle);
use Safe;
Params::Validate::validation_options( on_fail => sub { WebGUI::Error::InvalidParam->throw( error => shift ) } );
#-------------------------------------------------------------------
@ -430,9 +431,9 @@ Processes and records submitted survey responses in the L<"responses"> data stru
Does terminal handling, and branch processing, and advances the L<"lastResponse"> index
if all required questions have been answered.
=head3 $responses
=head3 $submittedResponses
A hash ref of form param data. Each element should look like:
A hash ref of submitted form param data. Each element should look like:
{
"questionId-comment" => "question comment",
@ -459,11 +460,11 @@ gotoExpression in the set of questions wins.
sub recordResponses {
my $self = shift;
my ($responses) = validate_pos( @_, { type => HASHREF } );
my ($submittedResponses) = validate_pos( @_, { type => HASHREF } );
# Build a lookup table of non-multiple choice question types
my %knownTypes = map {$_ => 1} $self->survey->specialQuestionTypes;
my %knownTypes = map {$_ => 1} @{$self->survey->specialQuestionTypes};
# We want to record responses against the "next" response section and questions, since these are
# the items that have just been displayed to the user.
my $section = $self->nextResponseSection();
@ -517,37 +518,40 @@ sub recordResponses {
}
# Record Question comment
$self->responses->{ $question->{id} }->{comment} = $responses->{ $question->{id} . 'comment' };
$self->responses->{ $question->{id} }->{comment} = $submittedResponses->{ $question->{id} . 'comment' };
# Process Answers in Question..
for my $answer ( @{ $question->{answers} } ) {
# Pluck the values out of the responses hash that we want to record..
my $answerValue = $responses->{ $answer->{id} };
my $answerComment = $responses->{ $answer->{id} . 'comment' };
my $submittedAnswerResponse = $submittedResponses->{ $answer->{id} };
my $submittedAnswerComment = $submittedResponses->{ $answer->{id} . 'comment' };
# Proceed if we're satisfied that response is valid..
if ( defined $answerValue && $answerValue =~ /\S/ ) {
# Proceed if we're satisfied that the submitted answer response is valid..
if ( defined $submittedAnswerResponse && $submittedAnswerResponse =~ /\S/ ) {
$aAnswered = 1;
if ($knownTypes{$question->{questionType}}) {
$self->responses->{ $answer->{id} }->{value} = $answerValue;
} else {
# Unknown type, must be a multi-choice bundle
# For Multi-choice, use recordedAnswer instead of answerValue
$self->responses->{ $answer->{id} }->{value} = $answer->{recordedAnswer};
}
$self->responses->{ $answer->{id} }->{time} = time;
$self->responses->{ $answer->{id} }->{comment} = $answerComment;
# Now, decide what to record. For multi-choice questions, use recordedAnswer.
# Otherwise, we use the (raw) submitted response (e.g. text input, date input etc..)
$self->responses->{ $answer->{id} }->{value}
= $knownTypes{ $question->{questionType} }
? $submittedAnswerResponse
: $answer->{recordedAnswer};
$self->responses->{ $answer->{id} }->{time} = time;
$self->responses->{ $answer->{id} }->{comment} = $submittedAnswerComment;
# Handle terminal Answers..
if ( $answer->{terminal} ) {
$terminal = 1;
$terminalUrl = $answer->{terminalUrl};
}
# ..and also gotos..
elsif ( $answer->{goto} =~ /\w/ ) {
$goto = $answer->{goto};
}
# .. and also gotoExpressions..
elsif ( $answer->{gotoExpression} =~ /\w/ ) {
$gotoExpression = $answer->{gotoExpression};
@ -645,89 +649,26 @@ indicates that we should branch.
=head3 $gotoExpression
The gotoExpression.
A gotoExpression is a string representing a list of expressions (one per line) of the form:
target: expression
target: expression
...
This subroutine iterates through the list, processing each line and, all things being
well, evaluates the expression. The first expression to evaluate to true triggers a
call to goto($target).
The expression is a simple subset of the formula language used in spreadsheet programs
such as Excel, OpenOffice, Google Docs etc..
Here is an example using section variables S1 and S2 as jump targets and question
variables Q1-3 in the expression. It jumps to S1 if the user's answer to Q1 has a value
of 3, jumps to S2 if Q2 + Q3 < 10, and otherwise doesn't branch at all (the default).
S1: Q1 = 3
S2: Q2 + Q3 < 10
Arguments are evaluated as follows:
Numeric arguments evaluate as numbers
=over 4
=item * No support for strings (and hence no string matching)
=item * Question variable names (e.g. Q1) evaluate to the numeric value associated with
user's answer to that question, or undefined if the user has not answered that question
=back
Binary comparisons operators: = != < <= >= >
=over 4
=item * return boolean values based on perl's equivalent numeric comparison operators
=back
Simple math operators: + - * /
=over 4
=item * return numeric values
=back
Later we may add Boolean operators: AND( x; y; z; ... ), OR( x; y; z; ... ), NOT( x ), with args separated by
semicolons (presumably because spreadsheet formulas use commas to indicate cell ranges)
Later still you may be able to say AVG(section1) or SUM(section3) and have those functions automatically
compute their result over the set of all questions in the given section.
But for now those things can be done manually using the limited subset defined.
The gotoExpression. See L<WebGUI::Asset::Wobject::Survey::ExpressionEngine> for more info.
=cut
sub processGotoExpression {
my $self = shift;
my ($expression) = validate_pos(@_, {type => SCALAR});
my $responses = $self->recordedResponses();
# Parse gotoExpressions one after the other (first one that's true wins)
foreach my $line (split /\n/, $expression) {
my $processed = $self->parseGotoExpression($line, $responses);
next if !$processed;
# (ab)use perl's eval to evaluate the processed expression
my $result = eval "$processed->{expression}"; ## no critic
$self->session->log->warn($@) if $@; ## no critic
if ($result) {
$self->session->log->debug("Truthy, goto [$processed->{target}]");
$self->processGoto($processed->{target});
return $processed;
} else {
$self->session->log->debug('Falsy, not branching');
next;
}
# Prepare the ingredients..
my $values = $self->responseValuesByVariableName;
my $scores = $self->responseScoresByVariableName;
my %validTargets = map { $_ => 1 } @{$self->survey->getGotoTargets};
use WebGUI::Asset::Wobject::Survey::ExpressionEngine;
my $engine = "WebGUI::Asset::Wobject::Survey::ExpressionEngine";
if (my $jump = $engine->run($self->session, $expression, { values => $values, scores => $scores, validTargets => \%validTargets} )) {
$self->session->log->debug("Hit. Jumping to [$jump]");
$self->processGoto($jump);
}
$self->session->log->debug("No hits, falling through");
return;
}
@ -735,111 +676,129 @@ sub processGotoExpression {
=head2 recordedResponses
Returns a hash (reference) of question responses. The hash keys are
question variable names. The hash values are the corresponding answer
values selected by the user.
Returns an array or response information in this response's survey order.
=cut
sub recordedResponses {
sub recordedResponses{
my $self = shift;
my $responses= {
# questionName => response answer value
};
# Populate %responses with the user's data..
my $responses= [
# {answer info hash}
];
# Populate @$responses with the user's data..
for my $address ( @{ $self->surveyOrder } ) {
my $question = $self->survey->question( $address );
my ($sIndex, $qIndex) = (sIndex($address), qIndex($address));
for my $aIndex (aIndexes($address)) {
my $question = $self->survey->question([$sIndex,$qIndex]);
my $answerId = $self->answerId($sIndex, $qIndex, $aIndex);
if ( defined $self->responses->{$answerId} ) {
my $answer = $self->survey->answer( [ $sIndex, $qIndex, $aIndex ] );
$responses->{$question->{variable}}
= $answer->{value} =~ /\w/ ? $answer->{value}
: $question->{value}
;
push(@$responses, {
value => $answer->{value} =~ /\w/ ? $answer->{value} : $question->{value},
recordedAnswer => $answer->{recordedAnswer},
isCorrect => $answer->{isCorrect},
answerText => $answer->{text},
address => [$sIndex,$qIndex,$aIndex],
questionText => $question->{text},
questionValue => $question->{value},
questionType => $question->{questionType}
}
);
}
}
}
return $responses;
}
#-------------------------------------------------------------------
=head2 parseGotoExpression( ( $expression, $responses)
=head2 responseValuesByVariableName
Parses a single gotoExpression. Returns undef if processing fails, or the following hashref
if things work out well:
{ target => $target, expression => $expression }
Returns a lookup table to question variable names and recorded response values.
=head3 $expression
The expression to process
=head3 $responses
Hashref that maps questionNames to response values
=head3 Explanation:
Uses the following simple strategy:
First, parse the expression as:
target: expression
Replace each questionName with its response value (from the $responses hashref)
Massage the expression into valid perl
Check that only valid tokens remain. This last step ensures that any invalid questionNames in
the expression generate an error because our list of valid tokens doesn't include a-z
Only questions with a defined variable name set are included. Values come from
the L<responses> hash.
=cut
sub parseGotoExpression {
my $self = shift;
my ($expression, $responses) = validate_pos(@_, { type => SCALAR }, { type => HASHREF, default => {} });
$self->session->log->debug("Parsing gotoExpression: $expression");
# Valid gotoExpression tokens are..
my $tokens = qr{\s|[-0-9=!<>+*/.()]};
my ( $target, $rest ) = $expression =~ /\s* ([^:]+?) \s* : \s* (.*)/x;
$self->session->log->debug("Parsed as Target: [$target], Expression: [$rest]");
if ( !defined $target ) {
$self->session->log->warn('Target undefined');
return;
sub responseValuesByVariableName {
my $self = shift;
my %lookup;
while (my ($address, $response) = each %{$self->responses}) {
next if (!$response || !$address);
# Turn responses s-q-a string into an address array
my @address = split /-/, $address;
# Filter out the non-answer entries
next unless @address == 3;
# Grab the corresponding question
my $question = $self->survey->question([@address]);
# Filter out questions without defined variable names
next if !$question || !defined $question->{variable};
# Add variable => value to our hash
$lookup{$question->{variable}} = $response->{value};
}
return \%lookup;
}
if ( !defined $rest || $rest eq q{} ) {
$self->session->log->warn('Expression undefined');
return;
#-------------------------------------------------------------------
=head2 responseScoresByVariableName
Returns a lookup table to question variable names and recorded response values.
Only questions with a defined variable name set are included. Scores come from
the L<responses> hash.
=cut
sub responseScoresByVariableName {
my $self = shift;
my %lookup;
while (my ($address, $response) = each %{$self->responses}) {
next if (!$response || !$address);
# Turn responses s-q-a string into an address array
my @address = split /-/, $address;
# Filter out the non-answer entries
next unless @address == 3;
# Grab the corresponding question
my $question = $self->survey->question([@address]);
# Filter out questions without defined variable names
next if !$question || !defined $question->{variable};
# Grab the corresponding answer
my $answer = $self->survey->answer([@address]);
# Add variable => score to our hash
$lookup{$question->{variable}} = $answer->{value};
}
# Replace each questionName with its response value
while ( my ( $questionName, $response ) = each %{$responses} ) {
$rest =~ s/$questionName/$response/g;
# Add section score totals
for my $s (@{$self->survey->sections}) {
next unless $s->{variable};
my $score = 0;
for my $q (@{$s->{questions}}) {
next unless $q->{variable};
next unless exists $lookup{$q->{variable}};
$lookup{$s->{variable}} += $lookup{$q->{variable}};
}
}
# convert '=' to '==' but don't touch '!=', '<=' or '>='
$rest =~ s/(?<![!<>])=(?!=)/==/g;
if ( $rest !~ /^$tokens+$/ ) {
$self->session->log->warn("Contains invalid tokens: $rest");
return;
}
$self->session->log->debug("Processed as: $rest");
return {
target => $target,
expression => $rest,
};
return \%lookup;
}
#-------------------------------------------------------------------
@ -915,11 +874,12 @@ sub nextQuestions {
my $section = $self->nextResponseSection();
my $sectionIndex = $self->nextResponseSectionIndex;
my $questionsPerPage = $self->survey->section( [ $self->nextResponseSectionIndex ] )->{questionsPerPage};
# Get all of the existing question responses (so that we can do Section and Question [[var]] replacements
my $recordedResponses = $self->recordedResponses();
my $responseValuesByVariableName = $self->responseValuesByVariableName();
# Do text replacement
$section->{text} = $self->getTemplatedText($section->{text}, $recordedResponses);
$section->{text} = $self->getTemplatedText($section->{text}, $responseValuesByVariableName);
# Collect all the questions to be shown on the next page..
my @questions;
@ -942,7 +902,7 @@ sub nextQuestions {
my %questionCopy = %{$self->survey->question( $address )};
# Do text replacement
$questionCopy{text} = $self->getTemplatedText($questionCopy{text}, $recordedResponses);
$questionCopy{text} = $self->getTemplatedText($questionCopy{text}, $responseValuesByVariableName);
# Add any extra fields we want..
$questionCopy{id} = $self->questionId($sIndex, $qIndex);
@ -954,7 +914,7 @@ sub nextQuestions {
my %answerCopy = %{ $self->survey->answer( [ $sIndex, $qIndex, $aIndex ] ) };
# Do text replacement
$answerCopy{text} = $self->getTemplatedText($answerCopy{text}, $recordedResponses);
$answerCopy{text} = $self->getTemplatedText($answerCopy{text}, $responseValuesByVariableName);
# Add any extra fields we want..
$answerCopy{id} = $self->answerId($sIndex, $qIndex, $aIndex);
@ -1086,7 +1046,116 @@ sub aIndexes {
#-------------------------------------------------------------------
=head2 returnResponsesForReporting
=head2 showSummary ( [$sectionAddresses] )
showSummary returns the current responses summary for the entire response, if
no address is passed in, or just the sections addressed by $sectionAddresses.
For each section, the total correct, wrong, time taken, and points are returned. And each
question is listed with the text, given score, user response, and if it was correct.
This list is meant for a template and only what is needed should be shown.
A summary of the entire suvey,
=cut
sub showSummary{
my $self = shift;
my $sectionAddies = shift;#array of section addresses
my $all = 0;
$all = 1 if(! $sectionAddies);
my ($summaries);
my $responses = $self->recordedResponses();
my %goodSection;
map{$goodSection{$_} = 1} @$sectionAddies;
return if(! $responses);
my ($sectionIndex, $questionIndex, $answerIndex) = (-1, -1, -1);
my ($currentSection,$currentQuestion) = (-1, -1);
($summaries->{totalCorrect},$summaries->{totalIncorrect}) = (0,0);
for my $response (@$responses){
if(! $all and ! $goodSection{$response->{address}->[0]}){next;}
if($response->{isCorrect}){
$summaries->{totalCorrect}++;
}else{
$summaries->{totalIncorrect}++;
}
$summaries->{totalAnswers}++;
if($currentSection != $response->{address}->[0]){
$summaries->{totalSections}++;
$sectionIndex++;
$questionIndex = -1;
$answerIndex = -1;
$currentQuestion = -1;
$currentSection = $response->{address}->[0];
_loadSectionIntoSummary(\%{$summaries->{sections}->[$sectionIndex]},$response);
}
if($currentQuestion != $response->{address}->[1]){
$summaries->{totalQuestions}++;
$questionIndex++;
$answerIndex = -1;
$currentQuestion = $response->{address}->[1];
_loadQuestionIntoSummary(\%{$summaries->{sections}->[$sectionIndex]->{questions}->[$questionIndex]},$response);
}
$answerIndex++;
_loadAnswerIntoSummary(\%{$summaries->{sections}->[$sectionIndex]->{questions}->[$questionIndex]->{answers}->[$answerIndex]},
$response,
$self->survey->{multipleChoiceTypes});
}
return $summaries;
}
sub _loadAnswerIntoSummary{
my $node = shift;
my $response = shift;
my $types = shift;
$node->{id} = $response->{address}->[2] + 1;
if($response->{isCorrect}){
$node->{iscorrect} = 1;
$node->{score} = $response->{value};
}else{
$node->{iscorrect} = 0;
$node->{score} = 0;
}
$node->{text} = $response->{answerText};
#test if it is a multiple choide type
if($types->{$response->{questionType}}){
$node->{value} = $response->{value};
}else{
$node->{value} = $response->{recordedValue};
}
}
sub _loadQuestionIntoSummary{
my $node = shift;
my $response = shift;
$node->{id} = $response->{address}->[1] + 1;
$node->{text} = $response->{questionText};
}
sub _loadSectionIntoSummary{
my $node = shift;
my $response = shift;
$node->{id} = $response->{address}->[0] + 1;
$node->{inCorrect} = 0 if(!defined $node->{section}->{inCorrect});
$node->{score} = 0 if(!defined $node->{section}->{score});
$node->{correct} = 0 if(!defined $node->{section}->{correct});
if($response->{isCorrect}){
$node->{score} += $response->{value};
$node->{correct}++;
}else{
$node->{inCorrect}++;
}
}
#-------------------------------------------------------------------
=head2 returnResponseForReporting
Used to extract JSON responses for use in reporting results.
@ -1096,7 +1165,7 @@ recorded value, and the id of the answer.
=cut
# TODO: This sub should make use of recordedResponses
# TODO: This sub should make use of responseValuesByVariableName
sub returnResponseForReporting {
my $self = shift;

View file

@ -48,79 +48,18 @@ likely operate on the question indexed by:
use strict;
use JSON;
use Data::Dumper;
use Params::Validate qw(:all);
Params::Validate::validation_options( on_fail => sub { WebGUI::Error::InvalidParam->throw( error => shift ) } );
# N.B. We're currently using Storable::dclone instead of Clone::clone
# because Colin uncovered some Clone bugs in Perl 5.10
#use Clone qw/clone/;
use Storable qw/dclone/;
use Clone qw/clone/;
# The maximum value of questionsPerPage is currently hardcoded here
my $MAX_QUESTIONS_PER_PAGE = 20;
my %MULTI_CHOICE_BUNDLES = (
'Agree/Disagree' => [ 'Strongly disagree', (q{}) x 5, 'Strongly agree' ],
Certainty => [ 'Not at all certain', (q{}) x 9, 'Extremely certain' ],
Concern => [ 'Not at all concerned', (q{}) x 9, 'Extremely concerned' ],
Confidence => [ 'Not at all confident', (q{}) x 9, 'Extremely confident' ],
Education => [
'Elementary or some high school',
'High school/GED',
'Some college/vocational school',
'College graduate',
'Some graduate work',
'Master\'s degree',
'Doctorate (of any type)',
'Other degree (verbatim)',
],
Effectiveness => [ 'Not at all effective', (q{}) x 9, 'Extremely effective' ],
Gender => [qw( Male Female )],
Ideology => [
'Strongly liberal',
'Liberal',
'Somewhat liberal',
'Middle of the road',
'Slightly conservative',
'Conservative',
'Strongly conservative'
],
Importance => [ 'Not at all important', (q{}) x 9, 'Extremely important' ],
Likelihood => [ 'Not at all likely', (q{}) x 9, 'Extremely likely' ],
'Oppose/Support' => [ 'Strongly oppose', (q{}) x 5, 'Strongly support' ],
Party =>
[ 'Democratic party', 'Republican party (or GOP)', 'Independent party', 'Other party (verbatim)' ],
Race =>
[ 'American Indian', 'Asian', 'Black', 'Hispanic', 'White non-Hispanic', 'Something else (verbatim)' ],
Risk => [ 'No risk', (q{}) x 9, 'Extreme risk' ],
Satisfaction => [ 'Not at all satisfied', (q{}) x 9, 'Extremely satisfied' ],
Security => [ 'Not at all secure', (q{}) x 9, 'Extremely secure' ],
Threat => [ 'No threat', (q{}) x 9, 'Extreme threat' ],
'True/False' => [qw( True False )],
'Yes/No' => [qw( Yes No )],
Scale => [q{}],
'Multiple Choice' => [q{}],
);
my @SPECIAL_QUESTION_TYPES = (
'Dual Slider - Range',
'Multi Slider - Allocate',
'Slider',
'Currency',
'Email',
'Phone Number',
'Text',
'Text Date',
'TextArea',
'File Upload',
'Date',
'Date Range',
'Hidden',
);
sub specialQuestionTypes {
return @SPECIAL_QUESTION_TYPES;
}
#sub specialQuestionTypes {
# return @SPECIAL_QUESTION_TYPES;
#}
=head2 new ( $session, json )
@ -153,6 +92,9 @@ sub new {
bless $self, $class;
#Load question types
$self->loadTypes();
# Initialise the survey data structure if empty..
if ( $self->totalSections == 0 ) {
$self->newObject( [] );
@ -160,6 +102,78 @@ sub new {
return $self;
}
=head2 loadTypes
Loads the Multiple Choice and Special Question types
=cut
sub loadTypes {
my $self = shift;
@{$self->{specialQuestionTypes}} = (
'Dual Slider - Range',
'Multi Slider - Allocate',
'Slider',
'Currency',
'Email',
'Phone Number',
'Text',
'Text Date',
'TextArea',
'File Upload',
'Date',
'Date Range',
'Year Month',
'Hidden',
);
my $refs = $self->session->db->buildArrayRefOfHashRefs("SELECT questionType, answers FROM Survey_questionTypes");
map($self->{multipleChoiceTypes}->{$_->{questionType}} = [split/,/,$_->{answers}], @$refs);
}
sub addType {
my $self = shift;
my $name = shift;
my $address = shift;
my $obj = $self->getObject($address);
my @answers;
for my $ans(@{$obj->{answers}}){
push(@answers,$ans->{text});
}
my $ansString = join(',',@answers);
$self->session->db->write("INSERT INTO Survey_questionTypes VALUES(?,?) ON DUPLICATE KEY UPDATE answers = ?",[$name,$ansString,$ansString]);
$self->question($address)->{questionType} = $name;
}
sub removeType {
my $self = shift;
my $address = shift;
my $obj = $self->getObject($address);
$self->session->db->write("DELETE FROM Survey_questionTypes WHERE questionType = ?",[$obj->{questionType}]);
}
=head2 specialQuestionTypes
Returns the arrayref to the special question types
=cut
sub specialQuestionTypes {
my $self = shift;
return $self->{specialQuestionTypes};
}
=head2 multipleChoiceTypes
Returns the hashref to the multiple choice types
=cut
sub multipleChoiceTypes {
my $self = shift;
return $self->{multipleChoiceTypes};
}
=head2 freeze
Serialize this Perl object into a JSON string. The serialized object is made up of the survey and sections
@ -350,13 +364,13 @@ sub getObject {
return if !$count;
if ( $count == 1 ) {
return dclone $self->sections->[ sIndex($address) ];
return clone $self->sections->[ sIndex($address) ];
}
elsif ( $count == 2 ) {
return dclone $self->sections->[ sIndex($address) ]->{questions}->[ qIndex($address) ];
return clone $self->sections->[ sIndex($address) ]->{questions}->[ qIndex($address) ];
}
else {
return dclone $self->sections->[ sIndex($address) ]->{questions}->[ qIndex($address) ]->{answers}
return clone $self->sections->[ sIndex($address) ]->{questions}->[ qIndex($address) ]->{answers}
->[ aIndex($address) ];
}
}
@ -403,12 +417,14 @@ sub getGotoTargets {
# Valid goto targets are all of the section variable names..
my @section_vars = map {$_->{variable}} @{$self->sections};
# ..and all of the question variable names..
my @question_vars = map {$_->{variable}} @{$self->questions};
# ..excluding the ones that are empty
return grep { $_ ne q{} } (@section_vars, @question_vars);
my @grep = grep { $_ ne q{} } (@section_vars, @question_vars);
return \@grep;
#return grep { $_ ne q{} } (@section_vars, @question_vars);
}
=head2 getSectionEditVars ( $address )
@ -512,7 +528,6 @@ sub getQuestionEditVars {
# Change questionType from a single element into an array of hashrefs which list the available
# question types and which one is currently selected for this question..
for my $qType ($self->getValidQuestionTypes) {
push @{ $var{questionType} }, {
text => $qType,
@ -529,7 +544,8 @@ A convenience method. Returns a list of question types.
=cut
sub getValidQuestionTypes {
return sort (@SPECIAL_QUESTION_TYPES, keys %MULTI_CHOICE_BUNDLES);
my $self = shift;
return sort (@{$self->{specialQuestionTypes}}, keys %{$self->{multipleChoiceTypes}});
}
=head2 getAnswerEditVars ( $address )
@ -761,14 +777,14 @@ sub copy {
if ( $count == 1 ) {
# Clone the indexed section onto the end of the list of sections..
push @{ $self->sections }, dclone $self->section($address);
push @{ $self->sections }, clone $self->section($address);
# Update $address with the index of the newly created section
$address->[0] = $self->lastSectionIndex;
}
elsif ( $count == 2 ) {
# Clone the indexed question onto the end of the list of questions..
push @{ $self->questions($address) }, dclone $self->question($address);
push @{ $self->questions($address) }, clone $self->question($address);
# Update $address with the index of the newly created question
$address->[1] = $self->lastQuestionIndex($address);
@ -1002,7 +1018,7 @@ sub getMultiChoiceBundle {
my $self = shift;
my ($type) = validate_pos( @_, { type => SCALAR | UNDEF } );
return $MULTI_CHOICE_BUNDLES{$type};
return $self->{multipleChoiceTypes}->{$type};
}
=head2 addAnswersToQuestion ($address, $answers, $verbatims)
@ -1047,7 +1063,7 @@ sub addAnswersToQuestion {
$self->update(
\@address_copy,
{ text => $answers->[$answer_index],
recordedAnswer => $answer_index + 1,
recordedAnswer => $answer_index + 1, # 1-indexed
verbatim => $verbatims->{$answer_index},
}
);
@ -1176,6 +1192,123 @@ sub totalAnswers {
}
}
=head2 validateSurvey ()
Returns an array of messages to inform a user what is logically wrong with the Survey
=cut
sub validateSurvey{
my $self = shift;
#check all goto's
#bad goto expressions
#check that all survey is able to be seen
my @messages;
#set up valid goto targets
my $gotoTargets = $self->getGotoTargets();
my $goodTargets;
my $duplicateTargets;
for my $g (@{$gotoTargets}) {
$goodTargets->{$g}++;
$duplicateTargets->{$g}++ if $goodTargets->{$g} > 1;
}
#step through each section validating it.
my $sections = $self->sections();
for(my $s = 0; $s <= $#$sections; $s++){
my $sNum = $s + 1;
my $section = $self->section([$s]);
if(! $self->validateGoto($section,$goodTargets)){
push @messages,"Section $sNum has invalid Jump target: \"$section->{goto}\"";
}
if(! $self->validateGotoInfiniteLoop($section)){
push @messages,"Section $sNum jumps to itself.";
}
if(my $error = $self->validateGotoExpression($section,$goodTargets)){
push @messages,"Section $sNum has invalid Jump Expression: \"$section->{gotoExpression}\". Error: $error";
}
if (my $var = $section->{variable}) {
if (my $count = $duplicateTargets->{$var}) {
push @messages, "Section $sNum variable name $var is re-used in $count other place(s).";
}
}
#step through each question validating it.
my $questions = $self->questions([$s]);
for(my $q = 0; $q <= $#$questions; $q++){
my $qNum = $q + 1;
my $question = $self->question([$s,$q]);
if(! $self->validateGoto($question,$goodTargets)){
push @messages,"Section $sNum Question $qNum has invalid Jump target: \"$question->{goto}\"";
}
if(! $self->validateGotoInfiniteLoop($question)){
push @messages,"Section $sNum Question $qNum jumps to itself.";
}
if(my $error = $self->validateGotoExpression($question,$goodTargets)){
push @messages,"Section $sNum Question $qNum has invalid Jump Expression: \"$question->{gotoExpression}\". Error: $error";
}
if($#{$question->{answers}} < 0){
push @messages,"Section $sNum Question $qNum does not have any answers.";
}
if(! $question->{text} =~ /\w/){
push @messages,"Section $sNum Question $qNum does not have any text.";
}
if (my $var = $question->{variable}) {
if (my $count = $duplicateTargets->{$var}) {
push @messages, "Section $sNum Question $qNum variable name $var is re-used in $count other place(s).";
}
}
#step through each answer validating it.
my $answers = $self->answers([$s,$q]);
for(my $a = 0; $a <= $#$answers; $a++){
my $aNum = $a + 1;
my $answer = $self->answer([$s,$q,$a]);
if(! $self->validateGoto($answer,$goodTargets)){
push @messages,"Section $sNum Question $qNum Answer $aNum has invalid Jump target: \"$answer->{goto}\"";
}
if(! $self->validateGotoInfiniteLoop($answer)){
push @messages,"Section $sNum Question $qNum Answer $aNum jumps to itself.";
}
if(my $error = $self->validateGotoExpression($answer,$goodTargets)){
push @messages,"Section $sNum Question $qNum Answer $aNum has invalid Jump Expression: \"$answer->{gotoExpression}\". Error: $error";
}
}
}
}
return \@messages;
}
sub validateGoto{
my $self = shift;
my $object = shift;
my $goodTargets = shift;
return 0 if($object->{goto} =~ /\w/ && ! exists($goodTargets->{$object->{goto}}));
return 1;
}
sub validateGotoInfiniteLoop{
my $self = shift;
my $object = shift;
return 0 if($object->{goto} =~ /\w/ and $object->{goto} eq $object->{variable});
return 1;
}
sub validateGotoExpression{
my $self = shift;
my $object = shift;
my $goodTargets = shift;
return unless $object->{gotoExpression};
use WebGUI::Asset::Wobject::Survey::ExpressionEngine;
my $engine = "WebGUI::Asset::Wobject::Survey::ExpressionEngine";
return $engine->run($self->session, $object->{gotoExpression}, { validate => 1, validTargets => $goodTargets } );
}
=head2 section ($address)
Returns a reference to one section.
@ -1208,9 +1341,9 @@ sub session {
Returns a reference to all the questions from a particular section.
=head3 $address
=head3 $address (optional)
See L<"Address Parameter">.
See L<"Address Parameter">. If not defined, returns all questions.
=cut
@ -1218,7 +1351,13 @@ sub questions {
my $self = shift;
my ($address) = validate_pos(@_, { type => ARRAYREF, optional => 1});
return $self->sections->[ $address->[0] ]->{questions};
if ($address) {
return $self->sections->[ $address->[0] ]->{questions};
} else {
my $questions;
push @$questions, @{$_->{questions} || []} for @{$self->sections};
return $questions;
}
}
=head2 question ($address)