NAME
OpenInteract2::Manual::TutorialAdvanced - Tutorial for advanced OpenInteract functionality
SYNOPSIS
This document will walk through some of the more advanced (but still common) actions you can do with OpenInteract2.
For the examples in this document we'll rely on the 'book' application created in OpenInteract2::Manual::Tutorial.
ACTIONS: SETTING UP A LOOKUP TABLE
Create the data structure
One of the immediate problems with our 'book' record is that there's no way to categorize them. Sure, most books cross categories -- my favorite: 'Computer Programming' + 'Self-Help' -- but our books don't even have one.
But we don't want the user to type in any old category. We should be able to define a list of known categories then allow her to choose one of them when creating or updating a book.
So, first we'll create a table to hold these categories. It's commonly known as a 'lookup' table because it's so simple, all we do is find values:
CREATE TABLE book_category (
category_id %%INCREMENT%%,
category varchar(25) not null,
primary key( category_id )
);
Store this in struct/book_category.sql
and if you're using Oracle/PostgreSQL add a sequence in struct/book_category_sequence.sql
:
CREATE SEQUENCE book_category_seq
Now we'll need to add the table and sequence to our SQL installer class. In OpenInteract2/SQLInstall/Book.pm
change:
my %FILES = (
oracle => [ 'book.sql', 'book_sequence.sql' ],
pg => [ 'book.sql', 'book_sequence.sql' ],
default => [ 'book.sql' ],
);
to:
my @tables = qw( book.sql book_category.sql );
my @sequences = qw( book_sequence.sql book_category_sequence.sql );
my %FILES = (
oracle => [ @tables, @sequences ],
pg => [ @tables, @sequences ],
default => [ @tables ],
);
Also add an SPOPS class to conf/spops_book_category.ini
:
[book_category]
class = OpenInteract2::BookCategory
isa =
field =
field_discover = yes
id_field = category_id
increment_field = yes
sequence_name = book_category_seq
is_secure = no
no_insert = category_id
no_update = category_id
base_table = book_category
name = category
object_name = Book Category
[book_category display]
ACTION = lookups
TASK =
Setup lookup action
And here's the interesting part -- to add simple editing functionality to your lookup table you can just add the following action to conf/action.ini
:
[book_category]
action_type = lookup
object_key = book_category
title = Book Categories
field_list = category
label_list = Category
size_list = 25
order = category
url_none = yes
The 'action_type' setting is the important one. OI2 references a list of action types in the server configuration (key: 'action_type'). Think of these as parent actions to yours -- all they require is configuration and you're set. In this case you need to set:
- object_key
-
SPOPS name of your object.
- title
-
Title of the values to edit.
- field_list
-
List of fields you want to edit. (We've only got one.)
- label_list
-
List of labels for each of the fields in
field_list
, in the same order. - size_list
-
Size of text fields to edit for each of the fields in
field_list
, in the same order. - order
-
Field/ORDER-BY clause used to retrieve the data.
See OpenInteract2::App::Lookup for more information and additional options you can set.
Add these files to your MANIFEST
, modify your Changes
and package.ini
and update your site. You can install the SQL structures like this (assuming OPENINTERACT2
environment variable is set to your website):
$ oi2_manage install_sql_structure --package=book \
--file=book_category.sql --file=book_category_sequence.sql
Back in the original tuturial we ran the 'install_sql' management task. That's really just a wrapper around three separate tasks: 'install_sql_structure', 'install_sql_data' and 'install_sql_security'. Additionally, since we don't want to try to reinstall our 'book.sql' we're giving it an argument to limit the files it will operate on.
Once you restart your server go to the 'Lookup Tables' link in your 'Admin Tools' box. (Or just go to the '/lookups/' URL.) You'll see 'Book Categories' there. Click on the link and you'll see a set of text entry fields, one per table row.
Each entry will become a new category, so enter a few and click 'Save'. You'll get a status message with your entries and get taken back to the initial lookup tables page. Click on 'Book Categories' again and you'll see your categories. You can now add, edit and remove at will.
Add field to table
So now we need to add our category our table of books. Using whatever database tool you're comfortable with add the following definition to the 'book' table:
category_id int null
For instance, when I use PostgreSQL I just do:
psql> ALTER TABLE book ADD category_id INT NULL;
When you restart the server all your 'book' objects will have the new property 'category_id'. Since we set 'field_discover' to 'yes' in the previous tutorial we didn't need to tell SPOPS or OI2 about it.
But we still can't assign the category from our book editing form...
Source values for field from lookup
What we want to do is present a list of all the available categories to the user when she's editing a book. But how do we do that? Remember, we're using one of the Common actions and didn't actually write any code to do inserts and updates.
Fortunately the Common actions have a callback just for this. It's always named "customize" -- "_display_add_customize", "_display_form_customize", etc.
So in our action OpenInteract2/Action/Book.pm
we'll implement these callbacks. This is very similar to how we added our list of publishers to the search form but instead of plain strings as the source we're using objects:
So add the following method:
sub _add_categories {
my ( $self, $template_params ) = @_;
$template_params->{categories} =
OpenInteract2::BookCategory->fetch_group({ order => 'category' });
}
And reference it from your callbacks:
sub _display_add_customize {
my ( $self, $template_params ) = @_;
$self->_add_categories( $template_params );
}
sub _display_form_customize {
my ( $self, $template_params ) = @_;
$self->_add_categories( $template_params );
}
Then add a reference to those categories in your data editing form:
[%- count = count + 1 -%]
[% INCLUDE label_form_select_row( label = 'Category'
name = 'category_id',
picked = book.category,
list = categories,
value_field = 'category_id',
label_field = 'category',
first_label = '---Categories---' ) -%]
Assuming you have the following categrories:
category_id category
---------------------
1 Self-Help
2 Perl
3 Regular Expressions
4 Vegetarian Cooking
You'll see something like this -- remember that we sorted the categories by 'category' when we assigned them to the template parameters:
<tr>
<td><b>Category</b></td>
<td><select name="category_id">
<option value="">---Categories---</option>
<option value="2">Perl</option>
<option value="3">Regular Expressions</option>
<option value="1">Self-Help</option>
<option value="4">Vegetarian Cooking</option>
</select>
</td>
</tr>
Add fields to action configuration
Finally, while we didn't have to add the field to our SPOPS configuration we do have to add it to our actions. (Common action configurations don't have an field discovery feature yet.) Add to your conf/action.ini
under '[book]' these keys and values:
c_update_fields = category_id
c_add_fields = category_id
After you do so you should be able to add and update your books with new categories. Give it a whirl!
ACTIONS: A TEMPLATE CAN BE AN ACTION
Another action type: template_only
Another way to create an action without any code is to point it directly at a template. This is very useful for embeddable components. For instance, many actions come with a 'box' that has pointers to common actions. Say we wanted to create a 'Book Actions Box' that had links like 'Search', 'Add' and (when you're on a book record), 'Edit' and 'Remove'.
We'll go backwards first this time, defining the action first. In your conf/action.ini
add the following:
[book_box]
action_type = template_only
template = book::toolbox
title = Book Tools
url_none = yes
We can now reference 'book_box' just like any other action. But first we need to create a simple template in template/toolbox.tmpl
:
[%- search_url = OI.make_url( ACTION = 'book', TASK = 'search' );
add_url = OI.make_url( ACTION = 'book', TASK = 'display_add' ); -%]
- <a href="[% search_url %]">Search</a> <br />
- <a href="[% add_url %]">Add</a> <br />
[% IF book -%]
[%- edit_url = OI.make_url( ACTION = 'book', TASK = 'display_form',
book_id = book.id );
remove_url = OI.make_url( ACTION = 'book', TASK = 'remove',
book_id = book.id ) -%]
- <a href="[% search_url %]">Edit</a> <br />
- <a href="[% add_url %]">Remove</a><br />
[% END %]
We're done. Just add the new file to your MANIFEST
and install.
You can execute the content from within a template too. For instance, just add the following to any template:
[% OI.action_execute( 'book_box' ) %]
Once it's processed you'll see your content. You do something similar when you place the action content in a box:
[% OI.box_add( 'book_box' ) %]
The 'box_add' method stores the action away for execution later (by another action, OpenInteract2::Action::Box). But since we've wrapped our template as an action we can now treat it like every other component.
It's also worth mentioning that we can still reference the template with Template Toolkit directives, like:
[% INCLUDE book::toolbox %]
The only differences are that when we call OI.action_execute()
we go through the action's security checks as well as use its cached content if available. The INCLUDE
call does not do this extra work so it's a little faster to process. It's up to you whether the speed is worth it. (And you can always change between the two, so a decision won't be permanent.)
ACTIONS: ADDING VALIDATION TO COMMON TASKS
ACTIONS: USING MULTIPLE TABLES WITH COMMON SEARCHING
Another table with searchable information
Our book empire is growing. We now have multiple locations where our books are stored and we need to track which books are stored in which locations.
For our purposes we'll assume a location
table like this:
CREATE TABLE location (
location_id %%INCREMENT%%,
name varchar(50) not null,
city varchar(25) null,
state varchar(2) null,
primary key( location_id )
)
And we'll also assume that we're getting a feed of these data from our shipping company, so we don't need to create code to edit the data.
For the sake of this exercise we'll also assume we don't need to manipulate individual 'location' records. Since the search mechanism relies entirely on database tables and joins we don't require that all tables are mapped to SPOPS objects.
Our main reason we want the additional table is for searching -- so we can find all books in a particular location, or all books with 'Food' in their title in a particular city.
Create a link table
Since there can be the same book at many different locations, and many different books at a single location, we have a many-to-many relationship. For this we need a separate table (aka, 'join table') to hold the linking data. It'll look like this:
CREATE TABLE book_at_location (
book_id %%INCREMENT_TYPE%% not null,
location_id %%INCREMENT_TYPE%% not null,
primary key( book_id, location_id )
)
If you want you can add information specific to this link to the table (count, date last book received, etc.). For our purposes here it doesn't matter.
Feed the search from your form
We want to be able to search by location name, city and state. First we'll add the fields to the search form -- in template/search_form.tmpl
add:
[% INCLUDE label_form_text_row( label = 'Location',
name = 'loc.name', size = 30 ) %]
[% INCLUDE label_form_text_row( label = 'City',
name = 'loc.city', size = 30 ) %]
[% INCLUDE label_form_text_row( label = 'State',
name = 'loc.state', size = 5 ) %]
Note that we prefixed the field names from our 'location' table with 'loc.'. That's because we need to need to be able to tell our action for which fields we should join to another table.
The string 'loc' doesn't mean anything. It just needs to be consistent between your form field declarations and the configuration we're about to do.
Configuring the search
In your conf/action.ini
you'll need to add these fields to your c_search_fields*
listings. We'll assume the location name and city are 'LIKE' matches while the state is an exact match. So under 'book' add:
c_search_fields_like = loc.name
c_search_fields_like = loc.city
c_search_fields_exact = loc.state
Note that we kept the 'loc.' prefix on all fields.
Next, we need to tell OI2 how to match up our book records with these 'loc.' fields. We do that with the parameter c_search_table_links
. It will look like this:
[book c_search_table_links]
loc = book.book_id
loc = book_at_location.book_id
loc = book_at_location.location_id
loc = location.location_id
The 'loc' field matches our prefix and its values represent (in pairs) how tables are joined. OI will step through these parameters and construct a SQL JOIN clause like this:
WHERE ...
AND book.book_id = book_at_location.book_id
AND book_at_location.location_id = location.location_id
It will only generate this join if any of the fields with a 'loc' prefix are searched. So if a user just searches for a book title OI2 will put together a query like this:
WHERE book.title LIKE '%Charlotte%'
But if a user searches for a book title in a particular city this query will change:
WHERE book.title LIKE '%Charlotte%'
AND location.city LIKE '%burgh%'
AND book.book_id = book_at_location.book_id
AND book_at_location.location_id = location.location_id
Once you've made these changes you're ready to search!
ACTIONS: SECURING AN ACTION
What is action security?
There are two layers of security in OpenInteract. The first, action security, is the most widely used and determines who can do what in your application. Action security can be segmented by task. So you may have certain tasks within an action that all users can do (such as search and view items) but other tasks only users of a particular group can do (such as create, modify and remove items).
Security is always specified by group. While the underlying mechanism for storing and retrieving security can be used with individual users it's strongly discouraged.
Configure your action
Assigning security to your action is very simple -- all the changes are in your action's configuration file.
First, you need to tell OI2 that your action is secure with the 'is_secure' key:
[book]
class = OpenInteract2::Action::Book
is_secure = yes
If this is set to anything but 'yes' the action processor will ignore any security settings. (In OpenInteract2::Manual::Tutorial we had this set to 'no'.)
Now you need to define the security required for your action. You have three options to choose from: 'NONE', 'READ' and 'WRITE'. (There's a fourth option, 'SUMMARY', but it's rarely used.) These levels are additive so if a user has 'WRITE' permission she also has 'READ'. Also, note that if you don't specify a requirement we assume 'WRITE'.
If you want a single security requirement for all tasks in our book action then the job is easy:
[book]
class = OpenInteract2::Action::Book
is_secure = yes
security = WRITE
However, many times you'll want to have separate requirements for separate tasks. For instance, in our book action we want everyone to be able to search and display book records. But maybe we only want groups with WRITE permission to the action to modify the book records. So we might have:
[book security]
DEFAULT = WRITE
search = READ
search_form = READ
display = READ
Here we have a new key, 'DEFAULT'. This is a special task name that acts as a catch-all: every task not explicitly gets this security. Of course, since a task not listed gets 'WRITE' security anyway this is technically redundant. But it better to communicate your intentions explicitly.
That's it -- restart the server and your action will now be secured. Of course, you need to assign security to groups.
Assigning security
Assigning security to actions is typically done through the website -- click on the 'Security' link from the 'Admin Tools' box, or just go to the '/security/' URL.
You can also modify action security through a management task OpenInteract2::Manage::Website::CreateSecurityForAction, or using oi2_manage
:
oi2_manage secure_action --action=book --scope=group --scope_id=5
SPOPS: ADDING OBJECT BEHAVIORS
Define the class
SPOPS objects provide simple persistence behavior. But you may want them to have other types of behavior as well. The recommended way of doing this is to create your class and have it subclass the generated SPOPS class.
A typical use of this is to encapsulate common queries. Say you have a 'recipe' object:
[recipe]
class = OpenInteract2::Recipe
...
After using it for a while you find that you're one of your standard queries is to find recipes with a particular ingredient posted in the last month. It's executed from multiple actions so it makes sense to have this query live in the same object that actually retrieves the objects. To hold it you'd create something like this:
package OpenInteract2::Recipe;
use strict;
@OpenInteract2::Recipe::ISA = qw( OpenInteract2::RecipePersist );
sub by_ingredient_in_last_month {
my ( $class, $ingredient ) = @_;
my $where = qq{
ingredient LIKE ? '
AND date_part( 'epoch', CURRENT_DATETIME ) - date_part( 'epoch', posted_on ) <= 25920000
};
return $class->fetch_group({
where => $where,
value => [ $ingredient ],
});
}
Hey, where did that OpenInteract2::RecipePersist
come from?
Add to declaration
The last step is to change the name of your generated class. This is so people can use the name they'd expect to see ('OpenInteract2::Recipe' for a 'recipe' object). Changing this is simple:
OLD:
[recipe]
class = OpenInteract2::Recipe
...
NEW:
[recipe]
class = OpenInteract2::RecipePersist
...
One other task: when we want to get the object class name from the context (using lookup_object()
) we don't want to get 'OpenInteract2::RecipePersist'. So we need to alias the class:
[recipe]
class = OpenInteract2::RecipePersist
alias_class = OpenInteract2::Recipe
...
So now when we get the class name like this we'll get the right thing:
my $recipe_class = CTX->lookup_object( 'recipe' );
my $recent_recipes =
$recipe_class->by_ingredient_in_last_month( 'ketchup' );
print "Stuff you've made recently:\n";
foreach my $recipe ( @{ $recent_recipes } ) {
print " ", $recipe->name, "\n";
}
Using security
First: are you sure you need per-object security? Checking security can add significant overhead to object retrieval, so if your security requirements can be met by application security instead you should use it.
So if you need it, adding security is as simple as setting 'is_secure' to 'yes' in your object configuration:
[myobject]
class = OpenInteract2::Myobject
...
is_secure = yes
This will add SPOPS::Secure to your class's @ISA
and automatically ensure that users have READ security to retrieve an object and WRITE security to store or remove it.
That leaves a gap: how do we know what the security should be to create an object? You cover this with the 'creation_security' configuration section. It looks like this:
[myobject creation_security]
user = WRITE
group = site_admin_group:WRITE
world = READ
This means that we create a security object for the user who creates the object as WRITE, one for the 'site_admin_group' as WRITE, and one for the world as READ. You can skip a scope if you like -- if you only want users in the 'public' group to be able to see your object you'd use:
[myobject creation_security]
group = public_group:READ
The 'site_admin_group' and 'public_group' used above is the name of any group found in your server configuration's 'default_objects' configuration section. That section typically looks like this:
[default_objects]
superuser = 1
supergroup = 1
theme = 1
public_group = 2
site_admin_group = 3
The 'groupname:PERMISSION' is a syntax enhancement: when the server starts up we lookup 'groupname' in 'default_objects' and substitute that group's ID. So with the above definitions when you create a new 'myobject' we'll also create a security object giving WRITE permission to group with ID 3.
MISC
Using Multiple Datasources
OI2 can support multiple datasources. Most applications do not have need for this, but some may need to present legacy data alongside current data.
OI2 ships with a single configured DBI datasource called 'main'. Assuming a configuration for PostgreSQL it looks like this:
[datasource main]
type = DBI
dbi_type = Pg
dsn = dbname=current_data
username = pguser
password = pgpass
Say we have a MySQL database with legacy data. We'd just add it to the configuration file like this:
[datasource main]
type = DBI
dbi_type = Pg
dsn = dbname=current_data
username = pguser
password = pgpass
[datasource legacy]
type = DBI
dbi_type = mysql
dsn = database=legacy
username = mysqluser
password = mysqlpass
We can reference the datasource in code through the context and use it as a straight DBI handle:
my $dbh = CTX->datasource( 'legacy' );
my $sql = "SELECT count(*) FROM old_table WHERE foo = ?";
my ( $sth );
eval {
$sth = $dbh->prepare( $sql );
$sth->execute( $foo );
};
my ( $count ) = $sth->fetchrow_array;
We can also use the datasource to back our SPOPS objects. Currently, the easiest way to associate an SPOPS class with a specific datasource is through its configuration.
Assuming we had a read-only SPOPS object declaration for old invoices:
[legacy_invoice]
class = OpenInteract2::LegacyInvoice
isa = SPOPS::Tool::ReadOnly
field =
field_discover = yes
id_field = invoice_id
is_secure = no
base_table = invoice_old
name = invoice_num
object_name = Invoice
we'd just add a 'datasource' key with the name of our legacy datasource:
[legacy_invoice]
class = OpenInteract2::LegacyInvoice
isa = SPOPS::Tool::ReadOnly
datasource = legacy
...
And that's it. You can use it just like any other SPOPS class:
my $customer_id = $request->param( 'customer_id' );
my $invoices = OpenInteract2::LegacyInvoice->fetch_group({
where => 'customer_id = ?',
value => $customer_id,
order => 'invoice_date DESC'
});
foreach my $inv ( @{ $invoices } ) {
print "Date: $inv->{invoice_date} ($inv->{num_items})\n";
}
SPOPS will pull the data from the separate database, but when you're accessing the data you won't know (or care) where it's from.
This means it's also easy to swap datasources behind the scenes -- for instance, to point to a backup database server if the main one goes down.
SEE ALSO
OpenInteract2::Manual::Management - Creating management tasks
COPYRIGHT
Copyright (c) 2003-2004 Chris Winters. All rights reserved.
AUTHORS
Chris Winters <chris@cwinters.com>