Tutorial: advanced plugin development

This tutorial covers the following topics on Cotonti extension development:

  • Creating a plugin.

  • Using the database in your plugin.

  • Using CoTemplate to provide administration interface.

  • Localization of the interface.

  • Tools for administrators in plugins.

  • Modification of system behavior.

  • Using AJAX to implement auto-complete.

#1. Planning the features and structure

The plugin we are going to make in this article is the long requested tool that would let site administrators assign custom access permissions for selected users, without creating custom groups for those users and affecting group permissions. As we need some official plugin name and plugin code (used in filenames etc.) let's call it “Rights per user” and 'rightsperuser' accordingly.

The first thing we need to do is to decide what features our plugin will provide to its end users. After thinking it over, we could come up to something like this:

  • Modify permissions on a selected item for a specific user.

  • Store modification rules in the database.

  • Provide an interface to add/update/delete rules.

Then we need to answer some questions about implementation details:

  1. Does the plugin provide user front end? - Yes.

  2. Is the interface available in multiple languages? - Yes.

  3. Does the plugin have custom database tables? - Yes.

  4. Should it provide an API for 3rd party plugins? - No.

  5. Does it need custom resource strings? - No.

  6. Does it have some JavaScript? - Yes

Based on these answers we need to create the following subfolders in our plugin's folder: 'tpl' for user interface templates, 'lang' for localization files, 'setup' for SQL schema. The answers 4 and 5 mean that we don't need include files, but as we are going to share names of database tables (answer 3) across different parts of our plugin, we also need 'inc' folder to store 'rightsperuser.functions.php' file where we define such table names. The answer to question 6 means that we also need a 'js' folder to store our custom JavaScript.

So far our plugin has the following structure:

rightsperuser structure 1

Administration interface means that we need 'rightsperuser.admin.php' part in our plugin which will provide list/add/update/delete operations for custom permissions. This part is quite big but simple. A more difficult question to answer is how we are going to apply those custom permissions when the user is actually acting on site. To answer this question we need to analyze how Cotonti authorization permissions work. If you are new to Cotonti and only wonder how to make plugins, you may skip next chapter.

#2. Digging deeper: affecting user's permissions

Please read this article first if you are not familiar with Cotonti's authorization system. Then you know that permissions for current user are always requested with cot_auth() function. If we want to modify permissions we need to know how this function works.

Find the source code of the function in system/functions.php. The signature of the function is:

/**
* Returns specific access permissions
*
* @param string $area Cotonti area
* @param string $option Option to access
* @param string $mask Access mask
* @return mixed
*/
function cot_auth($area, $option, $mask = 'RWA')

The first argument accepts module code for modules or 'plug' for plugins. The second argument accepts custom module item for modules or plugin code for plugins. The third parameter sets permission mask for the item. Masks are converted from string representation such as “RWA” to an integer number so that binary operators can be used to check certain kinds of permissions.

Analyzing the code of cot_auth() function you could notice that it takes actual permissions from the following variable:

$usr['auth'][$area][$option]

This is a global variable, so if we modify it before any cot_auth() call, e.g. in global hook, it will work as we want.

You can also have a look at cot_auth database table in phpMyAdmin to get a notion of raw area/option/mask values.

The part of our plugin which will be responsible for modification of $usr['auth'] will be called 'rightsperuser.global.php' because it uses 'global' hook.

#3. Plugin setup file

We must create 'rightsperuser.setup.php' file to provide metadata about our plugin. Its contents is as simple as this:

<?php
/* ====================
[BEGIN_COT_EXT]
Code=rightsperuser
Name=Rights per user
Description=Assigns custom permissions for specific users without affecting group permissions
Version=1.0
Date=2011-11-22
Author=Trustmaster
Copyright=Copyright (c) Vladimir Sibirov and Cotonti Team 2011
Notes=
Auth_guests=R
Lock_guests=12345A
Auth_members=R
Lock_members=
Requires_plugins=autocomplete
[END_COT_EXT]

[BEGIN_COT_EXT_CONFIG]
rules_perpage=01:select:5,10,20,30,50,100:30:Rules displayed per page
[END_COT_EXT_CONFIG]
==================== */

defined('COT_CODE') or die('Wrong URL');

?>

For more information about setup files please visit Introduction to extension development. This setup file doesn't contain anything special except for “Requires_plugins” setting. This setting tells the extension installer that this plugin requires 'autocomplete' plugin to be installed (we will reuse it in our admin interface) and our plugin will fail to install otherwise.

Our plugin has one configuration option which sets how many rules are displayed on one page in admin tool. For more information about configuration options please see Configuration values.

As our plugin is going to use its own database tables, we need to define their structure in an SQL file which will be executed upon plugin installation. Call it 'rightsperuser.install.sql' and put it into the 'setup' subfolder of our plugin. The structure of the database table that we need is quite similar to cot_auth but more simple:

