CSS hacks (underscore and star) for old IE versions

Ideally it shouldn’t be necessary to provide different CSS for different browsers, but sometimes needs must, particularly when it comes to dealing with the quirks of older versions of Internet Explorer (i.e. IE6 and IE7).

Thankfully there’s a couple of simple hacks that can be used to target CSS at old versions of IE only – the underscore (_) hack for IE6 (and below) and the star (*) hack for IE7 (and below). Just put _ or * in front of a CSS property as required, e.g.:

color: red;   /* all browsers */
*color: blue;   /* IE7 and below */
_color: green;   /* IE6 and below */

Using these rules, this sentence should appear green in IE6 and below, blue in IE7 and red in all other browsers.

There are other hacks that can be used. Check out these blog posts for more:

CSS Tip: Targeting IE 5.x, 6 and 7 Separately

IE7 only CSS hacks: Explained

Adding an image to a child theme template (e.g. header/footer) in WordPress

Child themes in WordPress are great, because they allow you to modify a theme without making changes to the theme’s core code, which makes things much easier when the theme gets updated – you don’t have to redo all of your changes.

I wanted to add an image to a custom footer template in a child theme (created by copying footer.php from the parent theme). I’d created an images directory in my child theme and put my image there, but was struggling to generate the correct link to the image.

After a bit of searching, I found this suggestion:

<img src="<?php echo get_template_directory_uri(); ?>/images/image.jpg" />

However, this was pointing to the image directory in the parent theme, not the child theme. Further searching led me to this blog: How to load files within WordPress themes, which had the answer (and explains things in more detail):

<img src="<?php echo get_stylesheet_directory_uri(); ?>/images/image.jpg" />

The basic difference, when using a child theme, is this:

  • Template refers to the parent theme
  • Stylesheet refers to the child theme

More information on the get_stylesheet_directory_uri() function can be found here: http://codex.wordpress.org/Function_Reference/get_stylesheet_directory_uri

Note: an alternative is to use the bloginfo() function (echo is not needed), as follows:

<img src="<?php bloginfo('stylesheet_directory'); ?>/images/image.jpg" />

However, it turns out that this is just an indirect way of calling get_stylesheet_directory_uri(), via get_bloginfo() in wp-includes/general-template.php. Both are included in the codex, so I don’t think it really matters which you use.

Another note: Dropping the _uri from the end of the function (i.e. get_stylesheet_directory()), will return the absolute server path to your child theme stylesheet directory, which can be used for including a another php file, e.g.

<?php include( get_stylesheet_directory() . '/includes/myfile.php'); ?>

More on this here: http://codex.wordpress.org/Function_Reference/get_stylesheet_directory

How to stop JQuery accordion scrolling to bottom of page

I struggled with this for a while until I found this great StackOverflow thread: http://stackoverflow.com/questions/3621161/jquery-accordion-will-it-scroll-to-the-top-of-the-open-item

What worked for me:

$("#accordion").accordion({autoHeight: false,active: 0});
$(".ui-accordion").bind("accordionchange", function(event, ui) {
     if ($(ui.newHeader).offset() != null) {
          ui.newHeader, // $ object, activated header
          $("html, body").animate({scrollTop: ($(ui.newHeader).offset().top)-100}, 500);
     }
});

There’s a lot of animation going on but it does exactly what is says on the tin.

Migrating Cordova/JQuery Mobile app from Android to iOS

Some notes on this process in case they are useful to others:

Back button

Android specifically prohibits back buttons due to the presence of a physical Back button on all Android mobiles. However, in iOS, it’s the norm. JQuery mobile does provide an easy to insert header back button :

<a href="#" data-role="button" data-rel="back" data-icon="arrow-l">Back</a>

but it uses the JQuery theme. If you want to make it look like an iOS back button, this article makes it relatively easy.

Navigation

In Android,with Cordova, we have to use absolute URLs for navigation:

navigator.app.loadUrl("file:///android_asset/www/mypage.html");

In iOS, we can use the much simpler form:

location.href = "mypage.html";

Seemingly random clicks firing intermittently

