Approval workflow how-tos
There is a lot that a developer can do with the approval workflows framework, because it was designed to give form plugin developers a lot of room for customisation.
For these how-tos, we’ll walk through an example of creating an approvalform plugin and associated workflow that allows applicants to request access to a course, with automatic enrolment if their application is approved.
We’ll call this example plugin approvalform_enrol
, the ‘Course enrolment request form’.
- 1 How to start an approvalform plugin
- 2 Create a default enrolment workflow
- 3 How to change a form label, or make it a lang string
- 4 How to add help text to form fields
- 5 How to modify form schema dynamically
- 6 How to set the application title from a form field
- 7 How to use a field value to trigger an action on event
- 8 How to capture key application data in a local table
- 9 How to trigger a transition via scheduled task
How to start an approvalform plugin
Fundamentally, approvalform plugins define the form fields and labels which are available to workflows based on them. But because they are plugins, they can also have their own front-end components, database tables, report sources, hook watchers, event observers, notifications and more.
Plugins should be placed in the server/mod/approval/form/ directory.
A minimalist approvalform plugin with the name ‘xyz' consists of the following:
form.json
- this is the JSON form schema which defines all of the available fields and sectionsversion.php
- a standardversion.php
file, with the component nameapprovalform_xyz
classes/xyz.php
- class which extends\mod_approval\model\form\approvalform_base
, can just be a stub (empty class definition)lang/en/approvalform_xyz.php
- language string for the plugin name
Install, enable, and prepare for use by workflows
Once Totara detects the new plugin and installs it via upgrade, it will need to be enabled. Go to Quick-access menu > Plugins > Approval form plugins > Manage approval form plugins and click the eye icon to enable it.
The plugin is now available when creating a new approval workflow form, which can then be used in one or more workflows. Go to Quick-access menu > Approval workflows > Manage approval forms to add a new form based on the new plugin.
Example code
To start, the approvalform_enrol
plugin consists of:
form.json
(see the JSON schema documentation for details):
{
"title": "Course enrolment request form",
"shortname": "enrol",
"revision": "Revised November 2023",
"version": "2023112400",
"language": "en",
"component": "approvalform_enrol",
"fields": [
{
"key": "course_id",
"line": "1",
"label": "Course ID",
"type": "number",
"required": true
},
{
"key": "notes",
"line": "2",
"label": "Why do you want to take this course?",
"type": "editor",
"char_length": "50",
"required": true
}
]
}
version.php
:
<?php
$plugin->version = 2023110200; // The current module version (Date: YYYYMMDDXX).
$plugin->requires = 2023110200; // Requires this Totara version.
$plugin->component = 'approvalform_enrol'; // To check on upgrade, that module sits in correct place
classes/enrol.php
:
<?php
namespace approvalform_enrol;
use mod_approval\model\form\approvalform_base;
/**
* Class enrol provides an interface to the approvalform_enrol sub-plugin.
*
* @package approvalform_simple
*/
class enrol extends approvalform_base {
// Stub
}
lang/en/approvalform_enrol.php
:
<?php
$string['pluginname'] = 'Course enrolment request form';
Create a default enrolment workflow
To create a default enrolment workflow, run this PHP script in your directory roots:
php dev/approval/enrol/create_workflow.php
How to change a form label, or make it a lang string
If the label for a form field is not quite right, you have two options for changing it:
Hard-coded change to the
form.json
schemaUse a language string key as the label, by changing it to
<key>_label
, where<key>
is the language string key.
The hard-coded approach is simple, but requires a developer if it needs to be changed in future. It also only supports forms in a single language.
Using a language string key as the label allows the label to be translated into multiple languages, and allows admins to change the label themselves by editing the language string.
Help text can also be a language string - see the How to add help text to form fields.
In either case, once the form schema has been changed, its internal version number needs to be increased. Any approval forms based on the plugin will then need to be refreshed to pick up the new schema.
Example code
Updated form.json
and language string definitions to use language strings for form labels.
form.json
updated:
...
"version": "2023112401",
...
"fields": [
{
"key": "course_id",
"line": "1",
"label": "course_id_label",
"type": "number",
"required": true
},
{
"key": "notes",
"line": "2",
"label": "notes_label",
"type": "editor",
"char_length": "50",
"required": true
}
]
lang/en/approvalform_enrol.php
updated:
// Keep keys alphabetical, please.
$string['course_id_label'] = 'Course ID from URL';
$string['notes_label'] = 'Message to manager';
$string['pluginname'] = 'Course enrolment request form';
How to add help text to form fields
There are multiple options for adding help text to a form field (help text appears when the i icon is clicked).
Hard-coded change to the
form.json
schema, add a 'help' property to any field.Use a language string key as the help text, by changing it to
<key>_help
, where<key>
is the language string key.Use the
help_html
property instead, when you need to include HTML markup. Normally help text is plain text, and special characters are escaped.
As always, once the form schema has been changed, its internal version number needs to be increased. Any approval forms based on the plugin will then need to be refreshed to pick up the new schema.
Example code
form.json
updated:
...
"version": "2023112402",
...
"fields": [
{
"key": "course_id",
"line": "1",
"label": "course_id_label",
"type": "number",
"required": true
},
{
"key": "notes",
"line": "2",
"label": "notes_label",
"help": "notes_help",
"type": "editor",
"char_length": "50",
"required": true
}
]
lang/en/approvalform_enrol.php
updated:
// Keep keys alphabetical, please.
$string['course_id_label'] = 'Course ID from URL';
$string['notes_help'] = 'Why do you want to take this course?';
$string['notes_label'] = 'Message to manager';
$string['pluginname'] = 'Course enrolment request form';
How to modify form schema dynamically
If you need to modify the form schema before it is sent to the front end to be rendered, for example to control the range of years in a date picker, or the set of available choices in a select field, you can do that by implementing adjust_form_schema_for_application()
in your approvalform class.
This is used internally to adjust editor fields for the current user, so ensure that you call parent::adjust_form_schema_for_application()
at some point in your implementation.
Pseudo-code:
public function adjust_form_schema_for_application($application_interactor, $form_schema) {
$form_schema = parent::adjust_form_schema_for_application($application_interactor, $form_schema);
if ($form_schema->has_field('fieldname')) {
// Modify the form schema here.
// See public set_ methods in \mod_approval\form_schema\form_schema
}
return $form_schema;
}
The form_schema in $form_schema
will be a subset of the form.json
schema, made up of the collection of form_views configured for the workflow at the current stage – hence the need for the conditional that checks whether the schema has the desired field.
Example code
We need to get the course ID from a $_GET
var, so that the applicant doesn’t have to fill it in themselves.
classes/enrol.php
updated:
class enrol extends approvalform_base {
/**
* Adjust form_schema by setting user profile fields.
*
* @param application_interactor $application_interactor
* @param form_schema $form_schema
* @return form_schema
*/
public function adjust_form_schema_for_application(application_interactor $application_interactor, form_schema $form_schema): form_schema {
$form_schema = parent::adjust_form_schema_for_application($application_interactor, $form_schema);
if ($form_schema->has_field('course_id')) {
$course_id = optional_param('course_id', null, PARAM_INT);
// Get course_id from the referrer if schema loaded via GraphQL.
if (!$course_id && !empty($_SERVER['HTTP_REFERER'])) {
$referer = new \moodle_url($_SERVER['HTTP_REFERER']);
$course_id = $referer->param('course_id');
}
if ($course_id) {
$form_schema->set_field_default('course_id', $course_id);
}
}
return $form_schema;
}
}
How to set the application title from a form field
Similar to how an approvalform plugin can modify form schema dynamically, it can also modify the application data (the form response), by implementing observe_form_data_for_application()
in your approvalform class.
Note that observe_form_data_for_application()
gets called whenever application data is processed - both on the way into the database (when posted by the applicant) and on the way out to the page (when the submitted application is viewed). Care should be taken to only execute actions once, and only when they need to be actioned.
Example code
We want to give each application the same name as the requested course.
classes/enrol.php
updated:
class enrol extends approvalform_base {
/**
* Allows the approvalform plugin to observe and perform specific actions based on an application instance.
*
* Note that the data may be incoming (from a mutation/post) or outgoing (to a query/form)
*
* @param application $application
* @param form_data $form_data
* @return void
*/
public function observe_form_data_for_application(application $application, form_data $form_data): void {
// Only do this if there is a course_id in the form data.
if ($form_data->has_value('course_id')) {
// Always a good idea to sanitise form data before using it!
$course_id = clean_param($form_data->get_value('course_id'), PARAM_INT);
// Load the course and use it to set the application title.
$course = new course($course_id);
// Only do this if the application has some other title.
if ($application->title != $course->fullname) {
\mod_approval\entity\application\application::repository()
->where('id', $application->id)
->update(['title' => $course->fullname]);
}
}
}
}
How to use a field value to trigger an action on event
Very often as an application moves through a workflow, we want to make something happen in the system. Currently this is only possible by observing an application event, or writing a scheduled task that looks for applications in a particular state.
Available application events include:
Application completed -
mod_approval\event\application_completed
Existing approvals invalidated due to rejection or withdrawal -
mod_approval\event\approvals_invalidated
Application approved -
mod_approval\event\level_approved
Application rejected -
mod_approval\event\level_rejected
Application entered new level -
mod_approval\event\level_started
Application fully-approved at stage -
mod_approval\event\stage_all_approved
Application ended current stage -
mod_approval\event\stage_ended
Application entered new stage -
mod_approval\event\stage_started
Application submitted -
mod_approval\event\stage_submitted
Application withdrawn -
mod_approval\event\stage_withdrawn
The Application completed event does not imply anything about whether the application was approved or not. It is triggered any time an application enters an ‘end' stage and cannot progress any further. Use Application fully-approved at stage to respond to approval.
As with all Totara events, care must be taken to ensure that you are only observing the particular events you want to observe. The event object ID is the application ID, and from there you can use the application model to discover the overall context of the application.
Event observer pseudo-code:
public static function application_completed($event) {
// Load application.
$application = application::load_by_id($event->objectid);
// Check form plugin.
if ($application->form_version->form->plugin_name !== 'xyz') {
return;
}
// Check last action (approved, rejected, withdrawn) if needed.
$application_action = $application->last_action;
// Get form response, if needed, to check a field value.
$form_data = $application->last_submission->get_form_data_parsed();
// Do something here.
}
Note that detecting true application state can be tricky with advanced workflow configurations. This is because of the configurability of workflows:
An application can reach an end state (i.e. be 'completed') on any transition, including form submission
You may need to do something at a particular stage, but stage names are admin-editable
You may need to check a particular form field value, but the admin can configure the workflow so that the field is not required, or not even available
Please contact Totara Support if you have particular issues with detecting application state, or suggestions for improvement.
Example code
Our example approvalform plugin needs to enrol the applicant on their selected course once the application is approved at all levels.
Note that we’re just using manual enrolments here for simplicity. Ideally we would create an enrolment plugin specifically for use with approvalform_enrol
-based workflows.
db/event.php
:
<?php
use approvalform_enrol\observer\application_event;
use mod_approval\event\application_completed;
defined('MOODLE_INTERNAL') || die();
$observers = [
[
'eventname' => application_completed::class,
'callback' => [application_event::class, 'application_completed'],
],
];
classes/observer/application_event.php
:
<?php
namespace approvalform_enrol\observer;
use core\entity\course;
use core\entity\role;
use core\orm\query\exceptions\record_not_found_exception;
use mod_approval\event\application_completed;
use mod_approval\model\application\action\approve;
use mod_approval\model\application\application;
/**
* Class application_event implements workflow application event observers for course enrolment workflows.
*/
class application_event {
protected const MANUAL_ENROL_PLUGIN_NAME = 'manual';
/**
* Enrol the user on the course when completed and approved.
*
* @param application_completed $event
*/
public static function application_completed(application_completed $event): void {
// Attempt to load the application.
try {
$application = application::load_by_id($event->objectid);
} catch (record_not_found_exception $ex) {
// Swallow exception.
return;
}
// Only respond to events for this approvalform plugin.
if ($application->form_version->form->plugin_name !== 'enrol') {
return;
}
// Check that the last action was approved.
if ($application->last_action->code != approve::get_code()) {
return;
}
$submission = $application->last_submission;
$form_data = $submission->get_form_data_parsed();
if ($form_data->has_value('course_id')) {
// Always a good idea to sanitise form data before using it!
$course_id = clean_param($form_data->get_value('course_id'), PARAM_INT);
$target_course = new course($course_id);
/**
* The following code is copied from \enrol_manual\webapi\resolver\mutation\enrol_user::resolve().
*/
global $CFG;
require_once($CFG->dirroot."/lib/enrollib.php");
// Check if manual enrolment is enabled.
$enrol_plugins = enrol_get_plugins(true);
if (!isset($enrol_plugins[self::MANUAL_ENROL_PLUGIN_NAME])) {
// Manual enrolment not enabled in site.
return;
}
$manual_enrol_plugin = $enrol_plugins[self::MANUAL_ENROL_PLUGIN_NAME];
$enrol_instances = enrol_get_instances($target_course->id, true);
$manual_enrol_instance = null;
if (is_iterable($enrol_instances)) {
foreach ($enrol_instances as $enrol_instance) {
if ($enrol_instance->enrol ?? '' === self::MANUAL_ENROL_PLUGIN_NAME) {
$manual_enrol_instance = $enrol_instance;
break;
}
}
}
if (!$manual_enrol_instance) {
// Manual enrolment not enabled for course.
return;
}
// Use the configured learner role, assume that it is assignable to the applicant.
$target_role = new role($manual_enrol_instance->roleid);
// Would want to check course->tenantid versus applicant tenantid here.
// Enrol the applicant.
$manual_enrol_plugin->enrol_user($manual_enrol_instance, $application->user_id, $target_role->id);
}
}
}
How to capture key application data in a local table
Approval workflows response data is stored as a JSON blob, so it can be difficult to access, filter, or sort applications based on the answers to specific form fields.
To solve this problem, approvalform plugins can create their own responses table, with fields for application_id and any form keys they wish to access or report on. Then use \approvalform_base::observe_form_data_for_application()
to save the tracked form response fields to the table.
Once application response fields are stored in a table, it can be used in report sources and business logic to supplement the information in the ttr_approval_application
table.
Example code
Our enrolment workflow needs to track the course_id provided by each application.
db/install.xml
:
<?xml version="1.0" encoding="UTF-8" ?>
<XMLDB PATH="mod/approval/form/enrol/db" VERSION="2023110202" COMMENT="XMLDB file for course enrolment approval plugin"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="../../../../../lib/xmldb/xmldb.xsd"
>
<TABLES>
<TABLE NAME="approvalform_enrol_response" COMMENT="Table to index responses by specific fields">
<FIELDS>
<FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
<FIELD NAME="application_id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
<FIELD NAME="course_id" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false"/>
</FIELDS>
<KEYS>
<KEY NAME="primary" TYPE="primary" FIELDS="id"/>
<KEY NAME="application_fk" TYPE="foreign" FIELDS="application_id" REFTABLE="approval_application" REFFIELDS="id" ONDELETE="cascade"/>
</KEYS>
<INDEXES>
<INDEX NAME="course_id_ix" UNIQUE="false" FIELDS="course_id" COMMENT="Not a foreign key. Might be null, or orphaned."/>
</INDEXES>
</TABLE>
</TABLES>
</XMLDB>
classes\entity\response.php
:
<?php
namespace approvalform_enrol\entity;
use core\entity\course;
use core\orm\entity\entity;
use core\orm\entity\relations\belongs_to;
use core\orm\entity\relations\has_one;
use mod_approval\entity\application\application;
/**
* Enrolment approval indexed form responses entity
*
* Properties:
* @property-read int $id Database record ID
* @property int $application_id Application ID
* @property int $course_id Course ID
*
* Relationships:
* @property-read application $application Related application
* @property-read course $course Related course
*
*/
class response extends entity {
/**
* Database table
*/
public const TABLE = 'approvalform_enrol_response';
/**
* Instantiates response entity by application ID
*
* @param int $application_id
* @return null|self
*/
public static function by_application_id(int $application_id): ?self {
return self::repository()->where('application_id', '=', $application_id)->one();
}
/**
* Application this response is part of.
*
* @return belongs_to the relationship.
*/
public function application(): belongs_to {
return $this->belongs_to(application::class, 'application_id');
}
/**
* Course this response is applying for enrolment to.
*
* @return has_one the relationship.
*/
public function course(): has_one {
return $this->has_one(course::class, 'course_id');
}
}
classes\enrol.php
updated:
use approvalform_enrol\entity\response;
class enrol extends approvalform_base {
public function observe_form_data_for_application(application $application, form_data $form_data): void {
// Only do this if there is a course_id in the form data.
if ($form_data->has_value('course_id')) {
// Earlier example code snipped...
// Save the local response record.
$response = response::by_application_id($application->id);
if (is_null($response)) {
$response = new response();
$response->application_id = $application->id;
}
$response->course_id = $form_data->get_value('course_id');
$response->save();
}
}
}
How to trigger a transition via scheduled task
Some workflows require time to pass before the application transitions to a new stage. This can be accomplished with a 'waiting' stage, and a scheduled task that makes the transition occur when a condition is met.
Or perhaps you want to automatically transition applications to an 'abandoned' end state after a certain amount of time, or if an approval deadline passes. Again, this is accomplished with a scheduled task.
Task pseudo-code:
public function execute(): void {
if (advanced_feature::is_disabled('approval_workflows')) {
return;
}
// Load applications that should be transitioned.
$eiligible_applications = application_entity::repository->where('field', '=', 'value');
foreach ($eiligible_applications as $application_entity) {
$application = application::load_by_entity($application_entity);
// Make the transition happen.
$next_state = $application->current_state->get_stage()->state_manager->get_next_state($application->current_state);
$application->change_state($next_state, null);
}
}
Loading the correct applications is made much easier if response fields or other metadata is tracked in a local database table, as described in the How to capture key application data in a local table section.
Example code
For our course enrolment use case, we want to automatically withdraw applications that have not been completed before the course start date is reached.
lang/en/approvalform_enrol.php
updated:
$string['withdraw_late_applications_task'] = 'Withdraw enrolment approval applications still pending at course start date';
db/tasks.php
:
<?php
use approvalform_enrol\task\withdraw_late_applications;
defined('MOODLE_INTERNAL') || die();
$tasks = [
[
'classname' => withdraw_late_applications::class,
'blocking' => 0,
'minute' => 0,
'hour' => '*',
'day' => '*',
'dayofweek' => '*',
'month' => '*'
],
];
classes\task\withdraw_late_applications.php
:
<?php
namespace approvalform_enrol\task;
use approvalform_enrol\entity\response;
use core\task\scheduled_task;
use mod_approval\model\application\action\withdraw_before_submission;
use mod_approval\model\application\action\withdraw_in_approvals;
use mod_approval\model\application\application;
use totara_core\advanced_feature;
class withdraw_late_applications extends scheduled_task {
/**
* @inheritDoc
*/
public function get_name(): string {
return get_string('withdraw_late_applications_task', 'approvalform_enrol');
}
/**
* @inheritDoc
*/
public function execute(): void {
if (advanced_feature::is_disabled('approval_workflows')) {
return;
}
// Use site admin as the actor.
$admin = get_admin();
// Find all the open responses referencing a course whose end date is past.
$eligible_responses = response::repository()
->join(['approval_application', 'application'], 'application.id', '=', 'approvalform_enrol_response.application_id')
->join(['course', 'course'], 'course.id', '=', 'response.course_id')
->where('application.is_draft', '=', 0)
->where_null('application.completed')
->where_not_null('course.startdate')
->where('course.startdate', '<', time())
->get_lazy();
foreach ($eligible_responses as $response) {
$application = application::load_by_id($response->application_id);
// Execute a withdraw action.
if ($application->current_approval_level) {
withdraw_in_approvals::execute($application, $admin->id);
} else {
withdraw_before_submission::execute($application, $admin->id);
}
// Manually set application completed if workflow withdraw doesn't land on end stage.
// This is a safety to prevent this task from continuously withdrawing the same applications.
$application->refresh(true);
if (is_null($application->completed)) {
$application->mark_completed();
}
}
}
}
Â