QuestionMark Perception’s GetScheduleListByParticipant method in QMWISe

PLEASE NOTE: I’m looking further into why this may be happening after a very useful chat with Questionmark’s Richard Ham so watch this space…

If you’re using QuestionMark Perception’s GetScheduleListByParticipant method through their QMWISe API and the Questionmark Basic LTI Community Connector (I don’t know about the LTI Connector for Questionmark OnDemand) then this is a quick heads up about a problem that I’ve just encountered in v5.4. We use this method to work out whether students are currently scheduled for a given assessment through a (rather inelegant) check of whether the AssessmentID they are trying to access is the same as the AssessmentID of one of the assessments they are scheduled for and, if that schedule is time-limited, whether it is currently open.

The problem is that, for reasons that are probably die to the way that PHP deals with a SOAP return , GetScheduleListByParticipant returns a different object structure depending on whether the one or more schedules are returned. For one schedule (with print_r()):

stdClass Object
(
    [Schedule] => stdClass Object
        (
            [Schedule_ID] => 111111111
            [Assessment_ID] => 1111111111111111 
            [Participant_ID] => 0 
            [Group_ID] => 111111111 
            [Schedule_Name] => Quiz 1 
            [Restrict_Times] => 1 
            [Restrict_Attempts] => 
            [Max_Attempts] => 0 
            [Monitored] => 0 
            [Schedule_Starts] => 2013-12-03T00:00:00 
            [Schedule_Stops] => 2013-12-04T00:00:00 
        )
)

Whereas with more than one schedule:

