This section requires that you first complete Installation Guide. That section tries to eliminate all variables so that you have the smoothest possible installation. But this part assumes that you want to learn how to create your own package, and every package (unlike every installation) is different. So in this part, you'll have to make lots of changes in order to get results. At the end of this part, you should have your own, slightly customized package, and you then install a reference version and compare.
The OpenACS package manager will set up the initial directories, meta-information files, and database entries for a new package.
Browse to http://yourserver:8000 and click on Package Manager.
Click Create a New Package.
Fill in the fields listed below. Tab through the rest. (Some will change automatically. Don't mess with those.)
Package Key: tutorialapp
Package Name: Tutorial App
Package Plural: Tutorial Apps
Initial Version: 0.1d
Summary: This is my first package.
At the bottom, click .
Now it's time to document. For a new package you should start by copying the documentation template from /web/openacs-dev/packages/acs-core-docs/xml/docs/xml/package-documentation-template.xml to yourpackage/www/docs/xml/package-documentation.xml.
You then open that file with emacs, write the requirements and design section, generate html, and start coding. For this tutorial, you should instead install the pre-written documentation files for the tutorial app, examine them, generate html, read it, and then proceed to build the package. Store any diagrams in native format in the xml directory, and store png or jpg versions of the diagrams in the doc direcory.
In this case, though, just copy the pre-written documentation files. You should be logged in as nsadmin throughout this section.
mkdir -p /web/openacs-dev/packages/tutorialapp/www/doc/xml cd /web/openacs-dev/packages/tutorialapp/www/doc/ cp /tmp/package-documentation.xml xml cp /tmp/*.png . cp /tmp/*.dia . emacs xml/package-documentation.xml
OpenACS uses DocBook for documentation. DocBook is an XML standard for semantic markup of documentation. That means that the tags you use indicate meaning, not intended appearance. The style sheet will determine appearance.
Examine the file. Find the version history (look for the tag <revhistory>). Add a new record to the document version history. Look for the <authorgroup> tag and add yourself as a second author. Save and exit. For tips on editing SGML files in emacs, see [[Staflin]]
Process the xml file to create html documentation. The html documentation is stored in the tutorialapp/www/docs/ directory. When the package is mounted in the site map, the html will automatically be published at http://yoursite:8000/tutorialapp/doc
cd /web/openacs-dev/packages/tutorialapp/www/ xsltproc -o doc/ /web/openacs-dev/packages/acs-core-docs/www/xml/ja-openacs.xsl doc/xml/package-documentation.xml
cd /web/openacs-dev/packages cvs add tutorialapp cd tutorialapp cvs add tutorialapp.info cvs add www cd www cvs add doc cd doc cvs add * cvs add xml cd xml cvs add * cd ../../.. mkdir tcl cvs add tcl mkdir -p sql/postgresql cvs add sql cd sql cvs add postgresql cd .. cvs commit -m "new package"
Every package must be mounted within the site map in order to function.
Browse to http://yourserver:8000 and click on Site Map
Click the new sub folder link on the Main Site line.
Type tutorialapp and click .
On the tutorialapp line, click the new application link.
Choose Tutorial App and enter tutorial as the name, then click .
Browse to http://yourserver:8000/tutorialapp/doc/ and examine the documentation. Make sure that your changes are reflected.
cd /web/openacs-dev/packages/tutorialapp/sql/postgresql emacs tutorialapp-create.sql
Paste this into the file and save and exit.
-- -- packages/tutorialapp/sql/postgresql/tutorialapp-create.sql -- -- @author rhs@mit.edu -- @creation-date 2000-10-22 -- @cvs-id $Id: ch03s02.html,v 1.1 2004/05/07 15:04:29 aufrecht Exp $ -- --/* That was the standard comment bar at the top of the file. Everything between the dollar signs in the cvs-id tag will be updated automatically by cvs. This file does all of the database setup necessary for our package, creating a table and related functions, and calling many functions to connect the table into the ACS system, including into full text search. */
create function inline_0 () returns integer as ' begin PERFORM acs_object_type__create_type ( ''tutorialnote'', -- object_type ''Tutorialnote'', -- pretty_name ''Tutorialnotes'', -- pretty_plural ''acs_object'', -- supertype ''tutorialapp'', -- table_name ''tutorialnote_id'', -- id_column null, -- package_name ''f'', -- abstract_p null, -- type_extension_table ''tutorialnote.name'' -- name_method ); return 0; end;' language 'plpgsql'; select inline_0 (); drop function inline_0 ();/* This creates a temporary function which calls the __create_type function on the acs_object_type table. That function tells ACS that each record in our new table is an instance of a new object called tutorialnote, and that tutorialnote is a subtype of the generic acs_object.*/
create function inline_1 () returns integer as ' begin PERFORM acs_attribute__create_attribute ( ''tutorialnote'', -- object_type ''TITLE'', -- attribute_name ''string'', -- datatype ''Title'', -- pretty_name ''Titles'', -- pretty_plural null, -- table_name null, -- column_name null, -- default_value 1, -- min_n_values 1, -- max_n_values null, -- sort_order ''type_specific'', -- storage ''f'' -- static_p ); PERFORM acs_attribute__create_attribute ( ''tutorialnote'', -- object_type ''BODY'', -- attribute_name ''string'', -- datatype ''Body'', -- pretty_name ''Bodies'', -- pretty_plural null, -- table_name null, -- column_name null, -- default_value 1, -- min_n_values 1, -- max_n_values null, -- sort_order ''type_specific'', -- storage ''f'' -- static_p ); return 0; end;' language 'plpgsql'; select inline_1 (); drop function inline_1 ();/* This creates a temporary function which twice calls the __create_attribute function on the acs_attribute table. That function tells ACS that two of the fields in our new table, title and body, are special - they are attributes of the tutorialnote object that we created earlier. Hooking up this fields like this will provide some unspecified benefit in the future.*/
create table tutorialapp ( tutorialnote_id integer constraint tutorialapp_tutorialnote_id_fk references acs_objects(object_id) constraint tutorialapp_tutorialnote_id_pk primary key, owner_id integer constraint tutorialapp_owner_id_fk references users(user_id), title varchar(255) constraint tutorialapp_title_nn not null, body varchar(1024) );/* Now we actually create the tutorialapp table. The tutorialnote_id field is based on the acs_objects id, and owner comes from the users table. */
create function tutorialnote__new (integer,integer,varchar,varchar,varchar,timestamp,integer,varchar,integer) returns integer as ' declare p_tutorialnote_id alias for $1; -- default null p_owner_id alias for $2; -- default null p_title alias for $3; p_body alias for $4; p_object_type alias for $5; -- default ''tutorialnote'' p_creation_date alias for $6; -- default now() p_creation_user alias for $7; -- default null p_creation_ip alias for $8; -- default null p_context_id alias for $9; -- default null v_tutorialnote_id tutorialapp.tutorialnote_id%TYPE; begin v_tutorialnote_id := acs_object__new ( p_tutorialnote_id, p_object_type, p_creation_date, p_creation_user, p_creation_ip, p_context_id ); insert into tutorialapp (tutorialnote_id, owner_id, title, body) values (v_tutorialnote_id, p_owner_id, p_title, p_body); PERFORM acs_permission__grant_permission( v_tutorialnote_id, p_owner_id, ''admin'' ); return v_tutorialnote_id; end;' language 'plpgsql';/* Whenever a new record is created, it will be done through this function, tutorialnote__new, instead of through a simple "insert into" command. The __new function sets default values for most fields. It creates a new acs_object and puts that object's id in a temporary variable, v_tutorialnote_id. Then it actually creates the record, grants admin permission on that object to the owner, and returns the new object's id.*/
create function tutorialnote__delete (integer) returns integer as ' declare p_tutorialnote_id alias for $1; begin delete from acs_permissions where object_id = p_tutorialnote_id; delete from tutorialapp where tutorialnote_id = p_tutorialnote_id; raise NOTICE ''Deleting tutorialnote...''; PERFORM acs_object__delete(p_tutorialnote_id); return 0; end;' language 'plpgsql';/* Records will be deleted through this function, tutorialnote__delete. It removes the permissions for the record, then removes the record, then removes the corresponding acs_object.*/
create function tutorialnote__name (integer) returns varchar as ' declare p_tutorialnote_id alias for $1; v_tutorialnote_name tutorialapp.title%TYPE; begin select title into v_tutorialnote_name from tutorialapp where tutorialnote_id = p_tutorialnote_id; return v_tutorialnote_name; end; ' language 'plpgsql';/* The tutorialnote__name function returns the name. This function is present (I assume) by convention, so that even tables and objects that don't have fields called "name" can return useful names. In this case, it returns the field title as the name.*/
\i tutorialapp-sc-create.sql/* This calls another file to set up full text search. */
Create a tcl file to hold a procedure to support text search.
emacs ../../tcl/tutorialapp-procs.tcl
Paste this into the file and save and exit
ad_proc tutorialapp__datasource {
object_id
} {
@author Neophytos Demetriou
} {
db_0or1row tutorialapp_datasource {
select t.tutorialnote_id as object_id,
t.title as title,
t.body as content,
'text/plain' as mime,
'' as keywords,
'text' as storage_type
from tutorialapp t
where tutorialnote_id = :object_id
} -column_array datasource
return [array get datasource]
}
ad_proc tutorialapp__url {
object_id
} {
@author Neophytos Demetriou
} {
set package_id [apm_package_id_from_key tutorialapp]
db_1row get_url_stub "
select site_node__url(node_id) as url_stub
from site_nodes
where object_id=:package_id
"
set url "${url_stub}note?tutorialnote_id=$object_id"
return $url
}
Create a database file to create the full text search functions for our package.
emacs tutorialapp-sc-create.sql
Paste this into the file and save and exit
-- packages/tutorialapp/sql/tutorialapp-sc-create.sql -- -- @cvs-id $Id: ch03s02.html,v 1.1 2004/05/07 15:04:29 aufrecht Exp $ --select acs_sc_impl__new( 'FtsContentProvider', -- impl_contract_name 'tutorialnote', -- impl_name 'tutorialapp' -- impl_owner_name ); select acs_sc_impl_alias__new( 'FtsContentProvider', -- impl_contract_name 'tutorialnote', -- impl_name 'datasource', -- impl_operation_name 'tutorialapp__datasource', -- impl_alias 'TCL' -- impl_pl ); select acs_sc_impl_alias__new( 'FtsContentProvider', -- impl_contract_name 'tutorialnote', -- impl_name 'url', -- impl_operation_name 'tutorialapp__url', -- impl_alias 'TCL' -- impl_pl );/* These three function calls tell the system that our package provides information for full text search*/
create function tutorialapp__itrg () returns opaque as ' begin perform search_observer__enqueue(new.tutorialnote_id,''INSERT''); return new; end;' language 'plpgsql'; create function tutorialapp__dtrg () returns opaque as ' begin perform search_observer__enqueue(old.tutorialnote_id,''DELETE''); return old; end;' language 'plpgsql'; create function tutorialapp__utrg () returns opaque as ' begin perform search_observer__enqueue(old.tutorialnote_id,''UPDATE''); return old; end;' language 'plpgsql'; create trigger tutorialapp__itrg after insert on tutorialapp for each row execute procedure tutorialapp__itrg (); create trigger tutorialapp__dtrg after delete on tutorialapp for each row execute procedure tutorialapp__dtrg (); create trigger tutorialapp__utrg after update on tutorialapp for each row execute procedure tutorialapp__utrg ();/* Whenever a record is changed, a trigger calls a function to update the full text search index. */
Create a database file to drop everything for uninstall.
emacs tutorialapp-drop.sql
Paste this into the file and save and exit.
-- packages/tutorialapp/sql/tutorialapp-drop.sql -- drop script -- Vinod Kurup, vkurup@massmed.org ---- This script removes from the database everything associated with our table.
\i tutorialapp-sc-drop.sql --drop functions drop function tutorialnote__new (integer,integer,varchar,varchar,varchar,timestamp,integer,varchar,integer); drop function tutorialnote__delete (integer); drop function tutorialnote__name (integer); --drop permissions delete from acs_permissions where object_id in (select tutorialnote_id from tutorialapp); --drop objects create function inline_0 () returns integer as ' declare object_rec record; begin for object_rec in select object_id from acs_objects where object_type=''tutorialnote'' loop perform acs_object__delete( object_rec.object_id ); end loop; return 0; end;' language 'plpgsql'; select inline_0(); drop function inline_0(); --drop table drop table tutorialapp; --drop attributes select acs_attribute__drop_attribute ( 'tutorialnote', 'TITLE' ); select acs_attribute__drop_attribute ( 'tutorialnote', 'BODY' ); --drop type select acs_object_type__drop_type( 'tutorialnote', 't' );-- This calls another file to remove the related full text search functions
Create another database file to drop the full text search functions.
emacs tutorialapp-sc-drop.sql
Paste this into the file and save and exit
-- packages/tutorialapp/sql/tutorialapp-sc-drop.sql -- -- @cvs-id $Id: ch03s02.html,v 1.1 2004/05/07 15:04:29 aufrecht Exp $ --select acs_sc_impl__delete( 'FtsContentProvider', -- impl_contract_name 'tutorialnote' -- impl_name ); drop trigger tutorialapp__utrg on tutorialapp; drop trigger tutorialapp__dtrg on tutorialapp; drop trigger tutorialapp__itrg on tutorialapp; drop function tutorialapp__utrg (); drop function tutorialapp__dtrg (); drop function tutorialapp__itrg ();-- This script removes all full text search functions for tutorialapp from the database.
Add the database files to cvs.
cvs add *.sql cd .. cvs add tcl cvs add tcl/* cvs commit -m "new files" psql -f tutorialapp-create.sql openacs-dev restart-aolserver openacs-dev
The psql command executes the create script and loads the table and related functions into the database.
Start adding the user interface and logic pages. The first file we're going to create is index.tcl, which is the default page for the pacage. It is supported by two other files, index.adp and index-postgresql.xql. All of these files live in the www subdirectory in the package.
Create index.tcl, the default page for the package.
cd www emacs index.tcl
Paste this into the file and save and exit
# main index page for tutorialapp.ad_page_contract { @author rhs@mit.edu @creation-date 2000-10-23 @cvs-id $Id: ch03s02.html,v 1.1 2004/05/07 15:04:29 aufrecht Exp $ } -properties { tutorialnote:multirow context_bar:onevalue create_p:onevalue }# The ad_page_contract function lists the public variables and their types.
set package_id [ad_conn package_id]# The unique identifier for this package.
set user_id [ad_conn user_id]# The id of the person logged in and browsing this page
set context_bar [ad_context_bar]# An HTML block for the breadcrumb trail
set create_p [ad_permission_p $package_id create]# Does the current user have permission to create?
db_multirow tutorialnote tutorialnote {} ad_return_template# Call a database function labelled tutorialnote, and store the # results in an array called tutorialnote. The contents of the query # are stored in other files specific to the database type.
# Return the results through an adp template. By default, look for a file with the same name, # so index.tcl invokes index.adp.
Create the file with the database query.
emacs index-postgresql.xql
Paste this into the file and save and exit
<queryset>
<rdbms><type>postgresql</type><version>7.2</version></rdbms>
<fullquery name="tutorialnote">
<querytext>
select tutorialnote_id,
owner_id,
title,
body,
case
when acs_permission__permission_p(tutorialnote_id,:user_id,'write')='t'
then 1
else 0
end
as write_p,
case
when acs_permission__permission_p(tutorialnote_id,:user_id,'admin')='t'
then 1
else 0
end
as admin_p,
case
when acs_permission__permission_p(tutorialnote_id,:user_id,'delete')='t'
then 1
else 0
end
as delete_p
from tutorialapp n, acs_objects o
where n.tutorialnote_id = o.object_id
and o.context_id = :package_id
and acs_permission__permission_p(tutorialnote_id, :user_id, 'read') = 't'
order by creation_date
</querytext>
</fullquery>
</queryset>Xql files hold database-specific language. Because our package has only an index-postgresql.xql file and not an index-oracle.xql file, the index page won't work in Oracle. This SELECT statement returns all of the tutorialapp that the selector has permission to read. It uses case statements to convert some procedure calls (__permission_p) into binary (0 or 1) return values.
Create the user-visible page.
emacs index.adp
Paste this into the file and save and exit
<master>@context_bar@ <hr> <center> <table border=0 cellpadding=1 cellspacing=0 width=80%> <tr><td bgcolor=#aaaaaa> <table border=0 cellpadding=3 cellspacing=0 width=100%><!-- Put the contents of the variable context_bar here -->
<multiple name=tutorialnote> <if @tutorialnote.rownum@ odd> <tr bgcolor=#eeeeee> </if> <else> <tr bgcolor=#ffffff> </else> <td valign=top width=1%><!-- Iterate through the rows of the variable tutorialnote, generating HTML table rows for each record -->
<if @tutorialnote.delete_p@ eq 1> <a href=delete?tutorialnote_id=@tutorialnote.tutorialnote_id@><img border=0 src=x alt=" [Delete] "></a> </if> <else> <img border=0 src=x-disabled alt=" [Can't Delete] "> </else> </td> <td> <a href=note?tutorialnote_id=@tutorialnote.tutorialnote_id@>@tutorialnote.title@</a><!-- If the user can delete records, show a delete button -->
<if @tutorialnote.write_p@ eq 1> [<a href=add-edit?tutorialnote_id=@tutorialnote.tutorialnote_id@>Edit</a>] </if> <table border=0 cellpadding=4 cellspacing=0> <tr> <td> </td> <td><!-- If the user can add records, show an add button -->
<% regsub -all "\n" $tutorialnote(body) "<br>" body adp_puts $body %> </td> </tr> </table> </td> <td valign=top align=right> <if @tutorialnote.admin_p@ eq 1> <font size=-1>(<a href=../permissions/one?object_id=@tutorialnote.tutorialnote_id@>admin</a>)</font> </if> <else> </else> </td> </tr> </multiple> <if @tutorialnote:rowcount@ eq 0> <tr bgcolor=#eeeeee> <td colspan=2 align=center><br>(no tutorialnote)<br> </td> </tr> </if> <tr bgcolor=#aaaaaa> <td colspan=2 align=center> <if @create_p@ eq 1> <a href=add-edit><img border=0 src=add alt=" [Add] "></a> </if> <else> <img border=0 src=add-disabled alt=" [Can't Add Tutorialnote] "> </else> </td> </tr> </table> </td><tr> </table> </center><!-- Insert the body variable, replacing line feeds with html line feeds-->
Create add-edit.tcl, the page used for adding new notes.
emacs add-edit.tcl
Paste this into the file and save and exit.
# packages/tutorialapp/www/add-edit.tcl
ad_page_contract {
@author rhs@mit.edu
@creation-date 2000-10-23
@cvs-id $Id: ch03s02.html,v 1.1 2004/05/07 15:04:29 aufrecht Exp $
} {
tutorialnote_id:integer,notnull,optional
{title:html,notnull,optional ""}
{body ""}
} -properties {
context_bar:onevalue
}
# the second argument of ad_page_contract specifies the expected incoming
# variables. This page has no required variables, but can accept tutorialnote_id,
# title, and body. It will make title and body empty strings if they are
# not specified.
set package_id [ad_conn package_id]
if {[info exists tutorialnote_id]} {
ad_require_permission $tutorialnote_id write
set context_bar [ad_context_bar "Edit Tutorialnote"]
} else {
ad_require_permission $package_id create
set context_bar [ad_context_bar "New Tutorialnote"]
}
# If an existing tutorialnote id is provided, make sure the user has permission to
# edit it. If no id is provided, make sure the user has permission to
# create a new tutorialnote. In either case, update the context bar (breadcrumb
# trail) with the appropriate title.
# Use the template system to prepare an html form.
template::form create new_tutorialnote
if {[template::form is_request new_tutorialnote] && [info exists tutorialnote_id]} {
template::element create new_tutorialnote tutorialnote_id \
-widget hidden \
-datatype number \
-value $tutorialnote_id
db_1row tutorialnote_select {}
}
template::element create new_tutorialnote title \
-datatype text \
-label "Title" \
-html { size 20 } \
-value $title
template::element create new_tutorialnote body \
-widget textarea \
-datatype text \
-label "Body" \
-html { rows 10 cols 40 wrap soft } \
-value $body
# This page is used both to display forms for editing and
# to process submitted forms. If the form was submitted,
# update or create it as appropriate, then redirect to the
# index page
if [template::form is_valid new_tutorialnote] {
set user_id [ad_conn user_id]
set peeraddr [ad_conn peeraddr]
if [info exists tutorialnote_id] {
db_dml tutorialnote_update {}
} else {
db_exec_plsql new_tutorialnote {}
}
ad_returnredirect "."
}
ad_return_template
Create the database query file.
emacs add-edit-postgresql.xql
Paste this into the file and save and exit
<?xml version="1.0"?>
<queryset>
<rdbms><type>postgresql</type><version>7.1</version></rdbms>
<fullquery name="new_tutorialnote">
<querytext>
select tutorialnote__new(
null,
:user_id,
:title,
:body,
'tutorialnote',
now(),
:user_id,
:peeraddr,
:package_id
);
</querytext>
</fullquery>
<fullquery name="tutorialnote_select">
<querytext>
select title,
body
from tutorialapp
where tutorialnote_id = :tutorialnote_id
</querytext>
</fullquery>
<fullquery name="tutorialnote_update">
<querytext>
update tutorialapp
set title = :title,
body = :body
where tutorialnote_id = :tutorialnote_id
</querytext>
</fullquery>
</queryset>
Create the user-visible page.
emacs add-edit.adp
Paste this into the file and save and exit
<master> @context_bar@ <hr> <center> <formtemplate id="new_tutorialnote"></formtemplate> </center>
Create delete.tcl, the page used for adding new notes.
emacs delete.tcl
Paste this into the file and save and exit.
# packages/tutorialapp/www/delete.tcl
ad_page_contract {
@author rhs@mit.edu
@creation-date 2000-10-23
@cvs-id $Id: ch03s02.html,v 1.1 2004/05/07 15:04:29 aufrecht Exp $
} {
note_id:integer,notnull
}
# Check that the current user has permission to
# delete the indicated note.
ad_require_permission $note_id delete
# call the database procudure to delete the note
db_exec_plsql note_delete {
begin
note.delete(:note_id);
end;
}
# return the viewer to the default page of the directory
ad_returnredirect "."
Create the database query file.
emacs delete-postgresql.xql
Paste this into the file and save and exit
<?xml version="1.0"?>
<queryset>
<rdbms><type>postgresql</type><version>7.1</version></rdbms>
<fullquery name="note_delete">
<querytext>
select note__delete( :note_id );
</querytext>
</fullquery>
</queryset>
The note page displays individual notes.
emacs note.tcl
Paste this into the file and save and exit.
# packages/tutorialapp/www/note.tcl
ad_page_contract {
@author Neophytos Demetriou <k2pts@yahoo.com>
@creation-date 2001-09-02
} {
tutorialnote_id:integer,notnull
} -properties {
context_bar:onevalue
title:onevalue
body:onevalue
}
set context_bar [ad_context_bar]
db_1row tutorialnote_select {}
ad_return_template
Create the database query file.
emacs note-postgresql.xql
Paste this into the file and save and exit
<?xml version="1.0"?>
<queryset>
<rdbms><type>postgresql</type><version>7.2</version></rdbms>
<fullquery name="tutorialnote_select">
<querytext>
select title,
body
from tutorialapp
where tutorialnote_id = :tutorialnote_id
</querytext>
</fullquery>
</queryset>
Create the user-visible page.
emacs note.adp
Paste this into the file and save and exit
<master> <h2>@title@</h2> @context_bar@ <hr> @body@
Before this page will work, we have to inform the package manager of the .xql file, so that it can handle database requests.
Go to the package manager and click on tutorialapp.
Click on Manage file information
Click on Scan the packages/tutorialapp directory for additional files in this package
Click
restart-aolserver openacs-dev
Browse to http://yourserver:8000/tutorialapp/ and verify that the page comes up without an error.
Make a list of basic tests to make sure it works
| Test Num | Action | Expected Result |
|---|---|---|
| 001 | Browse to the index page while not logged in and while one or more notes exist. | No edit or delete or add links should appear. |
| 002 | Browse to the index page while logged in. An Edit link should appear. Click on it. Fill out the form and click Submit. | The text added in the form should be visible on the index page. |
Other things to test: try to delete someone else's note. Try to delete your own note. Edit your own note. Search for a note.
The tutorial package should also show how to make use of the content repository API, which includes version control. (Forthcoming.)
Browse to the package manager. Click on tutorialapp.
Click on Manage dependency information
Click on Add a service required by this package
Select acs-service-contract, version 4.5 and
Assuming that the package passed testing and is ready to deploy, we need to indicate that it's ready to go to production. This bit is a little tricky: Because the package includes database changes, the best way to deploy into production is through the APM. But once it's in production, we'll want a way to make non-database changes and deploy them. So we're going to tag the package as production-ready, then create a matching package for the APM.
cd /web/openacs-dev/packages/tutorialapp cvs tag -F current