Changes between Initial Version and Version 1 of TestManagerForTracPluginGenericClass


Ignore:
Timestamp:
Jan 4, 2011, 5:25:48 PM (13 years ago)
Author:
Roberto Longobardi
Comment:

--

Legend:

Unmodified
Added
Removed
Modified
  • TestManagerForTracPluginGenericClass

    v1 v1  
     1= Test Manager for Trac - Generic Persistent Class Framework =
     2
     3The [wiki:TestManagerForTracPlugin Test Manager plugin] is comprised of four plugins, one of which is a '''Generic Persistent Class framework''', allowing plugin programmers to easily build and manage persistent objects in the Trac database.
     4
     5Inheriting from the '''!AbstractVariableFieldsObject''' base class provided with this plugin is a matter of a few lines of code, and will provide your objects with:
     6   * '''Persistence''', in the Trac database,
     7   * '''Custom Properties''', your Users will be able to specify in their trac.ini files. Also automatic Web user interface support is provided,
     8   * '''Change History tracking''', recording User, timestamp and values before and after of every object's property change,
     9   * '''Object lifecycle Listeners''', allowing your plugins or others to register for feedback on objects creation, deletion and modification,
     10   * '''Custom Authorization''', allowing custom permissions to control any access to your objects,
     11   * '''Integrated Search''', both via the Trac general Search box and programmatically, using pattern matching.
     12
     13[[BR]]
     14The [wiki:TracGenericWorkflowPlugin Generic Workflow Engine] and the [wiki:TestManagerForTracPlugin Test Manager] plugins leverage this plugin for their data models.
     15
     16Works with both Trac 0.11 and 0.12.
     17
     18The basic building blocks of the framework are:
     19
     20 * The '''!AbstractVariableFieldsObject''' base class, providing most object features. Inheriting from this class is a matter of a few lines of code, and will provide your objects with the features outlined above.
     21
     22 * The '''!IConcreteClassProvider''' interface, allowing your plugin to register your classes with the framework, participate in the framework's objects factory and provide custom security.
     23
     24 * The '''!GenericClassModelProvider''' component, providing the objects factory, managing concrete class providers and handling metadata about custom classes and their fields.
     25
     26 * The '''!GenericClassSystem''' component, providing the framework logic for displaying and updating custom properties in the Web user interface, and providing support for the Trac search box.
     27
     28 * The '''need_db_upgrade()''' and '''upgrade_db()''' utility methods, providing the ability for plugins to declaratively create and upgrade their database tables to match the custom classes provided.
     29
     30 * The '''IGenericObjectChangeListener''' inteface, an extension point interface for components that require notification when objects of any particular type are created, modified, or deleted.
     31
     32[[BR]]
     33== !AbstractVariableFieldsObject - The data model ==
     34
     35The plugin provides a base python class, '''!AbstractVariableFieldsObject''', originally derived from the base Trac Ticket class, which brings a rich set of features to any custom class that should inherit from it.
     36
     37It represents a persistent object which fields are declaratively specified.
     38
     39Each subclass will be identified by a type name, the "realm", which is provided at object initialization time.
     40This name must also match the name of the database table storing the corresponding objects, and is used as the base name for the custom fields table and the change tracking table (see below), if needed.
     41
     42Features:
     43 * Support for custom fields, specified in the trac.ini file with the same syntax as for custom Ticket fields. Custom field values are kept in a "<schema>_custom" table.
     44 
     45 * Keeping track of all changes to any field, into a separate "<schema>_change" table.
     46
     47 * A set of callbacks to allow for subclasses to control and perform actions pre and post any operation pertaining the object's lifecycle. Subclasses may give or deny permission for any operation to be performed.
     48
     49 * Registering listeners, via the IGenericObjectChangeListener interface, for object creation, modification and deletion.
     50
     51 * Searching objects matching any set of valorized fields, (even non-key fields), applying the "dynamic record" pattern. See the method list_matching_objects.
     52
     53 * Very well commented code and full of debug messages.
     54
     55[[BR]]
     56A set of special fields help subclasses to implement their logic:
     57
     58 * self.exists : always tells whether the object currently exists in the database.
     59
     60 * self.resource: points to a Resource, in the trac environment, corresponding to this object. This is used, for example, in the workflow implementation.
     61
     62 * self.fields: points to an array of dictionary objects describing name, label, type and other properties of all of this object's fields.
     63
     64 * self.metadata: points to a dictionary object describing further meta-data about this object.
     65   
     66[[BR]]
     67Note: database tables for specific realms are supposed to already exist: this object does not create any tables.
     68See below the !GenericClassModelProvider to see how to make the framework create the required tables declaratively.
     69
     70== How to create a custom persistent object ==
     71
     72This section will guide you with step-by-step instructions on how to create a custom object, including declaring the object's class, providing the fields metadata, and specifying the DB tables.
     73
     74Note: The following examples are taken from the [wiki:TracGenericWorkflowPlugin TracGenericWorkflow plugin]. Refer to the corresponding code if you want more context.
     75
     76The following is the '''basic set of imports''' you will need to create a custom object. The first one is needed to let your class inherit from the basic abstract class, the others to define your concrete model provider.
     77
     78{{{
     79from tracgenericclass.model import AbstractVariableFieldsObject, IConcreteClassProvider, need_db_upgrade, upgrade_db
     80}}}
     81
     82[[BR]]
     83Then we must '''declare the concrete class'''. See the description below the code box.
     84
     85{{{
     86class ResourceWorkflowState(AbstractVariableFieldsObject):
     87    # Fields that have no default, and must not be modified directly by the user
     88    protected_fields = ('id', 'res_realm', 'state')
     89
     90    def __init__(self, env, id=None, res_realm=None, state='new', db=None):
     91        self.values = {}
     92
     93        self.values['id'] = id
     94        self.values['res_realm'] = res_realm
     95        self.values['state'] = state
     96
     97        key = self.build_key_object()
     98   
     99        AbstractVariableFieldsObject.__init__(self, env, 'resourceworkflowstate', key, db)
     100
     101    def get_key_prop_names(self):
     102        return ['id', 'res_realm']
     103       
     104    def create_instance(self, key):
     105        return ResourceWorkflowState(self.env, key['id'], key['res_realm'])
     106}}}
     107
     108The first statement defines the concrete class as a subclass of AbstractVariableFieldsObject.
     109
     110The {{{protected_fields}}} field declares which fields must not be given a default at creation time. It is of little use, but it's still required.
     111
     112Then, the constructor. The only requirements on the constructor signature are that you must have room for the parameters {{{env}}} and {{{db}}}, which you'll need to pass to the superclass.
     113The other parameters you see in this example are just specific to the workflow object.
     114
     115Talking about the constructor we must see the different uses you can make of it.
     116
     117In fact, you can:
     118
     119 * Create an empty object, and also try to fetch it from the database, if an object with a matching key is found.
     120   To fetch an existing object from the database:
     121   1. specify a key at contruction time: the object will be filled with all of the values form the database,
     122   2. modify any other property via the {{{obj['fieldname'] = value}}} syntax, including custom fields.
     123      This syntax is the only one to keep track of the changes to any field.
     124   3. call the save_changes() method on the object.[[BR]]
     125 * Create a new object to be later stored in the database. In this case, the steps required are the following:
     126   1. specify a key at contruction time,
     127   2. set any other property via the {{{obj['fieldname'] = value}}} syntax, including custom fields,
     128   3. call the insert() method on the object.[[BR]]
     129 * Create an empty, template object, used for pattern matching search. To do this, do not specify a key at initialization time.[[BR]]
     130
     131In the body of the constructor, there a few important things you must do:
     132 1. Clear the {{{self.values}}} dictionary field, which holds all of the object's property values,
     133 2. Set the parameters received in the constructor into {{{self.values}}}. Use the syntax where you assign values directly to the {{{self.values}}} dictionary here, not the one like {{{self['fieldname'] = value}}},
     134 3. Build the object's key (by using the provided {{{self.build_key_object()}}} method, if you wish),
     135 4. Call the superclass constructor, passing as the third argument the unique type name that will identify objects of your custom class. Remember that this name must match the name of the database table providing persistence to your objects.
     136
     137Two more methods are required to make a functional subclass.
     138
     139The {{{get_key_prop_names()}}} method is vital in telling the framework which the key properties of your subclass are.
     140As you can see, you should return an array of property names here. Also note that the {{{build_key_object()}}} method uses information returned from this method to build the object's key.
     141
     142The {{{create_instance()}}} is a factory method used to create an instance of this custom class, given a key dictionary object.
     143
     144[[BR]]
     145'''This is it!!!''' Our new class is ready to be used, with all the features outlined at the top of this page.
     146
     147[[BR]][[BR]]
     148Well, actually before being able to use our new class, we must:
     149 * '''Define our concrete class provider''', implementing the '''!IConcreteClassProvider''' interface, and
     150 * '''Declare the data model''' to be created into the Trac DB.
     151
     152But, as you will see, it is far from complex. See the description below the code box.
     153
     154{{{
     155class GenericWorkflowModelProvider(Component):
     156    """
     157    This class provides the data model for the generic workflow plugin.
     158   
     159    The actual data model on the db is created starting from the
     160    SCHEMA declaration below.
     161    For each table, we specify whether to create also a '_custom' and
     162    a '_change' table.
     163   
     164    This class also provides the specification of the available fields
     165    for each class, being them standard fields and the custom fields
     166    specified in the trac.ini file.
     167    The custom field specification follows the same syntax as for
     168    Tickets.
     169    Currently, only 'text' type of custom fields are supported.
     170    """
     171
     172    implements(IConcreteClassProvider, IEnvironmentSetupParticipant)
     173
     174    SCHEMA = {
     175                'resourceworkflowstate': 
     176                    {'table':
     177                        Table('resourceworkflowstate', key = ('id', 'res_realm'))[
     178                              Column('id'),
     179                              Column('res_realm'),
     180                              Column('state')],
     181                     'has_custom': True,
     182                     'has_change': True}
     183            }
     184
     185    FIELDS = {
     186                'resourceworkflowstate': [
     187                    {'name': 'id', 'type': 'text', 'label': N_('ID')},
     188                    {'name': 'res_realm', 'type': 'text', 'label': N_('Resource realm')},
     189                    {'name': 'state', 'type': 'text', 'label': N_('Workflow state')}
     190                ]
     191            }
     192           
     193    METADATA = {
     194                'resourceworkflowstate': {
     195                        'label': "Workflow State",
     196                        'searchable': False,
     197                        'has_custom': True,
     198                        'has_change': True
     199                    },
     200                }
     201
     202           
     203    # IConcreteClassProvider methods
     204    def get_realms(self):
     205            yield 'resourceworkflowstate'
     206
     207    def get_data_models(self):
     208        return self.SCHEMA
     209
     210    def get_fields(self):
     211        return self.FIELDS
     212       
     213    def get_metadata(self):
     214        return self.METADATA
     215       
     216    def create_instance(self, realm, key=None):
     217        obj = None
     218       
     219        if realm == 'resourceworkflowstate':
     220            if key is not None:
     221                obj = ResourceWorkflowState(self.env, key['id'], key['res_realm'])
     222            else:
     223                obj = ResourceWorkflowState(self.env)
     224       
     225        return obj
     226
     227    def check_permission(self, req, realm, key_str=None, operation='set', name=None, value=None):
     228        pass
     229
     230    # IEnvironmentSetupParticipant methods
     231    def environment_created(self):
     232        self.upgrade_environment(self.env.get_db_cnx())
     233
     234    def environment_needs_upgrade(self, db):
     235        return self._need_initialization(db)
     236
     237    def upgrade_environment(self, db):
     238        # Create db
     239        if self._need_initialization(db):
     240            upgrade_db(self.env, self.SCHEMA, db)
     241
     242    def _need_initialization(self, db):
     243        return need_db_upgrade(self.env, self.SCHEMA, db)
     244}}}
     245
     246First of all, this should be a component, because it must implement Trac interfaces.
     247The interfaces to implement are the following:
     248 * IConcreteClassProvider, to provide custom classes to the framework,
     249 * IEnvironmentSetupParticipant, to be queried by Trac about any needs regarding database creation or upgrade.
     250
     251Then here comes the declaration of the database schema, class fields and class metadata, to be later returned in the corresponding methods of the interface.
     252
     253Following is the '''!IConcreteClassProvider interface''' documentation, which is pretty explanatory about the format and meaning of the required data.
     254
     255{{{
     256    def get_realms():
     257        """
     258        Return class realms provided by the component.
     259
     260        :rtype: `basestring` generator
     261        """
     262
     263    def get_data_models():
     264        """
     265        Return database tables metadata to allow the framework to create the
     266        db schema for the classes provided by the component.
     267
     268        :rtype: a dictionary, which keys are schema names and values
     269                are dictionaries with table metadata, as in the following example:
     270                return {'sample_realm':
     271                            {'table':
     272                                Table('samplerealm', key = ('id', 'otherid'))[
     273                                      Column('id'),
     274                                      Column('otherid'),
     275                                      Column('prop1'),
     276                                      Column('prop2'),
     277                                      Column('time', type='int64')],
     278                             'has_custom': True,
     279                             'has_change': True},
     280                       }
     281        """
     282
     283    def get_fields():
     284        """
     285        Return the standard fields for classes in all the realms
     286        provided by the component.
     287
     288        :rtype: a dictionary, which keys are realm names and values
     289                are arrays of fields metadata, as in the following example:
     290                return {'sample_realm': [
     291                            {'name': 'id', 'type': 'text', 'label': N_('ID')},
     292                            {'name': 'otherid', 'type': 'text', 'label': N_('Other ID')},
     293                            {'name': 'prop1', 'type': 'text', 'label': N_('Property 1')},
     294                            {'name': 'prop2', 'type': 'text', 'label': N_('Property 2')},
     295                            {'name': 'time', 'type': 'time', 'label': N_('Last Change')}
     296                       }
     297        """
     298       
     299    def get_metadata():
     300        """
     301        Return a set of metadata about the classes in all the realms
     302        provided by the component.
     303
     304        :rtype: a dictionary, which keys are realm names and values
     305                are dictionaries of properties.
     306               
     307                Available metadata properties are:
     308                    label: A User-friendly name for the objects in this class.
     309                    searchable: If present and equal to True indicates the class
     310                                partecipates in the Trac search framework, and
     311                                must implement the get_search_results() method.
     312                    'has_custom': If present and equal to True indicates the class
     313                                  supports custom fields.
     314                    'has_change': If present and equal to True indicates the class
     315                                  supports property change history.
     316                   
     317                See the following example:
     318                return {'sample_realm': {
     319                                'label': "Sample Realm",
     320                                'searchable': True
     321                            }
     322                       }
     323        """
     324
     325    def create_instance(realm, props=None):
     326        """
     327        Return an instance of the specified realm, with the specified properties,
     328        or an empty object if props is None.
     329
     330        :rtype: `AbstractVariableFieldsObject` sub-class instance
     331        """
     332
     333    def check_permission(req, realm, key_str=None, operation='set', name=None, value=None):
     334        """
     335        Checks whether the logged in User has permission to perform
     336        the specified operation on a resource of the specified realm and
     337        optionally with the specified key.
     338       
     339        Raise an exception if authorization is denied.
     340       
     341        Possible operations are:
     342            'set': set a property with a value. 'name' and 'value' parameters are required.
     343            'search': search for objects of this class.
     344       
     345        :param key_str: optional, the object's key, in the form of a string representing
     346                        a dictionary. To get a dictionary back from this string, use the
     347                        get_dictionary_from_string() function in the
     348                        tracgenericclass.util package.
     349        :param operation: optional, the operation to be performed on the object.
     350        :param name: optional property name, valid for the 'set' operation type
     351        :param value: optional property value, valid for the 'set' operation type
     352        """
     353}}}
     354
     355For what concerns the '''!IEnvironmentSetupParticipant interface''' implementation, it is really straightforward, in that the framework provides utility methods that, reading the schema declared in the above section:
     356 * Determine whether a database upgrade is needed, checking the availability of the tables and the columns as declared. This is achieved through the utility method {{{need_db_upgrade()}}}.
     357 * Perform the database upgrade if required. This is achieved through the utility method {{{upgrade_db()}}}. '''Note:''' only creating all the tables is currently supported, not altering existing tables (for example due to your plugin database schema changing between versions).
     358
     359[[BR]]
     360== !IGenericObjectChangeListener - Registering listeners to objects lifecycle events ==
     361
     362Every class inheriting from !AbstractVariableFieldsObject supports a listener interface for components interested in objects' lifecycle events.
     363
     364To register a listener for any particular class type, i.e. "realm", your component must implement the {{{!IGenericObjectChangeListener}}} from the {{{tracgenericclass.api}}} package.
     365
     366Following is the documentation from the interface itself.
     367
     368{{{
     369    def object_created(g_object):
     370        """Called when an object is created."""
     371
     372    def object_changed(g_object, comment, author, old_values):
     373        """Called when an object is modified.
     374       
     375        `old_values` is a dictionary containing the previous values of the
     376        fields that have changed.
     377        """
     378
     379    def object_deleted(g_object):
     380        """Called when an object is deleted."""
     381}}}
     382
     383
     384
     385