stdClass Object
(
    [Schedule] => Array
        (
            [0] => stdClass Object
               (
                   [Schedule_ID] => 1111111
                   [Assessment_ID] => 1111111111111111
                   [Participant_ID] => 0
                   [Group_ID] => 111111111
                   [Schedule_Name] => Quiz 1
                   [Restrict_Times] =>
                   [Restrict_Attempts] =>
                   [Max_Attempts] => 0
                   [Monitored] => 0
                   [Schedule_Starts] => 0001-01-01T00:00:00
                   [Schedule_Stops] => 0001-01-01T00:00:00
               )
            [1] => stdClass Object
               (
                   [Schedule_ID] => 2222222
                   [Assessment_ID] => 2222222222222222
                   [Participant_ID] => 0
                   [Group_ID] => 222222222
                   [Schedule_Name] => Quiz 2
                   [Restrict_Times] => 1
                   [Restrict_Attempts] =>
                   [Max_Attempts] => 0
                   [Monitored] => 0
                   [Schedule_Starts] => 2013-12-03T00:00:00
                   [Schedule_Stops] => 2013-12-04T00:00:00
               )
            [n] => and so on...

The real problem with is when you compare desired AssessmentID with scheduled AssessmentID in a for loop:

$schedules = get_schedule_list_by_participant($participant_id)) //method which calls GetScheduleListByParticipant
foreach($schedules->Schedule){
    ... //check each schedule
    }

It works fine until you come across a student with only one schedule, at which point it breaks as it tries to iterate through the properties of the only schedule returned. Until we can work out something else, the simplest solution is to check the number of schedules returned before deciding how to evaluate them:

if(count($schedules->Schedule)>1){ 
    foreach($schedules->Schedule as $schedule){
        ... //check each schedule
        }
    }
elseif(count($schedules->Schedule)==1){ 
    ... //check this schedule
    }

Haven’t checked whether behaviour is the same with the other QMWISe methods but I wouldn’t be surprised….

Timezone problems/ time offset in PHP under IIS/Windows

We’ve been struggling for a while now with an unexplained three hour offset in log files and error messages created by QuestionMark Perception – commercial software which runs under PHP on IIS. I thought it would worth sharing a few gotchas in trying to pin down what was causing the problem:

1. Make sure you are looking for the

date.timezone

setting in the correct php.ini file (there are usually at least two) – you can identify which is being used by looking in IIS Manager | PHP Manager under ‘PHP Settings’ | ‘Configuration file’.

2. Use phpinfo to check what timezone PHP is reporting. You can either use the function –

phpinfo()

– in an otherwise empty php page or by choosing IIS Manager | PHP Manager and then under ‘PHP Setup’, choose ‘Check phpinfo()’.

3. Be aware that the windows PHP installer adds a section at the end of the php.ini file: 

[WebPIChanges]

. A date.timezone setting in this section will override any earlier date.timezone settings.

4. If you make any changes to php.ini, you will need to restart IIS in order for them to be picked up. At the command prompt, type

iisreset

or use IIS Manager.

5. If it’s your own code you can override the value in php.ini with:

date_default_timezone_set('Europe/London');

This is useful if you can’t restart IIS on e.g. a production server.

Using jsTree as a radio input

While adapting existing code to create a Basic LTI connector for Questionmark Perception, we needed some way of allowing administrators to choose which one of our many hundreds of assessments students using the same link should see. As the assessments are organised into folders, jsTree seemed the obvious way to present them.

jsTree does come with a plugin which allows users to tick check boxes next to each node, and there is even an option to allow only one chcek box to be selected at a time, reproducing radio button functionality. However, check boxes just don’t say ‘Pick one’ to me. Instead, we decided to use jsTree’s UI plugin and a hidden form field to deliver something that behaves as if there were a radio button next to each leaf node the tree.

The tree is created from nested unordered lists e.g:

<div id="tree_container">
    <ul>
        <li id="73053976"><a href="#">Folder 1</a>
            <ul>
                <li id="1223099925"><a href="#">Folder 1.1</a>
                    <ul>
                        <li id="7312166358986135"><a href="#">Assessment 1.1-1</a></li>
                        <li id="0272148079818008"><a href="#">Assessment 1.1-2</a></li>
                    </ul>
                </li>
                <li id="8430544147035550"><a href="#">Assessment 1-1</a></li>
                <li id="3489131659907643"><a href="#">Assessment 1-2</a></li>
            </ul>
        </li>
        <li id="759603613"><a href="#">Folder 2</a>
            <ul>
                <li id="8842542540288867"><a href="#">Assessment 2-1</a></li>
                <li id="5658598094790998"><a href="#">Assessment 2-2</a></li>
            </ul>
        </li>
    </ul>
</div>

Each list item has an id and those which contain other assessments (i.e. are folders) are given a class of folder.

There is also a form:

<form action="mypage.php" method="POST">
     <input id="selected_node_id" type="hidden" name="selected_node_id"></input> 
     <input type="submit" id="save_button" value="Save change" disabled="disabled" />
</form>

This contains a hidden input which will be used to post selected node id.

And a span to show the currently saved node_id:

<p>Currently selected:<strong><span id="selected_node_text"></span></strong></p>

The jsTree is then created, using the HTML_DATA plugin, as follows:

var dirty=false; //keep track of whether changes need to be saved
$(function () {
    $("#tree_container")
        .jstree({ 
            "plugins" : [ "themes", "html_data", "ui"],
              "themes" : {
                   "theme" : "classic",
                "dots" : true,
                "icons" : true
                  },
              "ui" : {
                  "select_limit" : 1  //only allow one node to be selected at a time
                  <?php if(isset($_SESSION['saved_node_id'])&&$_SESSION['saved_node_id']!='') echo ', "initially_select" : ['.$_SESSION['saved_node_id'].']'; ?>
                  }
            })
        .bind("select_node.jstree", function(event,data) {
            var this_node = data.rslt.obj;
            event.preventDefault(); //stop any link from being followed
            if(this_node.attr("class").indexOf('folder') != -1){
                $("#selected_node_id").val('');  //this is a folder so empty hidden input
                return data.inst.toggle_node(this_node);   //toogle closed/open state of this node to display/hide contents
                }
            else {
                $("#selected_node_id").val(this_node.attr("id")); //this is a leaf/assessment so set hidden input value to id
                if(this_node.attr("id")!=<?php echo $saved_node_id ?>){   //selected node is not the same as that previously saved so enable save and set dirty
                    document.getElementById('save_button').disabled = false;
                    dirty=true;
                    }
                else{ //selected node hasn't cnaged from that saved so diable save and unset dirty
                    document.getElementById('save_button').disabled = true; 
                    $("#selected_node_text").text(this_node.children("a").text()); //this is really only used to set selected_node_text when jsTree loads
                    dirty=false;
                    }
                }
            })
    });

Points to note:

  • "select_limit" : 1

    indicates that only one node may be selected at any one time;

  • the value of initially_select is set from the saved_node_id (which corresponds to a node/li id) if this is present in the session. This selects the previously selected node when the jsTree is loaded;
  • .bind("select_node.jstree", function(event,data) {

    deals with events when a node is selected and is also fired if initially_select is specified.

And finally, to ensure user doesn’t leave page before changes have been saved:

window.onbeforeunload = function(e) {
    if (dirty) return "You have not saved your choice.";
    }

Watch this space for how this is integrated into our new Basic LTI connector.

BasicLTI for QuestionMark Perception

Using Sakai to connect to load-balanced QuestionMark Perception using BasicLTI.

Why?

  1. Provide single sign-on capability to avoid maintaining a separate password for each user;
  2. Sakia (at Oxford) allows ‘external’ users to be created  from their email addresses. Where these users are given permission with Sakai and where they have accounts in Perception, they would then be able to access assessments.

How?

By adapting the QuestiionMark BasicLTI connector. This is a community edition project which the very helpful Steve Lay pointed us at: http://projects.oscelot.org/gf/project/lti-qm/frs/

Process as follows (PLEASE NOTE THAT I AM BLOGGING THIS AS I GO SO I APOLOGISE FOR ANY MISUNDERSTANDINGS – I’LL UPDATE AS MY LEVEL OF UNDERSTANDING INCREASES):

  1. Create DB – as we have a load-balanced setup and a clustered MSSQL DB, we had to create another db in our MSSQL server to avoid introducing a single point of failure. Create the tables using the following (adapted from http://www.spvsoftwareproducts.com/php/lti_tool_provider/)
    CREATE TABLE lti_consumer (
     consumer_key varchar(255) NOT NULL,
     name varchar(45) NOT NULL,
     secret varchar(32) NOT NULL,
     lti_version varchar(12) DEFAULT NULL,
     consumer_name varchar(255) DEFAULT NULL,
     consumer_version varchar(255) DEFAULT NULL,
     consumer_guid varchar(255) DEFAULT NULL,
     css_path varchar(255) DEFAULT NULL,
     protected tinyint NOT NULL,
     enabled tinyint NOT NULL,
     enable_from datetime DEFAULT NULL,
     enable_until datetime DEFAULT NULL,
     last_access date DEFAULT NULL,
     created datetime NOT NULL,
     updated datetime NOT NULL,
     PRIMARY KEY (consumer_key)
    );
    
    CREATE TABLE lti_context (
     consumer_key varchar(255) NOT NULL,
     context_id varchar(255) NOT NULL,
     lti_context_id varchar(255) DEFAULT NULL,
     lti_resource_id varchar(255) DEFAULT NULL,
     title varchar(255) NOT NULL,
     settings text,
     primary_consumer_key varchar(255) DEFAULT NULL,
     primary_context_id varchar(255) DEFAULT NULL,
     share_approved tinyint DEFAULT NULL,
     created datetime NOT NULL,
     updated datetime NOT NULL,
     PRIMARY KEY (consumer_key, context_id)
    );
    
    CREATE TABLE lti_user (
     consumer_key varchar(255) NOT NULL,
     context_id varchar(255) NOT NULL,
     user_id varchar(255) NOT NULL,
     lti_result_sourcedid varchar(255) NOT NULL,
     created datetime NOT NULL,
     updated datetime NOT NULL,
     PRIMARY KEY (consumer_key, context_id, user_id)
    );
    
    CREATE TABLE lti_nonce (
     consumer_key varchar(255) NOT NULL,
     value varchar(32) NOT NULL,
     expires datetime NOT NULL,
     PRIMARY KEY (consumer_key, value)
    );
    
    CREATE TABLE lti_share_key (
     share_key_id varchar(32) NOT NULL,
     primary_consumer_key varchar(255) NOT NULL,
     primary_context_id varchar(255) NOT NULL,
     auto_approve tinyint NOT NULL,
     expires datetime NOT NULL,
     PRIMARY KEY (share_key_id)
    );
    
    ALTER TABLE lti_context
     ADD CONSTRAINT lti_context_consumer_FK1 FOREIGN KEY (consumer_key)
     REFERENCES lti_consumer (consumer_key);
    
    ALTER TABLE lti_context
     ADD CONSTRAINT lti_context_context_FK1 FOREIGN KEY (primary_consumer_key, primary_context_id)
     REFERENCES lti_context (consumer_key, context_id);
    
    ALTER TABLE lti_user
     ADD CONSTRAINT lti_user_context_FK1 FOREIGN KEY (consumer_key, context_id)
     REFERENCES lti_context (consumer_key, context_id);
    
    ALTER TABLE lti_nonce
     ADD CONSTRAINT lti_nonce_consumer_FK1 FOREIGN KEY (consumer_key)
     REFERENCES lti_consumer (consumer_key);
    
    ALTER TABLE lti_share_key
     ADD CONSTRAINT lti_share_key_context_FK1 FOREIGN KEY (primary_consumer_key, primary_context_id)
     REFERENCES lti_context (consumer_key, context_id);
  2. Create a user on the DB whose details you will enter below.
  3. Create a new administrator in Enterprise Manager which will be used by QMWISE
  4. Create an MD5 checksum for the QMWISE user as described here: https://www.questionmark.com/perception/help/v5/product_guides/qmwise/Content/Testing/Calculating%20an%20MD5%20Checksum.htm
  5. Change settings in config.php:
    define('CONSUMER_KEY', 'testkey');define('CONSUMER_SECRET', 'testsecret');
    define('DB_NAME', 'sqlsrv:Server=localhost;Database=testdb');
    define('DB_USERNAME', 'dbusername');
    define('DB_PASSWORD', 'dbpassword');
    define('QMWISE_URL', 'http://localhost/QMWISe5/QMWISe.asmx');
    define('SECURITY_CLIENT_ID', 'ClientIDFromQMWISETestHarness');
    define('SECURITY_CHECKSUM', 'ChceksumFromQMWISETestHarness');
    define('DEBUG_MODE', true);
    define('ADMINISTRATOR_ROLE', 'LTI_INSTRUCTOR');
    define('QM_USERNAME_PREFIX', '');
    define('WEB_PATH', '/qmlti');

    DB_NAME should adhere to DSN conventions here: http://www.php.net/manual/en/ref.pdo-sqlsrv.connection.php

    SECURITY_CLIENT_ID is the administrator which is used by QMWISE

    SECURITY_CHECKSUM is the chceksu which can be generated from the QMWISE test harness

    WEB_PATH:  we needed to change to subdirectory where we had placed lti connector code

  6. As detailed here (https://www.questionmark.com/Developer/Pages/apis_documentation004.aspx) you cannot write applications yourself that provide a trust header so switch this off under Administration | Server Management | Server Settings in Enterprise Manager. Don’t forget to reset IIS (Start | All Programs | Accessories right-click Command Prompt and Run as Administrator then type
    iisreset

    ) to ensure it picks up the change in settings.

  7. Upload qmlti folder into C:\inetpub\wwwroot on both of the load-balanced servers
  8. Copy the lti.pip file into the \repository\PerceptionRepository\pip folder on the storage area shared by both load-balanced servers
  9. At this stage, I wanted to be able to launch an assessment as an IMS ‘Learner’/QM Participant from the test_harness. I couldn’t work out how to do this so made the following changes (these will probably be superseded once I have worked out how to set up the tool as an IMS ‘Instructor’ so that the correct assessment is shown based upon the User/Context_ID):
    • added a custom parameters textarea to the test_harness.php (188):
      <div class="row">
      <div class="col1">
      Custom Parameters (each on separate line; param_name=param_value)
      </div>
      <div class="col2">
      <textarea name="custom" rows="5" cols="50" onchange="onChange();" ><?php echo htmlentities($_SESSION['custom']); ?></textarea>
      </div>
      </div>

      ..and on line 45, something to save them:

      set_session('custom');
    • Something to read them and turn them into request parameters in test_launch.php (48):
      $custom_params = explode('\n', str_replace('\n\r','\n',$_SESSION['custom']));
      foreach($custom_params as $custom_param){
      if(strpos($custom_param, '=')){
      $custom_param_parts = explode('=', $custom_param);
      $params['custom_'.$custom_param_parts[0]]=$custom_param_parts[1];
      }
      }
    • And changed the parameter read in index.php (65) to look for the assessment ID with ‘custom_’ prepended as this seems to be what the specification expects:
      $assessment_id = $tool_provider->context->getSetting(custom_ASSESSMENT_SETTING);
  10. Don’t forget that you’ll need to ‘Allow run from integration’ under ‘Control settings’ for any assessment that you want to launch with QMWISE.
  11. Now pouint your browser to http://yourserver/qmlti/test_harness.php. It should pick up your key and secret automatically from config.php so, to launch an assessment as a student, all you need to do is:
    • enter the username of a valid participant in the User Details | ID box;
    • Enter the ID of an assessment for which that student is scheduled in the new custom parameters box as follows:
      ASSESSMENT_SETTING=1234567887654321
    • Click ‘Save data’ to..save your data
    • Then click Launch

It’s worth noting that doing things like this does breaks the strict separation between the various tiers as the PHP application is running on the QPLA servers and accessing the database directly.

Finally, a huge thanks to QuestionMark’s Steve Lay and to Stephen Vickers for his Basic LTI code.

 

Excel ‘helpfully’ converting numbers longer than 15 digits to scientific notation when importing from .csv

When you attempt to open or import .csv data into Excel, long numbers are helpfully converted into scientific notation. In most situations, this is fine – it make it easier to see the number in a cell and the original number can be seen in full if the cell number format is changed.

However, when numbers are longer than 15 digits, Excel silently rounds anything after the 15th digit – a real problem with GUIDs in general and QuestionMark Perception Question IDs in particular.

There are a number of potential solutions to this, any of which may work for you:

  1. Change the file’s .csv extension to .txt or  choose Data | Get External Data | From Text to bring up the Text Import Wizard and, in Step 3 of 3 where you set the data format, select the appropriate column (or columns with Shift held down), set the Column data format to Text. However, this appears to fail with very high numbers of columns (in this case >200), leading me to solution 2:
  2. Upload the .csv file into Google Docs, making sure that Convert documents, presentations, spreadsheets, and drawings to the corresponding Google Docs format is ticked. Then export as Excel by File | Download As | Microsoft Excel. Thanks to kpm for this. However, this still seems to randomly covert a few GUIDs into 15 significant figure sceintific numbers.
  3. The solution which worked for me was to open the .csv file in OpenOffice 3 Calc and, as with Excel, in the Text Import dialogue, select all the  columns in the Fields section and then change their Column type to Text. Unlike Excel, this did reliably import all >200 columns without converting any GUIDs.