It seems that using JQuery Mobile’s ‘vclick’ event in iOS can cause problems, although the same code was working fine in Android. I was seeing a page change, followed by another page change as if something in the first page loaded had been clicked. After trying all sorts of event undelegation and unbinding, I finally looked at JQuery’s vclick documentation. This explains that there is a slight delay between the JQuery’s ‘vclick’ event  and the native ‘click’ event. When the content under the clicked point changes (as in this case with a new page load), the click event can actually fire on the changed rather than the original content. In situations like this, the recommendation is to use the ‘click’ rather than ‘vclick’ which solved the problem for me.

External pages

My app opens links to an external site for further information. In Android, simply specifying a full http:// prefix URL was enough to ensure that it loaded in the device’s normal web browser. In iOS, the default to seems to be to load it within the embedded brwoser which is being used to run your Cordova application, which is far from ideal. The workaround seems to simply add:

target = '_blank'

to all external links – they then fire up the iOS device’s Safari browser.

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.

 

jsTree: different contextmenu actions for different nodes

jsTree is a great jQuery plugin for easily creating expandable trees, e.g. from a list of items. We use it in our personalised portal page for Weblearn – see Personalising WebLearn (Sakai) – to display document trees.

We have been working on increasingly the functionality of this portal page to allow students to easily upload files. To do this, we are making use of the ‘contextmenu’ functionality of jsTree, which allows you to specify a menu of actions that can be accessed by right clicking on a tree node.

One thing that wasn’t immediately obvious from the jsTree docs was how to show different actions depending on the type of node, e.g. file or folder, on which the user clicked. However, it turns out that this can be done quite easily, by passing the ‘items’ parameter of the contextmenu configuration a function (that returns an object), rather than an object, as follows:

$('#tree_container').jstree({ 
    "plugins" : [ "contextmenu" ],
    "contextmenu" : {
        "items" : customMenu
    }
});

You then need to set up the customMenu function to return an items object, for example:

function customMenu(node) {
   //Show a different label for renaming files and folders
   if ($(node).hasClass("jstree-closed") || $(node).hasClass("jstree-open")) { //If node is a folder
      var renameLabel = "Rename Folder";
   }
   else {
      var renameLabel = "Rename File";
   }
   var items = {
      "upload" : {
          "label" : "Upload File",
          "action" : function () { ... }
      },
      "rename" : {
         "label" : renameLabel,   //Different label (defined above) will be shown depending on node type
         "action" : function () { ... }
      },
      "delete" : {
         "label" : "Delete File",
         "action" : function () { ... }
      }
   };

   //If node is a folder do not show the "delete" menu item
   if ($(node).hasClass("jstree-closed") || $(node).hasClass("jstree-open")) {
      delete items.remove;
   }

   return items;
}

Copying Arrays (and Objects) in Javascript

This is a bit of an amateur mistake, but I definitely wasn’t the first person to make it and I suspect I won’t be the last. I had a JavaScript array that I wanted to copy, then make some change to the copy, so that I could access the original and the modified copy. Therefore, being used to PHP, I did the following, assuming it would make a ‘proper’ copy of the original:

var original = [1,2,3];  //Define original array
var copy = original;     //Attempt to copy original
copy[2] = 4;             //Modify the copy
alert(original[2]);      //Hoped for 3, got 4

I’d created a reference to the original array, rather than a copy of it. As a result, updating the copy also updated the original.

So, how do I create a copy of an array, rather than a reference. Thankfully, the slice method makes this very easy:

var original = [1,2,3];        //Define original array
var copy = original.slice(0);  //Copy original using slice
copy[2] = 4;                   //Modify the copy
alert(original[2]);            //Got 3 this time

This works fine for simple, one-dimensional arrays, where the array contains only booleans, numbers or strings. If the array is multi-dimensional, i.e. it contains other arrays or objects, then only the top level array will be copied, the arrays/objects it contains will be referenced. Therefore, it is necessary to create a function to do this – this seems to be a good way to do: http://my.opera.com/GreyWyvern/blog/show.dml/1725165

Alternatively, use jQuery, and take advantage of the extend method:

var original = {team:"Arsenal"};               //Define original object
var copy = jQuery.extend(true, {}, original);  //Copy original using extend
copy.team = "Man Utd" ;                        //Modify the copy
alert(original.team);                          //Alerts Arsenal

Virtual microscopy in the classroom