/* Custom user permissions schema */
CREATE TABLE IF NOT EXISTS `cot_rightsperuser` (
	`ru_id` INT NOT NULL AUTO_INCREMENT, -- Rule ID
	`ru_user` INT NOT NULL REFERENCES `cot_users` (`user_id`), -- User ID
	`ru_code` VARCHAR(255) NOT NULL, -- Auth code
	`ru_option` VARCHAR(255) NOT NULL, -- Auth option
	`ru_rights` TINYINT UNSIGNED NOT NULL DEFAULT '0', -- Permissions
	PRIMARY KEY (`ru_id`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;

Let's create other plugin parts and localization files and templates to fill them with real code later. For now we can just leave them empty, but plugin directory structure is mostly complete:

rightsperuser structure 2

Now the preparation step is over and we are ready to burst with code!

#4. Template for the rule editor tool

One of the approaches to software development is to imagine the software from the end user point, then imagine it in more detail and then come down to particular functions. Let's use something similar and create the UI template for our tool first.

Template contents are usually enclosed into BEGIN: MAIN / END: MAIN block:

<!-- BEGIN: MAIN -->

block contents are here

<!-- END: MAIN -->

This is because a template may contain several root-level blocks at the same time, but usually only one root block is needed. The contents of our template could be logically split into 2 parts: a table for editing existing rule and a form to add a new rule to the table. There are also such secondary elements as headings and pagination.

Here is the code for editable table of rules:

<table class="cells centerall">
	<tr>
		<td class="coltop">{PHP.L.User}</td>
		<td class="coltop">{PHP.L.Code}</td>
		<td class="coltop">{PHP.L.Item}</td>
		<td class="coltop">{PHP.R.admin_icon_auth_r}</td>
		<td class="coltop">{PHP.R.admin_icon_auth_w}</td>
		<td class="coltop">{PHP.R.admin_icon_auth_1}</td>
		<td class="coltop">{PHP.R.admin_icon_auth_2}</td>
		<td class="coltop">{PHP.R.admin_icon_auth_3}</td>
		<td class="coltop">{PHP.R.admin_icon_auth_4}</td>
		<td class="coltop">{PHP.R.admin_icon_auth_5}</td>
		<td class="coltop">{PHP.R.admin_icon_auth_a}</td>
		<td class="coltop">{PHP.L.Update}</td>
		<td class="coltop">{PHP.L.Delete}</td>
	</tr>
	<!-- BEGIN: RIGHTSPERUSER_ROW -->
	<form action="{RIGHTSPERUSER_ROW_ACTION}" method="POST">
	<tr>
		<td>{RIGHTSPERUSER_ROW_USER}</td>
		<td>{RIGHTSPERUSER_ROW_CODE}</td>
		<td>{RIGHTSPERUSER_ROW_OPTION}</td>
		<td>{RIGHTSPERUSER_ROW_AUTH_R}</td>
		<td>{RIGHTSPERUSER_ROW_AUTH_W}</td>
		<td>{RIGHTSPERUSER_ROW_AUTH_1}</td>
		<td>{RIGHTSPERUSER_ROW_AUTH_2}</td>
		<td>{RIGHTSPERUSER_ROW_AUTH_3}</td>
		<td>{RIGHTSPERUSER_ROW_AUTH_4}</td>
		<td>{RIGHTSPERUSER_ROW_AUTH_5}</td>
		<td>{RIGHTSPERUSER_ROW_AUTH_A}</td>
		<td><input type="submit" class="submit" value="{PHP.L.Update}" /></td>
		<td><a href="{RIGHTSPERUSER_ROW_DELETE_URL}" class="button">{PHP.L.Delete}</a></td>
	</tr>
	</form>
	<!-- END: RIGHTSPERUSER_ROW -->
</table>

This listing deserves a thorough explanation. Lines 3-15 are column headings which are taken from language strings ({PHP.L.language_array_key}) and icons for permission types taken from resource strings ({PHP.R.resource_array_key}). Classes “cells” and “coltop” are traditional in Cotonti for tables which actually look like tables and their column headings. The “centerall” class sets center alignment for all table elements.

Lines 17-35 represent an actual row of the table enclosed in BEGIN/END block which should be parsed for each database entry. Each row is given its own HTML form. In many cases it is more practical to put the entire database in a single form and update all rows at once, but in this example plugin we will update one row at a time for simplicity. Most inputs are generated in PHP script with form generation functions, so they are represented by simple TPL tags. Update button submits the form for current row. Delete button is a hyperlink with a specific URLs and class.

User” cell will be implemented as text input with auto-complete ability. “Code” will be a select dropdown. “Option” will be a dropdown with options loaded via AJAX depending on current “Code” selection.

Below the table we put a form for adding new rules:

<form action="{RIGHTSPERUSER_ADD_ACTION}" method="POST">
<table class="cells centerall">
	<tr>
		<td class="coltop">{PHP.L.User}</td>
		<td class="coltop">{PHP.L.Code}</td>
		<td class="coltop">{PHP.L.Item}</td>
		<td class="coltop">{PHP.R.admin_icon_auth_r}</td>
		<td class="coltop">{PHP.R.admin_icon_auth_w}</td>
		<td class="coltop">{PHP.R.admin_icon_auth_1}</td>
		<td class="coltop">{PHP.R.admin_icon_auth_2}</td>
		<td class="coltop">{PHP.R.admin_icon_auth_3}</td>
		<td class="coltop">{PHP.R.admin_icon_auth_4}</td>
		<td class="coltop">{PHP.R.admin_icon_auth_5}</td>
		<td class="coltop">{PHP.R.admin_icon_auth_a}</td>
		<td class="coltop">{PHP.L.Add}</td>
	</tr>
	<tr>
		<td>{RIGHTSPERUSER_ADD_USER}</td>
		<td>{RIGHTSPERUSER_ADD_CODE}</td>
		<td>{RIGHTSPERUSER_ADD_OPTION}</td>
		<td>{RIGHTSPERUSER_ADD_AUTH_R}</td>
		<td>{RIGHTSPERUSER_ADD_AUTH_W}</td>
		<td>{RIGHTSPERUSER_ADD_AUTH_1}</td>
		<td>{RIGHTSPERUSER_ADD_AUTH_2}</td>
		<td>{RIGHTSPERUSER_ADD_AUTH_3}</td>
		<td>{RIGHTSPERUSER_ADD_AUTH_4}</td>
		<td>{RIGHTSPERUSER_ADD_AUTH_5}</td>
		<td>{RIGHTSPERUSER_ADD_AUTH_A}</td>
		<td><input type="submit" class="submit" value="{PHP.L.Add}" /></td>
	</tr>
</table>
</form>

The table/form is quite similar with one above but it has just one row for adding a new rule into the database.

There are some other interface elements that we will add later on.

#5. Controller for the rule editor tool

We start coding the controller code with a skeleton of the script. Our script is located in 'rightsperuser.admin.php' file that uses 'tools' hook (hook for admin tools) and can be accessed using the url: admin.php?m=other&p=rightsperuser or (admin/other?p=rightsperuser with SEF URLs). We will use a common GET parameter 'a' (usually understood as “action”) to differentiate our scripts behavior. There are 4 common parameters of this kind used across Cotonti modules:

  • $m — “mode”;

  • $n — “secondary mode”;

  • $a — “action”;

  • $b — “secondary action”.

They are imported automatically as alphanumeric values, they are not obligatory but are often used in these meanings.

So, our script will handle following actions:

  • 'add' – adding a new rule;

  • 'update' – update an existing rule;

  • 'delete' – delete an existing rule.

It displays the status of these actions using standard messaging API and no matter what action has been performed, it displays the table of rules and a form for adding a new one.

You can find full source of the script here.

Here the skeleton is:

<?php
/* ====================
[BEGIN_COT_EXT]
Hooks=tools
[END_COT_EXT]
==================== */

(defined('COT_CODE') && defined('COT_ADMIN')) or die('Wrong URL.');

$id = cot_import('id', 'G', 'INT');

if ($a == 'add')
{
	// Adding a new rule
}
elseif ($a == 'update' && $id > 0)
{
	// Updating an existing rule
}
elseif ($a == 'delete' && $id > 0)
{
	// Existing rule removal
}

// Render the rules table and form
$ru_t = new XTemplate(cot_tplfile('rightsperuser', 'plug'));

?>

Line 8 checks if the script has been included from the Administration area of Cotonti. Line 10 is responsible for importing an integer identifier variable from the get parameters. This variable is used to identify rules to be updated or deleted.

Line 26 creates a new XTemplate object from a TPL file which is located using a helper function which calculates path to 'rightsperuser' template of the plugin.

Next we'll wrap these “bones” with “meat”, but before that we need to create a file to store our database table name definition. It's called 'rightsperuser.functions.php' and it is located in 'inc' subfolder. Here it is:

<?php
/**
 * Rights per user API
 * 
 * @package rightsperuser
 * @author Trustmaster
 * @copyright (c) Vladimir Sibirov, 2011
 * @license BSD
 */

defined('COT_CODE') or die('Wrong URL');

// Calculate database table name if not set in config.php
$db_rightsperuser = (isset($db_rightsperuser)) ? $db_rightsperuser : $db_x . 'rightsperuser';

?>

“Functions files” or “API files” are quite common. They are used if some of the plugin's code is shared among the parts of the plugin itself or with other extensions. Notice that it starts with a PHPDoc comment providing information about this file. Using PHPDocs to document your files, constants, functions and methods is highly recommended.

In this particular case we use such a file to set default value for $db_rightsperuser using default database prefix and table name. This variable can be easily overwritten by site administrator in 'datas/config.php' file.

Then we add inclusion of this and other required API's at the top of 'rightsperuser.admin.php':

// We need Forms API to generate form elements
require_once cot_incfile('forms');
// Own functions and globals
require_once cot_incfile('rightsperuser', 'plug');

cot_incfile() is a helper function which calculates a path to an include file. Given 1 argument it loads a core API, given 2 arguments it loads an API of a given type ('module' or 'plug'), the 3rd argument can be used to load something other than 'functions'.

#5.1. Importing POST data

The following piece of code is used to import an item from HTTP POST data (submitted form), validate it and throw error messages if any:

if ($_SERVER['REQUEST_METHOD'] == 'POST')
{
	// Get the submitted item
	$rule = array();
	// We need an ID from database for the user name specified
	$user_name = cot_import('user', 'P', 'TXT');
	$res = $db->query("SELECT user_id, user_maingrp FROM $db_users
			WHERE user_name = ?", array($user_name));
	if ($res->rowCount() == 1)
	{
		// OK, user found
		$row = $res->fetch();
		if ($row['user_maingrp'] == COT_GROUP_SUPERADMINS)
		{
			// Don't affect superadmins
			cot_error('rightsperuser_superadmin', 'user');
		}
		else
		{
			// Got user ID
			$rule['ru_user'] = (int) $row['user_id'];
		}
	}
	else
	{
		// No such user
		cot_error('rightsperuser_invalid_user', 'user');
	}
	$rule['ru_code'] = cot_import('code', 'P', 'ALP');
	$rule['ru_option'] = cot_import('option', 'P', 'ALP');
	// Calculate rights byte
	$rights = 0;
	foreach ($mn as $key => $val)
	{
		if (cot_import('auth_'.$key, 'P', 'BOL'))
		{
			$rights += $val;
		}
	}
	$rule['ru_rights'] = $rights;
}

The file is commented rather well, so we'll pay more attention to details:

  • cot_import() is used to import an input parameter and filter it. The first argument is parameter name, the second is import source and the third is a filter type. Widely used filters are 'ALP' (alphanumeric), 'INT' (integer), 'BOL' (boolean), 'TXT' (text) and 'HTM' (unfiltered text).

  • cot_error() is a part of standard error handling in Cotonti. It saves an error message in error stack and sets error flag to true. Current value of error flag can be checked with cot_error_found() function.

  • $db is a global instance of CotDB class which inherits all properties and methods from PDO class. $db->query() is extended so that it accepts PDO placeholders in its first argument with substitutions passed as second argument. It returns and instance of PDOStatement class.

  • $res is an object of PDOStatement class which represents database rowset.

  • $rights is calculated as an integer binary mask, a combination of bits which set different kinds of permissions. Normally you don't need to care about it at all.

If you wonder what is $mn, it's an array that matches permission letters with appropriate bits:

$mn = array();
$mn['R'] = 1;
$mn['W'] = 2;
$mn['1'] = 4;
$mn['2'] = 8;
$mn['3'] = 16;
$mn['4'] = 32;
$mn['5'] = 64;
$mn['A'] = 128;

After the above part is executed, $rule contains imported fields. This variable is used as new row data for 'add' action, or as updated row data for 'update' action or as current input values when displaying the form to add new items. In the form it ensures that user input is not lost even if there is an error.

#5.2. Rendering the rules

In order to display the table of rules that will be browsed and updated by user, we need to load existing rules from the database and display each row as a set of TPL tags and form inputs. Here is the code that does it:

// Get available auth codes
$auth_codes = $db->query("SELECT DISTINCT auth_code
		FROM $db_auth")->fetchAll(PDO::FETCH_COLUMN);

// SELECT all rules
$res = $db->query("SELECT r.*, u.user_name
	FROM $db_rightsperuser AS r
		LEFT JOIN $db_users AS u ON r.ru_user = u.user_id
	ORDER BY ru_user, ru_code, ru_option");
	
// Loop through the result rowset
foreach ($res->fetchAll() as $row)
{
	// Generate checkboxes for each kind of permissions
	foreach ($mn as $key => $val)
	{
		$checked = (($row['ru_rights'] & $val) == $val);
		$rt->assign('RIGHTSPERUSER_ROW_AUTH_'.$key,
				cot_checkbox($checked, 'auth_'.$key));
	}
	// Get the options for selected code
	$options = $db->query("SELECT DISTINCT auth_option FROM $db_auth
			WHERE auth_code = ?", array($row['ru_code']))
			->fetchAll(PDO::FETCH_COLUMN);
	// Generate other tags
	$id = $row['ru_id'];
	$rt->assign(array(
		'RIGHTSPERUSER_ROW_ACTION'
			=> cot_url('admin', 'm=other&p=rightsperuser&a=update&id='.$id),
		'RIGHTSPERUSER_ROW_USER'
			=> cot_inputbox('text', 'user', $row['user_name'],
				array('class' => 'user')),
		'RIGHTSPERUSER_ROW_CODE'
			=> cot_selectbox($row['ru_code'], 'code', $auth_codes, $auth_codes,
				false, array('class' => 'code', 'id' => 'code_'.$id)),
		'RIGHTSPERUSER_ROW_OPTION'
			=> cot_selectbox($row['ru_option'], 'option', $options, $options,
				false, array('class' => 'option', 'id' => 'opt_'.$id)),
		'RIGHTSPERUSER_ROW_DELETE_URL'
			=> cot_url('admin', 'm=other&p=rightsperuser&a=delete&id='.$id)
	));
	$rt->parse('MAIN.RIGHTSPERUSER_ROW');
}

What's interesting about this code:

  • $auth_codes is an array containing all unique values of auth_code column in cot_auth table.

  • fetchAll(PDO::FETCH_COLUMN) on PDOStatement returns an array with the values of the same column in the entire result set of the query.

  • $res->fetchAll() returns the entire rowset contained in PDOStatement $res as an array which can be easily iterated using foreach loop.

  • $rt->assign() is a call to XTemplate::assign() method which is used to assign one or an array of template tags.

  • cot_checkbox() is a Forms API function which generates a checkbox with given name and current value.

  • $options contains unique valus of auth_option column in cot_auth table for given auth_code value.

  • cot_url() function is used to generate all the URLs in Cotonti. It makes SEF URLs and advanced URL manipulation possible.

  • cot_inputbox() is a Forms API function which generates an HTML input element with given type, name, current value and other parameters.

  • cot_selectbox() is a Forms API function which generates a select dropdown with given name, selection, list of values and titles and some other parameters.

  • We assign class attribute values such as “user”, “code” and “option” and id attribute values to use them in our jQuery code to implement auto-complete.

  • $rt->parse('MAIN.RIGHTSPERUSER_ROW') parses block 'RIGHTSPERUSER_ROW' within block 'MAIN' in $rt template. After this call a completely rendered table row is added in the template object.

Very similar but quite more simple code is used to assign template tags and render the form that is used to add a new rule:

$options = empty($rule['ru_code']) ? array('---')
	: $db->query("SELECT DISTINCT auth_option FROM $db_auth WHERE auth_code = ?",
		array($rule['ru_code']))->fetchAll(PDO::FETCH_COLUMN);
foreach ($mn as $key => $val)
{
	$checked = (($rule['ru_rights'] & $val) == $val);
	$rt->assign('RIGHTSPERUSER_ADD_AUTH_'.$key,
			cot_checkbox($checked, 'auth_'.$key));
}
$rt->assign(array(
	'RIGHTSPERUSER_ADD_ACTION'
		=> cot_url('admin', 'm=other&p=rightsperuser&a=add'), 
	'RIGHTSPERUSER_ADD_USER'
		=> cot_inputbox('text', 'user', $user_name,
			array('class' => 'user')),
	'RIGHTSPERUSER_ADD_CODE'
		=> cot_selectbox($rule['ru_code'], 'code', $auth_codes, $auth_codes,
			true, array('class' => 'code', 'id' => 'code_add')),
	'RIGHTSPERUSER_ADD_OPTION'
		=> cot_selectbox($rule['ru_option'], 'option', $options, $options,
			false, array('class' => 'option', 'id' => 'opt_add'))
));

We need to add some more code at the end of our script for the entire template to be rendered and displayed as the body part in the administration panel:

$rt->parse();
$plugin_body = $rt->text();

Note: in standalone plugins $t is used as the plugin template object and this piece of code isn't needed because it is done by the system automatically.

The display controller is now ready to work. We will add some extended features to it a bit later, now we need to add code that will handle data manipulation actions.

#5.3. Adding a new rule

There is the code in our script that import a new rule data but there is yet no code which handles 'add' action in particular. Let's add it inside the appropriate 'if':

if ($a == 'add')
{
	// Adding a new rule
	if ($db->query("SELECT COUNT(*) FROM $db_rightsperuser
			WHERE ru_user = ? AND ru_code = ? AND ru_option = ?",
			array($rule['ru_user'], $rule['ru_code'], $rule['ru_option']))
			->fetchColumn() > 0)
	{
		cot_error('rightsperuser_duplicate');
	}

	if (!cot_error_found())
	{
		// Insert a new DB record
		$db->insert($db_rightsperuser, $rule);
		// Send success message
		cot_message('rightsperuser_added');
		// Back to main to avoid refresh accidents
		cot_redirect(cot_url('admin', 'm=other&p=rightsperuser', '', true));
	}
}

First we make a check against duplicate entry in the database. If a rule for the same user/code/option combination is found, we emit an error message with code 'rightsperuser_duplicate' (we will provide localized strings for all messages later in this tutorial).

If no errors have been found during import, validation and duplication check, we insert a new row in the database, send localized success message 'rightsperuser_added' and redirect back to the main tool URL. The last one is not obligatory but it is highly recommended, because if you don't do that and a user hits F5 (refresh) in his browser the data will be submitted again.

Some new things to extend your Cotonti glossary:

  • fetchColumn() method of PDOStatement class fetches a single column value.

  • $db->insert() generates and executes SQL INSERT query from a table name and an associative array containing row data or a set of such arrays. It returns the actual number of inserted records.

  • cot_message() is a more generic analog of cot_error() which emit all kinds of messages. In our case it emits successful status.

  • cot_redirect() immediately redirects to a relative URL passed as its argument.

  • cot_url() must be used with 4th parameter set to TRUE in HTTP headers, JavaScript code and other kinds of strings which must not be html-encoded.

#5.4. Updating an existing rule

It is very similar to adding, but we need a positive confirmation of row existence and we use a different method to update a record in the database:

elseif ($a == 'update' && $id > 0)
{
	// Updating an existing rule
	if ($db->query("SELECT COUNT(*) FROM $db_rightsperuser WHERE ru_id = ?",
			array($id))->fetchColumn() == 0)
	{
		cot_error('rightsperuser_notfound');
	}

	if (!cot_error_found())
	{
		// Update record in DB
		$db->update($db_rightsperuser, $rule, "ru_id = ?", array($id));
		// Send success message
		cot_message('rightsperuser_updated');
		// Back to main to avoid refresh accidents
		cot_redirect(cot_url('admin', 'm=other&p=rightsperuser', '', true));
	}
}

The only thing which is new to you is:

  • $db->update() method which generates and runs an SQL UPDATE query from given arguments. The number of affected rows is returned.

#5.5. Deleting a rule

Is the most simple action:

elseif ($a == 'delete' && $id > 0)
{
	// Existing rule removal
	$num = $db->delete($db_rightsperuser, "ru_id = ?", array($id));
	// Send the message
	($num > 0) ? cot_message('rightsperuser_deleted')
		: cot_error('rightsperuser_delete_error');
	// Back to main to avoid refresh accidents
	cot_redirect(cot_url('admin', 'm=other&p=rightsperuser', '', true));
}

Nutrition facts:

  • $db->delete() removes all records from a table which match a given condition and returns the number of items removed.

  • Depending on whether some items were removed or not we show either success or error message.

Now we've got the minimal code for our admin tool to work! Later we'll add some advanced features to it.

#6. Global part to apply the rules

Now that we've got the table containing custom auth rules and an admin tool to edit it, it's time to add a part that will apply those rules. It's called 'rightsperuser.global.php' and it is executed on every request. It needs to check if current user isn't a guest and it should load and apply rules for that user. Here it is:

<?php
/* ====================
[BEGIN_COT_EXT]
Hooks=global
Order=9
[END_COT_EXT]
==================== */

defined('COT_CODE') or die('Wrong URL');

if ($usr['id'] > 0)
{
	// Self API required
	require_once cot_incfile('rightsperuser', 'plug');
	
	// Load all rules for current user
	$res = $db->query("SELECT * FROM $db_rightsperuser WHERE ru_user = ?", array($usr['id']));
	while ($row = $res->fetch())
	{
		$usr['auth'][$row['ru_code']][$row['ru_option']] = $row['ru_rights'];
	}
}

?>

First have a look at the header of this plugin part. The Hooks setting is 'global' as we told before. But what does Order=9 mean? Well, it means that this part will have higher priority than most of the other plugins which use the same hook because the default Order is 10. This is quite logical because we need to apply our modified permissions before any other plugins may start using them. Still, those plugins which need to change permissions before our plugin can use Orders from 0 to 8 to overwrite them before our plugin.

The rest is quite simple and we have seen such code before. Well, maybe except for this:

  • fetch() method of PDOStatement class returns next row from result set as an array. In Cotonti it is tweak so that it returns an associative array by default.

#7. Localization

We have already been using language strings in our template via {PHP.L.some_key} and in our PHP code via $L['some_key'] or passing 'some_key' as the first argument of cot_error() and cot_message() functions. These strings should be actually substituted with something and it means we should provide them in language files located in 'lang' subfolder of our plugin.

For 'rightsperuser.en.lang.php' which is our English language file it is as simple as this:

<?php

defined('COT_CODE') or die('Wrong URL.');

// Strings and messages
$L['rightsperuser_add'] = 'New rule';
$L['rightsperuser_added'] = 'New rule has been added';
$L['rightsperuser_delete_error'] = 'Could not delete the rule';
$L['rightsperuser_deleted'] = 'The rule has been deleted';
$L['rightsperuser_duplicate'] = 'Such a rule already exists';
$L['rightsperuser_invalid_user'] = 'Invalid user name';
$L['rightsperuser_notfound'] = 'No such rule';
$L['rightsperuser_rules'] = 'Rules';
$L['rightsperuser_superadmin'] = 'Cannot affect super administrators';
$L['rightsperuser_title'] = 'Rights per user';
$L['rightsperuser_updated'] = 'The rule has been updated';

?>

Other locales may also require strings for localized plugin description (displayed when installing it) and plugin configuration options in 'rightsperuser.setup.php'. For example, Russian language file for our plugin looks like this:

<?php

defined('COT_CODE') or die('Wrong URL.');

// Strings and messages
$L['rightsperuser_add'] = 'Новое правило';
$L['rightsperuser_added'] = 'Новое правило добавлено';
$L['rightsperuser_delete_error'] = 'Невозможно удалить правило';
$L['rightsperuser_deleted'] = 'Правило удалено';
$L['rightsperuser_duplicate'] = 'Такое правило уже существует';
$L['rightsperuser_invalid_user'] = 'Неправильное имя пользователя';
$L['rightsperuser_notfound'] = 'Нет такого правила';
$L['rightsperuser_rules'] = 'Правила';
$L['rightsperuser_superadmin'] = 'Нельзя затрагивать супер-администраторов';
$L['rightsperuser_title'] = 'Индивидуальные права';
$L['rightsperuser_updated'] = 'Правило обновлено';

// Localized plugin description
$L['info_desc'] = 'Позволяет назначать права конкретным пользователям, не затрагивая права групп';

// Configuration options
$L['cfg_rules_perpage'] = array('Число правил, отображаемых на странице');

?>

It is recommended to sort $L keys in alphabetical order so it's easier to maintain it as the number of strings grows.

#8. Making the plugin better

Now that we've got the basic plugin working let's cover a bit more advanced topics.

#8.1. Error and message display

We used cot_error() and cot_message() functions to generate error and success messages, we used cot_error_found() to check if there are any errors, but we haven't added any code to display them.

To display the messages in our template, we need to include a standard template file which defines layout for them. So we add this code at the top of 'rightsperuser.tpl':

{FILE "{PHP.cfg.themes_dir}/{PHP.cfg.defaulttheme}/warnings.tpl"}

And to render them, we need to pass our template object to cot_display_messages() function in the body of our PHP script ('rightsperuser.admin.php'):

cot_display_messages($rt);

Now our error handling is complete.

#8.2. Pagination

Our admin tool is fine for a few rules, but what if there are tens of them? Then we need to split them into several pages, i.e. use pagination.

Pagination implementation consists of 3 parts. First we need to import pagination parameters from current URL and convert them into page number, database offset and URL parameter:

list($pg, $d, $durl) = cot_import_pagenav('d', $cfg['plugin']['rightsperuser']['rules_perpage']);

'd' is the name of the GET parameter used by pagination. $pg will contain current page number, $d is the database offset used in SQL queries and $durl is used in URLs. $cfg['plugin']['rightsperuser']['rules_perpage'] is our configuration option value which sets max number of rows per page.

Then we modify the main SELECT query and add LIMIT section to it:

$res = $db->query("SELECT r.*, u.user_name
	FROM $db_rightsperuser AS r
		LEFT JOIN $db_users AS u ON r.ru_user = u.user_id
	ORDER BY ru_user, ru_code, ru_option
	LIMIT $d, {$cfg['plugin']['rightsperuser']['rules_perpage']}");

And the last step is rendering pagination tags to be displayed in the template:

$totalitems = $db->query("SELECT COUNT(*)
		FROM $db_rightsperuser")->fetchColumn();
$pagenav = cot_pagenav('admin', 'm=other&p=rightsperuser', $d, $totalitems,
		$cfg['plugin']['rightsperuser']['rules_perpage']);

$rt->assign(array(
	'RIGHTSPERUSER_PAGENAV_PREV' => $pagenav['prev'],
	'RIGHTSPERUSER_PAGENAV_MAIN' => $pagenav['main'],
	'RIGHTSPERUSER_PAGENAV_NEXT' => $pagenav['next']
));

First we fetch total number of rows among all pages. Then we generate pagination array using cot_pagenav() function. It has a lot of parameters but in this example we only pass URL area, URL parameters, current offset, total items and the number of items per page. The returned array has the following components:

  • main – buttons with page numbers;

  • prev – link to previous page;

  • next – link to next page;

  • first – link to the first page;

  • last – link to the last page;

  • firstlink – URL of the first page;

  • lastlink – URL of the last page;

  • prevlink – URL of the previous page;

  • nextlink – URL of the next page;

  • currentpage – current page number;

  • total – total number of pages;

  • onpage – items on current page;

  • entries – total items.

After the tags have been assigned, we can add pagination to our TPL file 'rightsperuser.tpl':

<p class="paging">
	{RIGHTSPERUSER_PAGENAV_PREV}{RIGHTSPERUSER_PAGENAV_MAIN}{RIGHTSPERUSER_PAGENAV_NEXT}
</p>

#8.3. Help in admin tools

Admin tools can have interactive help sections to give an administrator some hints. To add such a section in your plugin, just assign its HTML code to $adminhelp variable.

But we need to store help HTML somewhere in the template. So let's add another root-level block after MAIN in 'rightsperuser.tpl':

<!-- BEGIN: HELP -->
	<p>{PHP.R.admin_icon_auth_r}&nbsp; {PHP.L.Read}</p>
	<p>{PHP.R.admin_icon_auth_w}&nbsp; {PHP.L.Write}</p>
	<p>{PHP.R.admin_icon_auth_1}&nbsp; {PHP.L.Custom} #1</p>
	<p>{PHP.R.admin_icon_auth_2}&nbsp; {PHP.L.Custom} #2</p>
	<p>{PHP.R.admin_icon_auth_3}&nbsp; {PHP.L.Custom} #3</p>
	<p>{PHP.R.admin_icon_auth_4}&nbsp; {PHP.L.Custom} #4</p>
	<p>{PHP.R.admin_icon_auth_5}&nbsp; {PHP.L.Custom} #5</p>
	<p>{PHP.R.admin_icon_auth_a}&nbsp; {PHP.L.Administration}</p>
<!-- END: HELP -->

Then we can use this block in 'rightsperuser.admin.php' like this:

$rt->parse('HELP');
$adminhelp = $rt->text('HELP');

#8.4. AJAX auto-complete

There are 2 kinds of elements in our rule editor which need help of AJAX and JavaScript. First is user name input. There may be thousands of users registered on site, so we'd like to provide auto-complete feature for username inputs. The second is auth options dropdown: when a user selects an auth code, we need to provide auth options precisely for that code.

We will reuse auto-complete for user names from 'autocomplete' plugin. As this plugin is required to be installed, the necessary JS library and AJAX backend are already present in the system. We only need to apply autocomplete on our inputs with 'user' class. In JavaScript it means:

$('input.user').autocomplete('index.php?r=autocomplete', {minChars: 3});

The option list refresh is a bit more complex. First of all, we need to add an AJAX handler for it to our plugin. It should use 'ajax' hook. Let's call it 'rightsperuser.ajax.php':

<?php
/* ====================
[BEGIN_COT_EXT]
Hooks=ajax
[END_COT_EXT]
==================== */

defined('COT_CODE') or die('Wrong URL');

$code = cot_import('code', 'G', 'ALP');

// Get options for given code
$options = $db->query("SELECT DISTINCT auth_option FROM $db_auth WHERE auth_code = ?",
		array($code))->fetchAll(PDO::FETCH_COLUMN);

// Output as JSON
header('Content-Type: application/json');
echo json_encode($options);

?>

It simply gets all options for a given code from the database as an array, encodes it as JSON and sends to output with old good PHP echo.

The JavaScript part of our plugin is saved in 'js/rightsperuser.js' file. Full contents of this file includes:

$(function() {
	// Auto-complete for username
	$('input.user').autocomplete('index.php?r=autocomplete', {minChars: 3});
	// Load options via AJAX when code is changed
	$('select.code').change(function() {
		var id = ($(this).attr('id').split('_'))[1];
		$.ajax({
			url: "index.php?r=rightsperuser&code=" + $(this).val(),
			success: function(data) {
				var options = $('select#opt_'+id);
				var optHtml = '';
				for (var i = 0; i < data.length; i++) {
					optHtml += '<option>' + data[i] + '</option>';
				}
				options.html(optHtml);
			}
		});
	});
});

There are 2 ways of linking to this script from a web page. The first is using Cotonti's “header resouces” aka “JS/CSS consolidation” interface. The second is direct linking from the tpl. Our script is a simple admin tool which shouldn't be visible anywhere outside, so we choose the simple second way and leave the first way to some other articles. So we add this to 'rightsperuser.tpl':

<script type="text/javascript"
	src="{PHP.cfg.plugins_dir}/rightsperuser/js/rightsperuser.js">
</script>

#9. Conclusion

Have a look at the plugin which we have made together:

rightsperuser screenshot

You can find full plugin source on Github.

Or download it from plugin page.

Of course this tutorial doesn't cover everything and it isn't very verbose, but we hope that reading it you have made an important step in Cotonti extension development.

 



1. Macik  2011-12-03 00:25

Very usefull article. Special tanks for links in it.

Only registered users can post new comments