Using Soda to Create New Moodle Modules
Overview
Soda is a Moodle plugin to develop new modules. Soda does two things:
- It constructs all your standard module functions dynamically: you don't have to set up a complete lib.php file anymore for each new module.
- Soda provides you with a Model-View-Controller (MVC) framework for your module, eliminating a lot of complexity.
Traditionally, all your standard module functions reside in a file called lib.php. They are prefixed with your module's name, e.g.: planner_get_instance
. Soda provides these functions for your new module and allows you to override their default behavior.
The MVC part of Soda makes it easier to separate layout (html) from business logic and default Moodle code. It does so by providing a controller class which you can subclass for your module.
Soda's API documentation can be found here: soda-api.solin.eu
Here, we will show you how to use Soda to easily set up a new module.
Using Soda To Set Up a New Module
Download Soda and install the plugin inside your Moodle's local
directory, e.g.:
- moodle/local/soda
- public_html/local/soda
Go to the administration / notifications section of your Moodle website or visit the /admin url directly.
Now it's time to create your own module. Simply use Soda's shell script local/soda/create.php
. Here's an example which assumes your webroot is called 'moodle' and your new module 'planner':
cd moodle/local/soda ./create.php planner ## Your module's name should be singular (so no 's' at the end).
You can now install your module by calling the /admin/index.php
script on your Moodle site.
Please note that the create.php
script does not check if a module with the same name already exists.
Set Up a New Module Manually
Instead of using Soda's create.php
shell script, you can also set up a new module manually. This will give you a better idea of the anatomy of a Moodle module.
Create a new directory structure for you module inside your Moodle site's mod directory. Depending on the name of your 'webroot', this might be:
- public_html/mod/planner
- or, moodle/mod/planner
For example:
cd moodle/mod mkdir planner cd planner mkdir db mkdir lang mkdir lang/en touch view.php index.php version.php lib.php mod_form.php touch lang/en/planner.php touch db/access.php
Alternatively, you can download the “newmodule” template here: http://moodle.org/mod/data/view.php?d=13&rid=715 or through Git: http://docs.moodle.org/dev/NEWMODULE_Documentation. Simply replace all appearances of string “newmodule” by the name of your own module, planner
in our example.
Attention: the “newmodule” template has a value 0 for $module→version
in version.php. Change this value or the module will not be installed.
onno@mac-mini:~/public_html/mod/planner$ find . -name '*' -exec sed -i -e 's/newmodule/planner/g' {} \;
When creating a new module manually make sure the files version.php and lang/en/[modulename].php have a few lines of mandatory code:
<?php // example contents for version.php defined('MOODLE_INTERNAL') || die(); $module->version = 2012010400; // The current module version (Date: YYYYMMDDXX) $module->requires = 2011112900; // Requires this Moodle version $module->component = 'mod_planner'; // Full name of the plugin (used for diagnostics) $module->cron = 0; ?>
<?php // example contents for lang/en/planner.php $string['modulename'] = "Planner Module"; $string['modulenameplural'] = 'Planner Modules'; $string['modulename_help'] = 'Use the newmodule module for... | The newmodule module allows...'; $string['newmodulefieldset'] = 'Custom example fieldset'; $string['newmodulename'] = 'newmodule name'; $string['newmodulename_help'] = 'This is the content of the help tooltip associated with the newmodulename field. Markdown syntax is supported.'; $string['newmodule'] = 'newmodule'; $string['pluginadministration'] = 'newmodule administration'; $string['pluginname'] = 'newmodule'; ?>
Now prepare all your tables in MySQL (or whatever database you are using) and then use the xmldbeditor to extract the right xml for the newmodule/install.xml file.
You'll need at least 1 table to store the module's instances. The table should have the following fields:
CREATE TABLE IF NOT EXISTS `mdl_planner` ( `id` bigint(10) unsigned NOT NULL AUTO_INCREMENT, `course` bigint(10) NOT NULL, `name` varchar(255) COLLATE utf8_unicode_ci NOT NULL, `intro` text COLLATE utf8_unicode_ci NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci COMMENT='Default comment for planner, please edit me' AUTO_INCREMENT=1 ;
Your initial db/install.xml file should contain something like this:
<?xml version="1.0" encoding="UTF-8" ?> <XMLDB PATH="mod/family/db" VERSION="20090722" COMMENT="XMLDB file for Planner module" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="../../../lib/xmldb/xmldb.xsd" > <TABLES> <TABLE NAME="family" COMMENT="each record is one planner record"> <FIELDS> <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" UNSIGNED="true" SEQUENCE="true" NEXT="course"/> <FIELD NAME="course" TYPE="int" LENGTH="10" NOTNULL="true" UNSIGNED="true" DEFAULT="0" SEQUENCE="false" PREVIOUS="id" NEXT="name"/> <FIELD NAME="name" TYPE="char" LENGTH="255" NOTNULL="true" SEQUENCE="false" PREVIOUS="course" NEXT="intro"/> <FIELD NAME="intro" TYPE="char" LENGTH="255" NOTNULL="false" SEQUENCE="false" PREVIOUS="name"/> </FIELDS> <KEYS> <KEY NAME="primary" TYPE="primary" FIELDS="id"/> </KEYS> <INDEXES> <INDEX NAME="course" UNIQUE="false" FIELDS="course"/> </INDEXES> </TABLE> </TABLES> </XMLDB>
In order to add a new instance of your module, you'll need to create the file mod_form.php. Download the file from the newmodule template from moodle.org (https://github.com/moodlehq/moodle-mod_newmodule/blob/master/mod_form.php) if needed. Replace all 'newmodule' strings with 'planner'.
Inside mod_form.php, don't forget to change the class name as well:
FROM: class mod_newmodule_mod_form extends moodleform_mod { TO: class mod_planner_mod_form extends moodleform_mod {
Also, some strings, like 'newmodulename', should be replaced in multiple places, i.e. in both lang/en/planner.php and mod_form.php.
We need to add some code to view.php, so all calls will be redirected to index.php.
<?php // FILENAME: mod/planner/view.php require_once("../../config.php"); $id = required_param('id', PARAM_INT); header("Location: {$CFG->wwwroot}/mod/planner/index.php?id=$id"); exit; ?>
From Moodle 2.3 onwards, you need to populate your db/access.php
file with (at least) the following code:
<?php defined('MOODLE_INTERNAL') || die(); $capabilities = array( 'mod/planner:addinstance' => array( 'riskbitmask' => RISK_XSS, 'captype' => 'write', 'contextlevel' => CONTEXT_COURSE, 'archetypes' => array( 'editingteacher' => CAP_ALLOW, 'manager' => CAP_ALLOW ), 'clonepermissionsfrom' => 'moodle/course:manageactivities' ) ); ?>
After creating a basic class.planner.php, lib.php and index.php you should be able to add the module to a course. See below for creating the three files.
Have Your Module Use Soda
Create a file for your module's main class. Following our example we call this file class.planner.php
.
<?php // FILENAME: mod/planner/class.planner.php include_once("{$CFG->dirroot}/local/soda/class.soda.php"); class planner extends soda { } // class planner ?>
Use this file to override specific functions you would normally store in lib.php. For instance, to override the default function planner_delete_instance
, add a static method your module's main class:
static function delete_instance($id) { global $DB; if (! $planner = $DB->get_record("planner", array("id" => $id))) { return false; } $result = true; # Delete any dependent records here # if (! $DB->delete_records("planner", array("id" => $planner->id))) $result = false; $tables = array( "planner_categories", "planner_completed", "planner_completed_jobs" ); foreach($tables as $table) { if (! $DB->delete_records($table, array("planner_id" => $planner->id))) $result = false; } return $result; } // function delete_instance
Soda will automatically create a new function called planner_delete_instance
anyway, if you instantiate your module's main class. But now it will do so with the modified behavior.
Moodle expects to find your module's standard functions in lib.php, so a perfect place to do the instantiation of your module's main class, is inside planner/lib.php:
<?php // FILENAME: lib.php require_once("class.planner.php"); $planner_instance = new planner(); ?>
Organize Your Module's Code Using Soda
Moodle is famous for its extensibility. You can easily add new functionality by creating a new module. Unfortunately, it's also very easy to create increasingly complex code because there's no standard way to organize your code. Soda encourages you to organize your module's code using the Model-View-Controller (MVC) paradigm. Within the MVC paradigm, all code is placed in either the Controller, the View or the Model. Roughly speaking:
- Model: contains business logic
- Controller: holds the application logic
- View: the layout for a specific page or screen
Soda sets up your module with a single point of entry: index.php. Just have your module's index.php file call your main class's display
method to utilize Soda as your module's MVC framework:
<?php // $Id: index.php require_once("../../config.php"); include_once('lib.php'); $planner_instance->display(); ?>
This example follows our example of the previous section, where we created a planner
class which extends class soda
.
Add your module to a course
We're one step from being able to add our module to a new course. Adjust your mod_form.php if you downloaded the newmodule files from moodle.org (or Git) or download the mod_form.php and add it to your new plugin folder (https://github.com/moodlehq/moodle-mod_newmodule ). Replace 'newmodule' with the name of your plugin. You should be able to add your module to a new course:
Courses -> Add/edit courses -> Testcourse -> Add an activity
Convention Over Configuration
To use Soda as your module's MVC framework you have to follow a few conventions. Once you know these conventions, it's easier to follow them than to do a lot of upfront configuration (such as populating your lib.php file). This principle is called Convention over Configuration.
A Soda-based module follows these conventions:
- Each url points to index.php, e.g.: mod/planner/index.php
- Soda uses parameters (derived from either a form post or the url's query string) to determine which controller and view to use. E.g.:
mod/planner/?controller=organization&view=index
would typically show a list (index) of organizations. - The names (class, file, and directory names) of models, views and controllers are always singular (in contrast with the Ruby on Rails web application platform).
Futhermore, Soda will be looking in specific places to find your controllers and views. Your Soda-based planner module should contain this directory structure:
mod/planner/controllers mod/planner/views mod/planner/models
The models
directory can be omitted if your data model is relatively simple (does not contain any additional business logic on top of your database tables' relations).
The controllers directory contains a file for each 'topic' (or model). The views
directory is further subdivided into directories for each model (or 'topic'), because you may want to have different views for each topic. For example, if our 'planner' module contains an option to provide a planned completion date for each activity, we should add an 'activity' directory inside the views
directory:
controllers/activity.php views/activity/index.html views/activity/edit.html
Views are html snippets which may escape to php to output the value of php variables. Controllers are php files which handle specific incoming requests.
Soda Controllers
Soda expects your module's controllers to extend Soda's controller class.
// filename: mod/planner/controllers/planner.php include_once("{$CFG->dirroot}/local/soda/class.controller.php"); class planner_controller extends controller { /* ... */ } // class planner_controller
Each controller should be “about” a specific topic. The controller's class name and file name are derived from this topic. For instance, if a controller is about an activity, the class name is activity_controller
and the file name is activity.php
.
Usually, all possible CRUD operations for the topic are handled by its controller:
- Create
- Read
- Update
- Delete
To specify what controller method must be invoked, you should use the action parameter, e.g.: {$CFG→wwwroot}/mod/planner/?controller=activity&action=edit&activity_id=20&id=928
. This url will invoke the method edit
on an object of class activity_controller
. Here's an example of a controller designed to:
- see if the user is logged in;
- edit the activity with id $activity_id.
// filename: mod/planner/controllers/activity.php include_once("{$CFG->dirroot}/local/soda/class.controller.php"); class activity_controller extends controller { function __construct($mod_name, $mod_instance_id) { parent::__construct($mod_name, $mod_instance_id); $this->require_login(); } // function __construct function edit($activity_id) { global $DB; if (! $activity = $DB->get_record('planner_activities', array('id' => $activity_id)) ) { error("Could not find planner_activities record with id = $activity_id"); } echo $this->get_view(array('activity' => $activity, 'activity_id' => $activity_id) ); } // function edit } // class activity_controller
Soda's controller class provides you with a number of methods. The examples here are require_login
and get_view
.
The first is really a wrapper for the standard Moodle require_login function (but without the need to specify the course as a parameter).
The second, get_view
, returns the html for the associated action. In this example, get_view
is called from the edit
action, so the view which is automatically retrieved is mod/planner/views/activity/edit.html
. Which would typically be a form to edit the activity. The get_view
method requires you to specify the php variables you want to use in your view.
Actions Are Functions: Treat Them As Such
Actions are really function calls on a controller class. Each function should perform one task only. Here are the typical functions of a controller:
class booking_controller extends controller { // Retrieves a list of bookings function index() {} // Retrieves the details of a specific booking function show() {} // Get a specific booking and display an edit form function edit() {} // Create, update or delete a booking // In Moodle, records are usually not actually erased. // Instead, a column bookings.deleted will be set to 1 (through an update) function save() { $booking = new booking($_REQUEST['booking']); if (!$booking->save()) return $this->get_view('edit'); $this->redirect_to($action = 'index'); } }
If you find yourself adding many functions to the controller, consider refactoring them into the model class. This is summarized as fat models, skinny controllers.
Soda Models
Use a Soda model to:
- Easily specify (server side) validation rules (See validation section)
- Quickly save and load multiple records and their associated records
Conventions
To quickly load a bunch of records and their associated data as Soda models, you must tell Soda a few things about your data. Here's what a typical Soda model should look like:
<?php class category extends model { static $plural = 'categories'; static $table_name = 'compass_categories'; } // class category ?>
The conventions here are:
- The primary key is always
id
- A foreign key always looks like:
[model_name]_id
(without the square brackets of course) static $plural
is used to tell Soda what your association field should look like if it's not simply[model_name]s
Finders And Loaders
It's usually very cumbersome to retrieve an object from an array, based on the value of an object's property. In Soda, you can use a 'finder'. Here's an example:
$jackson_book = book::find_by_author('Jackson', $books);
This will retrieve the first book in a collection $books with the value 'Jackson' for the property author
.
All property names are valid finders if preceded by 'find_by_'. You can also string properties together, separating them with 'and':
$book = book::find_by_author_and_year_and_publisher('Jackson', 1999, 'Penguin');
By the same token, you can use find_all_by_
:
$jacksons_books = book::find_all_by_author_and_publisher('Jackson', 'Penguin', $books);
Now here's the good part: if you omit the collection, Soda will attempt to load it on the fly from the database.
$jacksons_books = book::find_all_by_author('Jackson');
Please note the Soda uses the restriction added in the first argument for the retrieval. In other words: the database query contains a clause WHERE author = 'Jackson
'.
When used without plugging in the collection, find
and find_all
are functionally almost identical to load
and load_all
. The loaders, however, allow you to specify which associations to include:
$authors = author::load_all_by_publisher('Penguin', $include = array('book'));
This will automatically load all associated books for each author. Please note that this takes two database calls in the background.
Soda Views
Soda views are html snippets which may use Soda's view and form helpers (based in Soda's controller).
Here's an example of a view for an index of activities, which would typically reside in mod/planner/views/activity/index.html
:
<h1><?= get_string('activities', 'planner') ?> </h1> <?= $this->form_open($action = false, array('action' => ($activity_id) ? 'update' : 'create')); ?> <table id='planner_activity_index'> <tr><th><?= get_string('activity', 'planner') ?></th></tr> <?php foreach($activities as $activity) { if ($activity->id == $activity_id) { echo "<tr> <td> <input type='text' value='{$activity->label}' name='activity[label]'> <input type='hidden' value='{$activity_id}' name='activity_id'> </td>"; echo "<td><input type='submit' name='submit' value='" . get_string('update') . "'/></td>"; echo "<td><input value='" . get_string('cancel') ."' type='button' onclick='window.location=\"" . $this->get_url() . "\"'/></td></tr>"; continue; } ?> <tr> <td><?= $activity->label ?></td> <td><a onclick="<?= $this->post_to_url_js(array('activity_id' => $activity->id)) ?>" href="#"><?= get_string('delete', 'planner') ?></a></td> <td><a href='<?= $this->get_url("activity_id={$activity->id}") ?>'><?= get_string('edit', 'planner') ?></a></td> </tr> <?php } ?> <?php if (! $activity_id ) { ?> <tr> <td> <input type='text' value='' name='activity[label]'> </td> <td colspan='2'><input type='submit' name='submit' value='<?= get_string('add') ?>'/></td> </tr> <?php } ?> </table> </form>
This approach has significant advantages over a spaghetti code style lib.php file:
- The graphical designer will always know where to find the html code: in the
views
directory. - There is very few php code in the views. And what there is, is very simple php code (mainly loops and variables).
- The views can be edited in isolation, for instance with a web design tool like Dreamweaver.
Furthermore, Soda provides you with some form and view helper methods. Because the view is rendered in the context of the Soda controller, you can call these helper methods using the php $this
keyword, e.g.: $this→form_open()
.
A Template for Your Views
You can also embed all your views in a template. This is very useful if for instance you want output all content in the middle column of a three columns table. Just place a file called template.html
directly inside your views
directory, and have it include the view through the default variable $view_path
:
<? if (soda_error::invalid()) { ?> <div class='soda_errors'> <ul> <?= soda_error::foreach_error(function($model, $field, $message) { echo "<li>$field: $message</li>"; }); ?> </ul> </div> <? }?> <h1>My First Soda based Module!</h1> <?php include_once($view_path); ?> <p class='footer'>All rights reserved</p>
Please note that this template also conditionally outputs any validation errors on top of your view.
Soda Application Flow
In the previous sections, we have explained the nuts and bolts of Soda. Now let's see how we can put it all together.
A typical course of action for the end user would be to first look up a list of items, let's say activities to be planned. Then a specific activity is edited. Finally, the activity is updated, i.e. saved to the database.
Using the Soda Model-View-Controller framework, you would provide an activity controller with a method for each of the following actions:
- index
- edit
- update
Let's review each of them.
List of items: index
So, if the user visits this url: {$CFG→wwwroot}/mod/planner/?controller=activity
, the default method index
will be invoked. The activity_controller#index method retrieves all activities from the database and hands them over to a view called index.html
.
The index view will typically contain a list of clickable items. The user chooses an activity by clicking a link or a button, which points to: {$CFG→wwwroot}/mod/planner/?controller=activity&action=edit&activity_id=23
.
Changing a specific item: edit
Soda invokes the edit
method on an object of class activity_controller. The edit method returns a form where the user makes the necessary changes. The changed activity is posted to {$CFG→wwwroot}/mod/planner/index.php
, with a few required post parameters: action=update
, activity_id=23
and controller= activity
.
Saving the edits: update
This time, Soda invokes the update
method, which stores the changes in the database. Please note that whenever you call a typical database modifying action, you should redirect the browser afterwards. A redirect prevents accidental reposts (which would otherwise happen whenever the user refreshes the browser window).
If Soda detects that you have used its controller#redirect_to
method, it will omit all layout. Soda will skip not just your module's layout, but also the standard Moodle header and footer. The same can be achieved by calling a Soda based module with the no_layout=true
parameter (either through the querystring or as a post parameter).
Here's what an update method might look like:
// filename: mod/planner/controllers/activity.php function update($activity_id) { global $DB; $activity = $_POST['activity']; if (! $record = $DB->get_record('compass_activities', array('id' => $activity_id)) ) { error("Could not find compass_activities record with id = $activity_id"); } $record->label = $activity['label']; $DB->update_record('compass_activities', $record); $this->redirect_to('index'); } // function update
In this example, the user is redirected back to the index (i.e. list of activities). Please note that Soda automatically invokes all methods with the $activity_id
as the first parameter. If the post or get variable with the same name is not available, Soda will just insert false
as the first argument for the method call.
On a final note: for explanatory reasons we have skipped over security issues. For instance, you might want to check whether the user actually has the right to update the activity.
Default CRUD methods
By default, Soda provides the following CRUD methods for Soda based models (more about those later, in the section “Soda Server Side Validation”):
- Save: covers both Create and Update (simply provide the id as the first argument to make updates)
- Delete
Save always calls a validation method first. If the model is invalid, there will be no actual insert or update and the method returns false. Delete simply sets the deleted
column of your model to 1
and then calls save_without_validation
.
Soda Server Side Validation
Soda makes it very easy to perform server side validation. If you stick to a few conventions, you won't have to do a lot of work yourself.
In Soda, you specify validation rules inside models. A model usually contains the information for one database record. You can populate a new model with properties and corresponding values:
$organization = new organization($_POST['organization']);
In this example, all fields and values of the array 'organization' are translated into properties of the object $organization. To enable this, your model should extend Soda's default model. You also need to define the actual validation rules for each of your model's properties. Here's how to:
- Make sure the there's a method
define_validation_rules
- Inside
define_validation_rules
, call the default Soda model methodadd_rule
to define a new rule
The method add_rule
requires three arguments:
- The property name
- The error message
- The actual validation code as an anonymous function
Here's an example:
<?php class organization extends model { static $table_name = 'compass_organizations'; function define_validation_rules() { $this->add_rule('label', 'Please fill in a label.', function($label) { return ( trim($label) != '' ); }); $this->add_rule('label', 'Must be a number', function($label) { return ( is_numeric($label) ); }); $this->add_rule('email', 'Please fill in your email.', function($email) { return ( trim($email) != '' ); }); } // function define_validations } // class organization ?>
Whenever the model is validated, e.g. every time it is saved by the default Soda save
method, all validation rules are applied. Please note that you can also specify multiple rules for a single property. If the model is invalid, the save method will return false.
In the following example:
- We instantiate a new organization model with the properties derived from the form post. The original form contains fields of the type
<input type='text' name='organization[email]'/>
. In other words: your form fields must have names that correspond exactly with the columns names of the database table where the “organization” data is stored.
- We save the organization by calling the default
save()
method. If this call returns false, we output the form again, and populate its fields with the contents of the $organization object so the user can fix the invalid fields.
function create() { $organization = new organization($_POST['organization']); if (!$organization->save()) return $this->get_view('edit'); $this->redirect_to('index'); } // function create
Soda automatically keeps track of all validation errors in the static class soda_error
.
Here's how you can output the errors, for instance inside views/template.html
(so you won't have to put this code in each of your views):
<? if (soda_error::invalid()) { ?> <div class='soda_errors'> <ul> <?= soda_error::foreach_error(function($model_name, $instance, $field, $message) { echo "<li>$field: $message</li>"; }); ?> </ul> </div> <? }?>
Avoiding Repetition With Helpers
If you find yourself repeating a lot of layout code in your views, consider using helpers. You may also want to put generic form fields, e.g. a “countries” select box, inside a helper class.
Soda supports helpers on two levels: a general helper class, named after your module, and a specific helper class, named after your controller. So, if your module is named “planner”, your helper files look like this:
- General: mod/planner/helpers/class.planner_helper.php
- Specific: mod/planner/helpers/calendar/class.calendar_helper.php
All non-static methods and properties available in your controller class are also available in your helper classes. Conversely, all methods, but not the properties, of the helper classes are available inside your controller class.
Due to limitations of php, static properties cannot be made available in the context of a helper. Of course, you can still access static properties in a helper class by spelling out the name of the controller: planner_controller::$the_static_property
.
Your helper classes must extend the Soda helper class, which is already included by Soda:
<?php class planner_helper extends helper { function print_countries_dropdown_box($country_preset = null) { echo "<select id='{$this->model_name}_country' name='{$this->model_name}[country]'>"; echo "<option value=''>".get_string('select')."</option>"; foreach(get_list_of_countries() as $key=>$country) { $selected = ($country_preset == $key) ? "selected='selected'" : ""; echo "<option value='$key' $selected>$country</option>"; } echo "</selected>"; } // function print_countries_dropdown_box() } ?>
Layout Helper
Ever since php 5.3, we can use closures (“anonymous functions”) to help us out with layout issues. Here's an example:
In your view:
<?= $this->fieldset(function() use ($stories) { ?> <? $i = 0; ?> <? foreach ($stories as $story) { ?> <? if ($story->selected == '' || $story->selected == 0) continue; ?> <? $i++; ?> <div class="compass_label"><?= $i ?>. <?= $story->title ?></div> <? } ?> <? }); ?>
In your helper:
function fieldset($displayer) { echo "<div class='compass_fieldset'> <div class='compass_dummy'> <fieldset class='hidden'> <div>"; echo $displayer(); echo " </div> </fieldset> </div> </div>"; } // function fieldset
That's a lot nicer than using something like $this→open_fieldset() … $this→close_fieldset()
, which forces you to define a pair of functions for each layout box.
You are here: start » moodle » using_soda_to_create_new_moodle_modules