trac-customfieldadmin / 0.11 / customfieldadmin / api.py

# -*- coding: utf-8 -*-
"""
API for administrating custom ticket fields in Trac.
Supports creating, getting, updating and deleting custom fields.

License: BSD

(c) 2005-2012 ::: www.CodeResort.com - BV Network AS (simon-code@bvnetwork.no)
"""

import re

from pkg_resources import resource_filename

from trac.core import *
from trac.ticket.api import TicketSystem

try:
    from  trac.util.translation import domain_functions
    add_domain, _, tag_ = \
        domain_functions('customfieldadmin', ('add_domain', '_', 'tag_'))
except:
    # fall back to 0.11 behavior, i18n functions are no-ops then
    from genshi.builder import tag as tag_
    from trac.util.translation import gettext as _
    def add_domain(*args, **kwargs):
        pass


__all__ = ['CustomFields']


class CustomFields(Component):
    """ These methods should be part of TicketSystem API/Data Model.
    Adds update_custom_field and delete_custom_field methods.
    (The get_custom_fields is already part of the API - just redirect here,
     and add option to only get one named field back.)
    
    Input to methods is a 'cfield' dict supporting these keys:
        name = name of field (ascii alphanumeric only)
        type = text|checkbox|select|radio|textarea
        label = label description
        value = default value for field content
        options = options for select and radio types
                  (list, leave first empty for optional)
        cols = number of columns for text area
        rows = number of rows for text area
        order = specify sort order for field
        format = text|wiki (for text and textarea)
    """
    
    implements()
    
    config_options = ['label', 'value', 'options', 'cols', 'rows',
                         'order', 'format']
    
    def __init__(self):
        # bind the 'customfieldadmin' catalog to the specified locale directory
        locale_dir = resource_filename(__name__, 'locale')
        add_domain(self.env.path, locale_dir)
        # TODO: Remove systeminfo compat code when only supporting Trac>=0.12
        try:
            from trac.loader import get_plugin_info
        except ImportError:
            from customfieldadmin import __version__
            self.env.systeminfo.append(('CustomFieldAdmin', __version__))
        
    def get_custom_fields(self, cfield=None):
        """ Returns the custom fields from TicketSystem component.
        Use a cfdict with 'name' key set to find a specific custom field only.
        """
        if not cfield:    # return full list
            return TicketSystem(self.env).get_custom_fields()
        else:                  # only return specific item with cfname
            for item in TicketSystem(self.env).get_custom_fields():
                if item['name'] == cfield['name']:
                    return item
            return None        # item not found
    
    def verify_custom_field(self, cfield, create=True):
        """ Basic validation of the input for modifying or creating
        custom fields. """
        # Requires 'name' and 'type'
        if not (cfield.get('name') and cfield.get('type')):
            raise TracError(
                    _("Custom field requires attributes 'name' and 'type'."))
        # Use lowercase custom fieldnames only
        cfield['name'] = cfield['name'].lower()
        # Only alphanumeric characters (and [-_]) allowed for custom fieldname
        if re.search('^[a-z][a-z0-9_]+$', cfield['name']) == None:
            raise TracError(_("Only alphanumeric characters allowed for " \
                             "custom field name ('a-z' or '0-9' or '_'), " \
                             "with 'a-z' as first character."))
        # Name must begin with a character - anything else not supported by Trac
        if not cfield['name'][0].isalpha():
            raise TracError(
                    _("Custom field name must begin with a character (a-z)."))
        # Check that it is a valid field type
        if not cfield['type'] in \
                        ['text', 'checkbox', 'select', 'radio', 'textarea']:
            raise TracError(_("%(field_type)s is not a valid field type"),
                                        field_type=cfield['type'])
        # Check that field does not already exist
        # (if modify it should already be deleted)
        if create and self.config.get('ticket-custom', cfield['name']):
            raise TracError(_("Can not create as field already exists."))
    
    def create_custom_field(self, cfield):
        """ Create the new custom fields (that may just have been deleted as
        part of 'modify'). In `cfield`, 'name' and 'type' keys are required.
        Note: Caller is responsible for verifying input before create."""
        # Need count pre-create for correct order
        count_current_fields = len(self.get_custom_fields())
        # Set the mandatory items
        self.config.set('ticket-custom', cfield['name'], cfield['type'])
        # Label = capitalize fieldname if not present
        self.config.set('ticket-custom', cfield['name'] + '.label',
                        cfield.get('label') or cfield['name'].capitalize())
        # Optional items
        if 'value' in cfield:
            self.config.set('ticket-custom', cfield['name'] + '.value',
                                                        cfield['value'])
        if 'options' in cfield:
            if cfield.get('optional', False):
                self.config.set('ticket-custom', cfield['name'] + '.options',
                                '|' + '|'.join(cfield['options']))
            else:
                self.config.set('ticket-custom', cfield['name'] + '.options',
                               '|'.join(cfield['options'])) 
        if 'format' in cfield and cfield['type'] in ('text', 'textarea'):
            self.config.set('ticket-custom', cfield['name'] + '.format',
                                                        cfield['format'])
        # Textarea
        if cfield['type'] == 'textarea':
            cols = cfield.get('cols') and int(cfield.get('cols', 0)) > 0 \
                                                and cfield.get('cols') or 60
            rows = cfield.get('rows', 0) and int(cfield.get('rows', 0)) > 0 \
                                                and cfield.get('rows') or 5
            self.config.set('ticket-custom', cfield['name'] + '.cols', cols)
            self.config.set('ticket-custom', cfield['name'] + '.rows', rows)
        # Order
        order = cfield.get('order') or count_current_fields + 1
        self.config.set('ticket-custom', cfield['name'] + '.order', order)
        self._save(cfield)

    def update_custom_field(self, cfield, create=False):
        """ Updates a custom field. Option to 'create' is kept in order to keep
        the API backwards compatible. """
        if create:
            self.verify_custom_field(cfield)
            self.create_custom_field(cfield)
            return
        # Check input, then delete and save new
        if not self.get_custom_fields(cfield=cfield):
            raise TracError(_("Custom Field '%(name)s' does not exist. " \
                    "Cannot update.", name=cfield.get('name') or '(none)'))
        self.verify_custom_field(cfield, create=False)
        self.delete_custom_field(cfield, modify=True)
        self.create_custom_field(cfield)
    
    def delete_custom_field(self, cfield, modify=False):
        """ Deletes a custom field. Input is a dictionary
        (see update_custom_field), but only ['name'] is required.
        """
        if not self.config.get('ticket-custom', cfield['name']):
            return # Nothing to do here - cannot find field
        if not modify:
            # Permanent delete - reorder later fields to lower order
            order_to_delete = self.config.getint('ticket-custom',
                                                    cfield['name']+'.order')
            cfs = self.get_custom_fields()
            for field in cfs:
                if field['order'] > order_to_delete:
                    self.config.set('ticket-custom', field['name']+'.order',
                                                    field['order'] -1 )
        supported_options = [cfield['name']] + \
            [cfield['name']+'.'+opt for opt in self.config_options]
        for option, _value in self.config.options('ticket-custom'):
            if modify and not option in supported_options:
                # Only delete supported options when modifying
                # http://trac-hacks.org/ticket/8188
                continue
            if option == cfield['name'] \
                    or option.startswith(cfield['name'] + '.'):
                self.config.remove('ticket-custom', option)
        # Persist permanent deletes
        if not modify:
            self._save(cfield)

    def _save(self, cfield=None):
        """ Saves a value, clear caches if needed / supported. """
        self.config.save()
        try:
            # cache support for Trac >= 0.12
            del TicketSystem(self.env).custom_fields
        except AttributeError:
            # 0.11 cached values internally
            TicketSystem(self.env)._custom_fields = None
        # Re-populate contents of cfield with new values and defaults
        if cfield:
            stored = self.get_custom_fields(cfield=cfield)
            if stored: # created or updated (None for deleted so just ignore)
                cfield.update(stored)
Tip: Filter by directory path e.g. /media app.js to search for public/media/app.js.
Tip: Use camelCasing e.g. ProjME to search for ProjectModifiedEvent.java.
Tip: Filter by extension type e.g. /repo .js to search for all .js files in the /repo directory.
Tip: Separate your search with spaces e.g. /ssh pom.xml to search for src/ssh/pom.xml.
Tip: Use ↑ and ↓ arrow keys to navigate and return to view the file.
Tip: You can also navigate files with Ctrl+j (next) and Ctrl+k (previous) and view the file with Ctrl+o.
Tip: You can also navigate files with Alt+j (next) and Alt+k (previous) and view the file with Alt+o.