This morning saw the first use of CSlide teaching slides within the laboratory to demonstrate the features that students should be looking for down their own microscopes. Monitors around the laboratory display the demonstrator’s computer screen or a view down the demonstrators microscope. The two main benefits of using CSlide on the computer, rather than projecting images from the demonstrator’s microscope, are:

  1. CSlide starts with a view of the whole slide and the demonstrator zooms in by double-clicking. This helps students get a feel for where they will find the relevant structures on their own slides. With a projected microscope, this overview is generally not possible and the change of lenses required for zooming in can make the experience disjointed.
  2. Each slide can be opened before the class, ready for use, in a separate window. This makes it very much quicker and easier to switch between slides, when teaching or responding to student questions, than with a traditional microscope.

The next stage is to embed the slides in students’ online teaching materials as has been done in the second year Nervous System course (Oxford users only). Annotations and ‘tours’ around the slides will give students a chance to come back to slides of interest outside the time-pressured environment of the laboratory. We also hope to build on these with self-test quizzes and decision maze case-studies.

 

The Basics of writing a Basic LTI Tool Provider

I hope this will be helpful for anyone just getting started with (Basic) LTI and wanting to create their first Tool Provider. Apologies for any abuse/misuse of the terminology – this is just how I understand it. To recap the two halves of an LTI launch:

Tool Consumer (TC) = An LTI-enabled VLE/LMS/other system that can make an LTI launch request. Generally (or at least the way we are using it), the TC manages user accounts/passwords, so that the Tool Provider doesn’t have to.
Tool Provider (TP) = an external tool that receives an LTI request from a TC and uses the launch data to work out what the user is able to see/do within the tool.

Useful links

I found the following useful when getting to grips with LTI and creating my first TP (in PHP):

Thanks of course to Dr Chuck and the rest of the LTI community for developing this specification and the above Classes, Tools and Tutorials.

Basic Implementation

The PHP Basic LTI class makes it very easy to do the LTI/OAuth bit of the TP. Here’s my pseudo-PHP code for the basic process:

//All of the LTI Launch data gets passed through in $_REQUEST
if(isset($_REQUEST['lti_message_type'])) {    //Is this an LTI Request?

    //We store oauth_consumer_key and secret pairs in our database, so we look the secret up here, but it can just be hard-coded (especially for testing)
    $secret = [secret];

    //Get BLTI class to do all the hard work (more explanation of these below)
    // - first parameter is the secret, which is required
    // - second parameter (defaults to true) tells BLTI whether to store the launch data is stored in the session ($_SESSION['_basic_lti_context'])
    // - third parameter (defaults to true) tells BLTI whether to redirect the user after successful validation
    $context = new BLTI($secret, true, false);

    //Deal with results from BLTI class 
    if($context->complete) exit(); //True if redirect was done by BLTI class
    if($context->valid) { //True if LTI request was verified
     //Let the user in
    }
}
else { //Not an LTI request, so either don't let this user in, or provide another way for them to authenticate, or show them only public content
}

Just to further explain the parameters passed when instantiating the BLTI class, the first argument is the secret, which is required and would usually be a string. Alternatively, you can pass through an associative array of database information (e.g. ‘table’ => ‘lti_keys’, ‘key_column’ => ‘oauth_consumer_key’), and the BLTI class will look up the secret from the database.

The second argument (true by default) tells the BLTI class whether to store the launch data in the session (from which it can be retrieved using $_SESSION[‘_basic_lti_context’]) and whether to try to automatically retrieve any stored LTI launch data if someone tries to access a tool without coming in through LTI. This means that if a user has initially come to a tool through LTI, then closes the browser tab containing the tool, and then goes directly back to the tool, without coming through LTI, as long as their session has not expired they will be allowed back into the tool, even though have not come through LTI. I would generally recommend keeping this as true, as I think this would usually be useful behaviour.

The third argument (true by default, but I generally set it to false) tells the BLTI class whether to do the redirect or not after validation of the request. Setting this to false will prevent it from doing the redirect.

I hope this is helpful. My understanding is pretty (cheap pun alert!) basic, so I would welcome any thoughts, queries, suggestions or corrections.

For further information/discussion of LTI, and how we have used it to allow access to our iCases system through WebLearn (our VLE), please see these posts: