Create Ticket

Ticket #146: t146_r1386655_inplace_ticket_backend.diff

File t146_r1386655_inplace_ticket_backend.diff, 46.5 KB (added by olemis, 21 months ago)

Patch: BH Theme #146 : Handle submit requests generated by in-place editors in ticket view

  • .py

    # HG changeset patch
    # Parent 1da56c51c8b44a4157a63c238150fd4a0bdcdd1f
    BH Theme #146 : Handle submit requests generated by in-place editors in ticket view
    
    diff --git a/bhtheme/theme.py b/bhtheme/editable.py
    copy from bhtheme/theme.py
    copy to bhtheme/editable.py
    old new  
    1616#  specific language governing permissions and limitations 
    1717#  under the License. 
    1818 
    19 from genshi.builder import tag 
    20 from genshi.filters.transform import Transformer 
     19""" 
     20Support in web UI to edit object properties in place. 
     21""" 
    2122 
    2223from trac.core import * 
    23 from trac.mimeview.api import get_mimetype 
    2424from trac.ticket.api import TicketSystem 
    25 from trac.ticket.model import Ticket 
    26 from trac.ticket.notification import TicketNotifyEmail 
    27 from trac.ticket.web_ui import TicketModule 
    28 from trac.util.compat import set 
    29 from trac.util.translation import _ 
    30 from trac.versioncontrol.web_ui.browser import BrowserModule 
    31 from trac.web.api import IRequestFilter, IRequestHandler, ITemplateStreamFilter 
    32 from trac.web.chrome import (add_script, add_stylesheet, INavigationContributor, 
    33                              ITemplateProvider, prevnext_nav) 
     25from trac.util.text import to_unicode 
     26from trac.web.api import IRequestFilter, IRequestHandler, \ 
     27        ITemplateStreamFilter, RequestDone 
    3428 
    35 from themeengine.api import ThemeBase, ThemeEngineSystem 
     29from bhtheme.ticket import get, _get_ticket_module, update 
    3630 
    37 from bhdashboard.util import dummy_request, load_json 
    38 from bhdashboard.web_ui import DashboardModule 
    39  
    40 from urlparse import urlparse 
    41 from wsgiref.util import setup_testing_defaults 
    42  
    43 json = load_json() 
    44  
    45 try: 
    46     from multiproduct.ticket.web_ui import ProductTicketModule 
    47 except ImportError: 
    48     ProductTicketModule = None 
    49  
    50 class BloodhoundTheme(ThemeBase): 
    51     """Look and feel of Bloodhound issue tracker. 
     31class TicketInplaceEditing(Component): 
     32    """Edit ticket fields in place in ticket view. 
    5233    """ 
    53     template = htdocs = css = screenshot = disable_trac_css = True 
    54     disable_all_trac_css = True 
    55     BLOODHOUND_KEEP_CSS = set( 
    56         ( 
    57             'diff.css', 
    58         ) 
    59     ) 
    60     BLOODHOUND_TEMPLATE_MAP = { 
    61         # Admin 
    62         'admin_basics.html' : ('bh_admin_basics.html', None), 
    63         'admin_components.html' : ('bh_admin_components.html', None), 
    64         'admin_enums.html' : ('bh_admin_enums.html', None), 
    65         'admin_logging.html' : ('bh_admin_logging.html', None), 
    66         'admin_milestones.html' : ('bh_admin_milestones.html', None), 
    67         'admin_perms.html' : ('bh_admin_perms.html', None), 
    68         'admin_plugins.html' : ('bh_admin_plugins.html', None), 
    69         'admin_repositories.html' : ('bh_admin_repositories.html', None), 
    70         'admin_versions.html' : ('bh_admin_versions.html', None), 
    71         'admin_products.html' : ('bh_admin_products.html', None), 
    72  
    73         # Preferences 
    74         'prefs_advanced.html' : ('bh_prefs_advanced.html', None), 
    75         'prefs_datetime.html' : ('bh_prefs_datetime.html', None), 
    76         'prefs_general.html' : ('bh_prefs_general.html', None), 
    77         'prefs_keybindings.html' : ('bh_prefs_keybindings.html', None), 
    78         'prefs_pygments.html' : ('bh_prefs_pygments.html', None), 
    79  
    80         # Search 
    81         'search.html' : ('bh_search.html', '_modify_search_data'), 
    82  
    83         # Wiki 
    84         'wiki_delete.html' : ('bh_wiki_delete.html', None), 
    85         'wiki_diff.html' : ('bh_wiki_diff.html', None), 
    86         'wiki_edit.html' : ('bh_wiki_edit.html', None), 
    87         'wiki_rename.html' : ('bh_wiki_rename.html', None), 
    88         'wiki_view.html' : ('bh_wiki_view.html', '_modify_wiki_page_path'), 
    89  
    90         # Ticket 
    91         'milestone_edit.html' : ('bh_milestone_edit.html', None), 
    92         'milestone_delete.html' : ('bh_milestone_delete.html', None), 
    93         'milestone_view.html' : ('bh_milestone_view.html', '_modify_roadmap_css'), 
    94         'query.html' : ('bh_query.html', None), 
    95         'report_delete.html' : ('bh_report_delete.html', None), 
    96         'report_edit.html' : ('bh_report_edit.html', None),  
    97         'report_list.html' : ('bh_report_list.html', None), 
    98         'report_view.html' : ('bh_report_view.html', None), 
    99         'ticket.html' : ('bh_ticket.html', '_modify_editable'), 
    100         'ticket_preview.html' : ('bh_ticket_preview.html', None), 
    101  
    102         # Multi Product 
    103         'product_view.html' : ('bh_product_view.html', None), 
    104  
    105         # General purpose 
    106         'history_view.html' : ('bh_history_view.html', None), 
    107     } 
    108     BOOTSTRAP_CSS_DEFAULTS = ( 
    109         # ('XPath expression', ['default', 'bootstrap', 'css', 'classes']) 
    110         ("body//table[not(contains(@class, 'table'))]", # TODO: Accurate ? 
    111                 ['table', 'table-condensed']), 
    112     ) 
    113  
    114     implements(IRequestFilter, INavigationContributor, ITemplateProvider, 
    115                ITemplateStreamFilter) 
    116  
    117     # ITemplateStreamFilter methods 
    118  
    119     def filter_stream(self, req, method, filename, stream, data): 
    120         """Insert default Bootstrap CSS classes if rendering  
    121         legacy templates (i.e. determined by template name prefix). 
    122         """ 
    123         tx = Transformer('body') 
    124  
    125         def add_classes(classes): 
    126             """Return a function ensuring CSS classes will be there for element. 
    127             """ 
    128             def attr_modifier(name, event): 
    129                 attrs = event[1][1] 
    130                 class_list = attrs.get(name, '').split() 
    131                 self.log.debug('BH Theme : Element classes ' + str(class_list)) 
    132  
    133                 out_classes = ' '.join(set(class_list + classes)) 
    134                 self.log.debug('BH Theme : Inserting class ' + out_classes) 
    135                 return out_classes 
    136             return attr_modifier 
    137          
    138         # Insert default bootstrap CSS classes if necessary 
    139         for xpath, classes in self.BOOTSTRAP_CSS_DEFAULTS : 
    140             tx = tx.end().select(xpath) \ 
    141                     .attr('class', add_classes(classes)) 
    142         return stream | tx 
     34    implements(IRequestFilter, IRequestHandler) 
    14335 
    14436    # IRequestFilter methods 
    14537 
    14638    def pre_process_request(self, req, handler): 
    147         """Pre process request filter""" 
     39        """Intercept requests sent to (existing) ticket URL and handle it only 
     40        if `inplace` argument is set to `submit` . 
     41        """ 
     42        try: 
     43            tm = _get_ticket_module(self.env) 
     44        except TracError: 
     45            pass 
     46        else: 
     47            if tm.match_request(req) and 'id' in req.args \ 
     48                    and req.args.get('inplace') == 'submit': 
     49                return self 
    14850        return handler 
    14951 
    15052    def post_process_request(self, req, template, data, content_type): 
    151         """Post process request filter. 
    152         Removes all trac provided css if required""" 
    153         def is_active_theme(): 
    154             is_active = False 
    155             active_theme = ThemeEngineSystem(self.env).theme 
    156             if active_theme is not None: 
    157                 this_theme_name = self.get_theme_names().next() 
    158                 is_active = active_theme['name'] == this_theme_name 
    159             return is_active 
    160  
    161         links = req.chrome.get('links',{}) 
    162         # replace favicon if appropriate 
    163         if self.env.project_icon == 'common/trac.ico': 
    164             bh_icon = 'theme/img/bh.ico' 
    165             new_icon = {'href': req.href.chrome(bh_icon), 
    166                         'type': get_mimetype(bh_icon)} 
    167             if links.get('icon'): 
    168                 links.get('icon')[0].update(new_icon) 
    169             if links.get('shortcut icon'): 
    170                 links.get('shortcut icon')[0].update(new_icon) 
    171          
    172         is_active_theme = is_active_theme() 
    173         if self.disable_all_trac_css and is_active_theme: 
    174             if self.disable_all_trac_css: 
    175                 stylesheets = links.get('stylesheet',[]) 
    176                 if stylesheets: 
    177                     path = req.base_path + '/chrome/common/css/' 
    178                     _iter = ([ss, ss.get('href', '')] for ss in stylesheets) 
    179                     links['stylesheet'] = [ss for ss, href in _iter  
    180                             if not href.startswith(path) or 
    181                             href.rsplit('/', 1)[-1] in self.BLOODHOUND_KEEP_CSS] 
    182             template, modifier = self.BLOODHOUND_TEMPLATE_MAP.get( 
    183                     template, (template, None)) 
    184             if modifier is not None: 
    185                 modifier = getattr(self, modifier) 
    186                 modifier(req, template, data, content_type, is_active_theme) 
    187         return template, data, content_type 
    188  
    189     # ITemplateProvider methods 
    190  
    191     def get_htdocs_dirs(self): 
    192         """Ensure dashboard htdocs will be there even if 
    193         `bhdashboard.web_ui.DashboardModule` is disabled. 
     53        """Do nothing 
    19454        """ 
    195         if not self.env.is_component_enabled(DashboardModule): 
    196             return DashboardModule(self.env).get_htdocs_dirs() 
    197  
    198     def get_templates_dirs(self): 
    199         """Ensure dashboard templates will be there even if 
    200         `bhdashboard.web_ui.DashboardModule` is disabled. 
    201         """ 
    202         if not self.env.is_component_enabled(DashboardModule): 
    203             return DashboardModule(self.env).get_templates_dirs() 
    204  
    205     # Request modifiers 
    206  
    207     def _modify_search_data(self, req, template, data, content_type, is_active): 
    208         """Insert breadcumbs and context navigation items in search web UI 
    209         """ 
    210         if is_active: 
    211             # Insert query string in search box (see bloodhound_theme.html) 
    212             req.search_query = data.get('query') 
    213             # Breadcrumbs nav 
    214             data['resourcepath_template'] = 'bh_path_search.html' 
    215             # Context nav 
    216             prevnext_nav(req, _('Previous'), _('Next')) 
    217  
    218     def _modify_wiki_page_path(self, req, template, data, content_type, is_active): 
    219         """Override wiki breadcrumbs nav items 
    220         """ 
    221         if is_active: 
    222             data['resourcepath_template'] = 'bh_path_wikipage.html' 
    223  
    224     def _modify_roadmap_css(self, req, template, data, content_type, is_active): 
    225         """Insert roadmap.css 
    226         """ 
    227         add_stylesheet(req, 'dashboard/css/roadmap.css') 
    228  
    229     def _modify_editable(self, req, template, data, content_type, is_active): 
    230         """Insert data needed for inplace edit 
    231         """ 
    232         add_script(req, 'dashboard/js/jquery.json.js') 
    233         add_script(req, 'dashboard/js/jquery.jeditable.js') 
    234         add_script(req, 'dashboard/js/bheditable.js') 
    235         json = load_json() 
    236         if data is not None: 
    237             data['json'] = {'dumps': json.dumps, 'loads':json.loads} 
    238             data['edit_data'] = dict([f['name'], self.field_edit_data(f, data)]  
    239                     for f in data.get('fields', [])) 
    240  
    241     # INavigationContributor methods 
    242  
    243     def get_active_navigation_item(self, req): 
    244         return 
    245  
    246     def get_navigation_items(self, req): 
    247         if 'BROWSER_VIEW' in req.perm and 'VERSIONCONTROL_ADMIN' in req.perm: 
    248             bm = self.env[BrowserModule] 
    249             if bm and not list(bm.get_navigation_items(req)): 
    250                 yield ('mainnav', 'browser',  
    251                        tag.a(_('Browse Source'), 
    252                              href=req.href.wiki('TracRepositoryAdmin'))) 
    253  
    254     # Public API and helper methods 
    255  
    256     EDIT_DEFAULTS = { 
    257             'submit' : tag.button(tag.i(class_='icon-ok'), 
    258                     class_='btn', title='Update'), 
    259             'cancel' : tag.button(_('Cancel'), class_='btn-link'), 
    260         } 
    261  
    262     def field_edit_data(self, field, data): 
    263         """Attributes used to install/trigger jEditable inplace editor for a  
    264         ticket field 
    265         """ 
    266         ticket = data.get('ticket') 
    267         if ticket: 
    268             value = ticket.get_value_or_default(field['name']) 
    269         else: 
    270             value = None 
    271         if field['type'] == 'select': 
    272             options = field.get('options', []) 
    273             if field['optional']: 
    274                 options = [''] + options 
    275             optgroups = [g for g in field.get('optgroups', []) if g['options']] 
    276             return { 
    277                     'data-editdata' : 'javascript:select_value', 
    278                     'data-editselopts' : json.dumps( 
    279                             { 
    280                                 'options' : options, 
    281                                 'optgroups' : optgroups, 
    282                             }), 
    283                     'data-edittype' : 'bhselect', 
    284                 } 
    285         elif field['type'] == 'text': 
    286             return { 
    287                     'data-editsubmit' : unicode(self.EDIT_DEFAULTS['submit']), 
    288                     'data-edittype' : 'bhtext', 
    289                     'data-editheight': 'none', 
    290                     'data-editwidth': 'none', 
    291                     'data-editcssclass' : 'inplace input-append', 
    292                 } 
    293         elif field['type'] == 'textarea': 
    294             return { 
    295                     'data-editsubmit' :  
    296                             unicode(self.EDIT_DEFAULTS['submit']), 
    297                     'data-editcancel' :  
    298                             unicode(self.EDIT_DEFAULTS['cancel']), 
    299                     'data-edittype' : 'bhwiki' if field.get('format') == 'wiki' 
    300                             else 'textarea', 
    301                     'data-editcols': field.get('width'), 
    302                     'data-editrows': field.get('height'), 
    303                 } 
    304         elif field['type'] == 'checkbox': 
    305             return { 
    306                     'data-editdata' : 'javascript:checkbox_value', 
    307                     'data-edittype' : 'checkbox', 
    308                     'data-editnofocus' : 'true', 
    309                 } 
    310         elif field['type'] == 'radio': 
    311             return { 
    312                     'data-editdata' : 'javascript:select_value', 
    313                     'data-editselopts' : json.dumps( 
    314                             { 
    315                                 'options' : field.get('options', []), 
    316                             }), 
    317                     'data-edittype' : 'bhradio', 
    318                 } 
    319         return None 
    320  
    321 class QuickCreateTicketDialog(Component): 
    322     implements(IRequestFilter, IRequestHandler) 
    323  
    324     # IRequestFilter(Interface): 
    325  
    326     def pre_process_request(self, req, handler): 
    327         """Nothing to do. 
    328         """ 
    329         return handler 
    330  
    331     def post_process_request(self, req, template, data, content_type): 
    332         """Append necessary ticket data 
    333         """ 
    334         try: 
    335             tm = self._get_ticket_module() 
    336         except TracError: 
    337             # no ticket module so no create ticket button 
    338             return template, data, content_type 
    339  
    340         if (template, data, content_type) != (None,) * 3: # TODO: Check ! 
    341             if data is None: 
    342                 data = {} 
    343             fakereq = dummy_request(self.env) 
    344             ticket = Ticket(self.env) 
    345             tm._populate(fakereq, ticket, False) 
    346             fields = dict([f['name'], f] \ 
    347                         for f in tm._prepare_fields(fakereq, ticket)) 
    348             data['qct'] = { 'fields' : fields } 
    34955        return template, data, content_type 
    35056 
    35157    # IRequestHandler methods 
    35258 
    35359    def match_request(self, req): 
    354         """Handle requests sent to /qct 
     60        """No need to match request path because this handler is always  
     61        selected by request filter methods . 
    35562        """ 
    356         return req.path_info == '/qct' 
     63        return False 
    35764 
    35865    def process_request(self, req): 
    359         """Forward new ticket request to `trac.ticket.web_ui.TicketModule` 
    360         but return plain text suitable for AJAX requests. 
     66        """Modify ticket field and return its current HTML representation. 
    36167        """ 
     68        ticket_id = fieldnm = value = None 
    36269        try: 
    363             tm = self._get_ticket_module() 
    364             req.perm.require('TICKET_CREATE') 
    365             summary = req.args.pop('field_summary', '') 
    366             desc = "" 
    367             attrs = dict([k[6:], v] for k,v in req.args.iteritems() \ 
    368                                     if k.startswith('field_')) 
    369             ticket_id = self.create(req, summary, desc, attrs, True) 
     70            assert req.args.get('inplace') == 'submit' 
     71            ticket_id = req.args.get('id') 
     72            if ticket_id is None: 
     73                raise ValueError('Missing ticket ID') 
     74            fieldnm = req.args.get('field') 
     75            value = req.args.get('value') 
     76            if fieldnm is None: 
     77                self.log.warning('Bad request: Ticket field name expected. ' \ 
     78                        'Got nothing') 
     79                req.send('Bad request: Ticket field name expected. Got nothing', 
     80                        'plain/text', 400) 
     81            elif fieldnm.startswith('v_'): 
     82                # FIXME: Little hack. Figure out a way to always post field name 
     83                fieldnm = fieldnm[2:] 
     84 
     85            if value is None: 
     86                self.log.warning('Bad request: Value expected. Got nothing') 
     87                req.send('Bad request: Value expected. Got nothing', 
     88                        'plain/text', 400) 
     89            t = update(self.env, req, ticket_id, '',  
     90                    { fieldnm : value, 'action' : 'leave'}, True) 
     91 
     92            # Shortest path rather than invoking TicketSystem._prepare_fields 
     93            field = ( f for f in TicketSystem(self.env).fields  
     94                        if f['name'] == fieldnm ).next() 
     95            tm = _get_ticket_module(self.env) 
     96            rendered = t[3][fieldnm] 
     97            if field['type'] not in ['text', 'textarea']: 
     98                rendered = tm._query_link(req, fieldnm, rendered) 
     99                ct = 'text/html' 
     100            else: 
     101                ct = 'plain/text' 
     102 
     103        except RequestDone: 
     104            raise 
    370105        except Exception, exc: 
    371             self.log.exception("BH: Quick create ticket failed %s" % (exc,)) 
    372             req.send(str(exc), 'plain/text', 500) 
     106            self.log.exception("BH: In-place edit failed ticket '%s' " \ 
     107                    "field '%s' value '%s'", ticket_id, fieldnm, value) 
     108            req.send(to_unicode(exc).encode('utf-8'), 'plain/text', 500) 
    373109        else: 
    374             req.send(str(ticket_id), 'plain/text') 
     110            req.send(to_unicode(rendered).encode('utf-8'), ct) 
    375111 
    376     def _get_ticket_module(self): 
    377         ptm = None 
    378         if ProductTicketModule is not None: 
    379             ptm = self.env[ProductTicketModule] 
    380         tm = self.env[TicketModule] 
    381         if not (tm is None) ^ (ptm is None): 
    382             raise TracError('Unable to load TicketModule (disabled)?') 
    383         if tm is None: 
    384             tm = ptm 
    385         return tm 
    386  
    387     # Public API 
    388     def create(self, req, summary, description, attributes = {}, notify=False): 
    389         """ Create a new ticket, returning the ticket ID.  
    390  
    391         PS: Borrowed from XmlRpcPlugin. 
    392         """ 
    393         t = Ticket(self.env) 
    394         t['summary'] = summary 
    395         t['description'] = description 
    396         t['reporter'] = req.authname 
    397         for k, v in attributes.iteritems(): 
    398             t[k] = v 
    399         t['status'] = 'new' 
    400         t['resolution'] = '' 
    401         t.insert() 
    402         # Call ticket change listeners 
    403         ts = TicketSystem(self.env) 
    404         for listener in ts.change_listeners: 
    405             listener.ticket_created(t) 
    406         if notify: 
    407             try: 
    408                 tn = TicketNotifyEmail(self.env) 
    409                 tn.notify(t, newticket=True) 
    410             except Exception, e: 
    411                 self.log.exception("Failure sending notification on creation " 
    412                                    "of ticket #%s: %s" % (t.id, e)) 
    413         return t.id 
    414  
    415  
  • bhtheme/templates/bh_ticket_box.html

    diff --git a/bhtheme/templates/bh_ticket_box.html b/bhtheme/templates/bh_ticket_box.html
    a b  
    5555          <py:if test="field"><i18n:msg params="field">${field.label or field.name}:</i18n:msg></py:if> 
    5656        </p> 
    5757        <p style="font-size: ${fontsize if field and field.name != 'cc' else '120%'}; min-height: 1em" 
     58            id="${'v_' + field.name if field else None}" data-editid="field" 
    5859            py:with="fed = edit_data.get(field.name) if edit_data else None"  
    5960            data-edit="${'inplace' if fed else None}" py:attrs="fed or {}" > 
    6061          <py:if test="field"> 
  • bhtheme/theme.py

    diff --git a/bhtheme/theme.py b/bhtheme/theme.py
    a b  
    1616#  specific language governing permissions and limitations 
    1717#  under the License. 
    1818 
     19""" 
     20Apache(TM) Bloodhound theme  
     21""" 
     22 
    1923from genshi.builder import tag 
    2024from genshi.filters.transform import Transformer 
    2125 
    2226from trac.core import * 
    2327from trac.mimeview.api import get_mimetype 
    24 from trac.ticket.api import TicketSystem 
    2528from trac.ticket.model import Ticket 
    26 from trac.ticket.notification import TicketNotifyEmail 
    27 from trac.ticket.web_ui import TicketModule 
    2829from trac.util.compat import set 
     30from trac.util.text import to_unicode 
    2931from trac.util.translation import _ 
    3032from trac.versioncontrol.web_ui.browser import BrowserModule 
    3133from trac.web.api import IRequestFilter, IRequestHandler, ITemplateStreamFilter 
     
    3638 
    3739from bhdashboard.util import dummy_request, load_json 
    3840from bhdashboard.web_ui import DashboardModule 
     41from bhtheme.ticket import create, _get_ticket_module 
    3942 
    4043from urlparse import urlparse 
    4144from wsgiref.util import setup_testing_defaults 
    4245 
    4346json = load_json() 
    4447 
    45 try: 
    46     from multiproduct.ticket.web_ui import ProductTicketModule 
    47 except ImportError: 
    48     ProductTicketModule = None 
    49  
    5048class BloodhoundTheme(ThemeBase): 
    5149    """Look and feel of Bloodhound issue tracker. 
    5250    """ 
     
    321319class QuickCreateTicketDialog(Component): 
    322320    implements(IRequestFilter, IRequestHandler) 
    323321 
    324     # IRequestFilter(Interface): 
     322    # IRequestFilter methods 
    325323 
    326324    def pre_process_request(self, req, handler): 
    327325        """Nothing to do. 
     
    332330        """Append necessary ticket data 
    333331        """ 
    334332        try: 
    335             tm = self._get_ticket_module() 
     333            tm = _get_ticket_module(self.env) 
    336334        except TracError: 
    337335            # no ticket module so no create ticket button 
    338336            return template, data, content_type 
     
    360358        but return plain text suitable for AJAX requests. 
    361359        """ 
    362360        try: 
    363             tm = self._get_ticket_module() 
     361            tm = _get_ticket_module(self.env) 
    364362            req.perm.require('TICKET_CREATE') 
    365363            summary = req.args.pop('field_summary', '') 
    366364            desc = "" 
    367365            attrs = dict([k[6:], v] for k,v in req.args.iteritems() \ 
    368366                                    if k.startswith('field_')) 
    369             ticket_id = self.create(req, summary, desc, attrs, True) 
     367            ticket_id = create(self.env, req, summary, desc, attrs, True) 
    370368        except Exception, exc: 
    371369            self.log.exception("BH: Quick create ticket failed %s" % (exc,)) 
    372             req.send(str(exc), 'plain/text', 500) 
     370            req.send(to_unicode(exc).encode('utf-8'), 'plain/text', 500) 
    373371        else: 
    374             req.send(str(ticket_id), 'plain/text') 
     372            req.send(to_unicode(ticket_id).encode('utf-8'), 'plain/text') 
    375373 
    376     def _get_ticket_module(self): 
    377         ptm = None 
    378         if ProductTicketModule is not None: 
    379             ptm = self.env[ProductTicketModule] 
    380         tm = self.env[TicketModule] 
    381         if not (tm is None) ^ (ptm is None): 
    382             raise TracError('Unable to load TicketModule (disabled)?') 
    383         if tm is None: 
    384             tm = ptm 
    385         return tm 
    386  
    387     # Public API 
    388     def create(self, req, summary, description, attributes = {}, notify=False): 
    389         """ Create a new ticket, returning the ticket ID.  
    390  
    391         PS: Borrowed from XmlRpcPlugin. 
    392         """ 
    393         t = Ticket(self.env) 
    394         t['summary'] = summary 
    395         t['description'] = description 
    396         t['reporter'] = req.authname 
    397         for k, v in attributes.iteritems(): 
    398             t[k] = v 
    399         t['status'] = 'new' 
    400         t['resolution'] = '' 
    401         t.insert() 
    402         # Call ticket change listeners 
    403         ts = TicketSystem(self.env) 
    404         for listener in ts.change_listeners: 
    405             listener.ticket_created(t) 
    406         if notify: 
    407             try: 
    408                 tn = TicketNotifyEmail(self.env) 
    409                 tn.notify(t, newticket=True) 
    410             except Exception, e: 
    411                 self.log.exception("Failure sending notification on creation " 
    412                                    "of ticket #%s: %s" % (t.id, e)) 
    413         return t.id 
    414  
    415  
  • .py

    diff --git a/bhtheme/theme.py b/bhtheme/ticket.py
    copy from bhtheme/theme.py
    copy to bhtheme/ticket.py
    old new  
    1616#  specific language governing permissions and limitations 
    1717#  under the License. 
    1818 
    19 from genshi.builder import tag 
    20 from genshi.filters.transform import Transformer 
     19""" 
     20Ticket operations and related helper functions. 
     21""" 
    2122 
    22 from trac.core import * 
    23 from trac.mimeview.api import get_mimetype 
     23from trac.core import TracError 
    2424from trac.ticket.api import TicketSystem 
    2525from trac.ticket.model import Ticket 
    2626from trac.ticket.notification import TicketNotifyEmail 
    2727from trac.ticket.web_ui import TicketModule 
    28 from trac.util.compat import set 
    29 from trac.util.translation import _ 
    30 from trac.versioncontrol.web_ui.browser import BrowserModule 
    31 from trac.web.api import IRequestFilter, IRequestHandler, ITemplateStreamFilter 
    32 from trac.web.chrome import (add_script, add_stylesheet, INavigationContributor, 
    33                              ITemplateProvider, prevnext_nav) 
    34  
    35 from themeengine.api import ThemeBase, ThemeEngineSystem 
    36  
    37 from bhdashboard.util import dummy_request, load_json 
    38 from bhdashboard.web_ui import DashboardModule 
    39  
    40 from urlparse import urlparse 
    41 from wsgiref.util import setup_testing_defaults 
    42  
    43 json = load_json() 
     28from trac.util.datefmt import to_datetime, to_utimestamp, utc 
    4429 
    4530try: 
    4631    from multiproduct.ticket.web_ui import ProductTicketModule 
    4732except ImportError: 
    4833    ProductTicketModule = None 
    4934 
    50 class BloodhoundTheme(ThemeBase): 
    51     """Look and feel of Bloodhound issue tracker. 
     35def _get_ticket_module(env): 
     36    """Choose ticket module. 
    5237    """ 
    53     template = htdocs = css = screenshot = disable_trac_css = True 
    54     disable_all_trac_css = True 
    55     BLOODHOUND_KEEP_CSS = set( 
    56         ( 
    57             'diff.css', 
    58         ) 
    59     ) 
    60     BLOODHOUND_TEMPLATE_MAP = { 
    61         # Admin 
    62         'admin_basics.html' : ('bh_admin_basics.html', None), 
    63         'admin_components.html' : ('bh_admin_components.html', None), 
    64         'admin_enums.html' : ('bh_admin_enums.html', None), 
    65         'admin_logging.html' : ('bh_admin_logging.html', None), 
    66         'admin_milestones.html' : ('bh_admin_milestones.html', None), 
    67         'admin_perms.html' : ('bh_admin_perms.html', None), 
    68         'admin_plugins.html' : ('bh_admin_plugins.html', None), 
    69         'admin_repositories.html' : ('bh_admin_repositories.html', None), 
    70         'admin_versions.html' : ('bh_admin_versions.html', None), 
    71         'admin_products.html' : ('bh_admin_products.html', None), 
     38    ptm = None 
     39    if ProductTicketModule is not None: 
     40        ptm = env[ProductTicketModule] 
     41    tm = env[TicketModule] 
     42    if not (tm is None) ^ (ptm is None): 
     43        raise TracError('Unable to load TicketModule (disabled)?') 
     44    if tm is None: 
     45        tm = ptm 
     46    return tm 
    7247 
    73         # Preferences 
    74         'prefs_advanced.html' : ('bh_prefs_advanced.html', None), 
    75         'prefs_datetime.html' : ('bh_prefs_datetime.html', None), 
    76         'prefs_general.html' : ('bh_prefs_general.html', None), 
    77         'prefs_keybindings.html' : ('bh_prefs_keybindings.html', None), 
    78         'prefs_pygments.html' : ('bh_prefs_pygments.html', None), 
     48#------------------------- 
     49#   Ticket operations 
     50#------------------------- 
    7951 
    80         # Search 
    81         'search.html' : ('bh_search.html', '_modify_search_data'), 
     52# Disclaimer : Most of the code included in this section is almost a  
     53# verbatim copy of XmlRpcPlugin ticket handler. If this plugin is  
     54# incorporated later as part of default Bloodhound installation then we better 
     55# use it directly. 
    8256 
    83         # Wiki 
    84         'wiki_delete.html' : ('bh_wiki_delete.html', None), 
    85         'wiki_diff.html' : ('bh_wiki_diff.html', None), 
    86         'wiki_edit.html' : ('bh_wiki_edit.html', None), 
    87         'wiki_rename.html' : ('bh_wiki_rename.html', None), 
    88         'wiki_view.html' : ('bh_wiki_view.html', '_modify_wiki_page_path'), 
     57# XmlRpcPlugin version 1.1.2 @ r12099 
     58# Patch applied : http://trac-hacks.org/ticket/9921#comment:1 
    8959 
    90         # Ticket 
    91         'milestone_edit.html' : ('bh_milestone_edit.html', None), 
    92         'milestone_delete.html' : ('bh_milestone_delete.html', None), 
    93         'milestone_view.html' : ('bh_milestone_view.html', '_modify_roadmap_css'), 
    94         'query.html' : ('bh_query.html', None), 
    95         'report_delete.html' : ('bh_report_delete.html', None), 
    96         'report_edit.html' : ('bh_report_edit.html', None),  
    97         'report_list.html' : ('bh_report_list.html', None), 
    98         'report_view.html' : ('bh_report_view.html', None), 
    99         'ticket.html' : ('bh_ticket.html', '_modify_editable'), 
    100         'ticket_preview.html' : ('bh_ticket_preview.html', None), 
     60def create(env, req, summary, description, attributes={}, notify=False,  
     61        when=None): 
     62    """  
     63    Create a new ticket, returning the ticket ID. 
     64    Overriding 'when' requires admin permission.  
     65    """ 
     66    t = Ticket(env) 
     67    t['summary'] = summary 
     68    t['description'] = description 
     69    t['reporter'] = req.authname 
     70    for k, v in attributes.iteritems(): 
     71        t[k] = v 
     72    t['status'] = 'new' 
     73    t['resolution'] = '' 
     74    # custom create timestamp? 
     75    if when and not 'TICKET_ADMIN' in req.perm: 
     76        env.log.warn("RPC ticket.create: %r not allowed to create with " 
     77                "non-current timestamp (%r)", req.authname, when) 
     78        when = None 
     79    t.insert(when=when) 
     80    if notify: 
     81        try: 
     82            tn = TicketNotifyEmail(env) 
     83            tn.notify(t, newticket=True) 
     84        except Exception, e: 
     85            env.log.exception("Failure sending notification on creation " 
     86                               "of ticket #%s: %s" % (t.id, e)) 
     87    return t.id 
    10188 
    102         # Multi Product 
    103         'product_view.html' : ('bh_product_view.html', None), 
     89def get(env, req, id): 
     90    """ 
     91    Fetch a ticket. Returns [id, time_created, time_changed, attributes]. 
     92    """ 
     93    t = Ticket(env, id) 
     94    req.perm(t.resource).require('TICKET_VIEW') 
     95    t['_ts'] = str(t.time_changed) 
     96    return (t.id, t.time_created, t.time_changed, t.values) 
    10497 
    105         # General purpose 
    106         'history_view.html' : ('bh_history_view.html', None), 
    107     } 
    108     BOOTSTRAP_CSS_DEFAULTS = ( 
    109         # ('XPath expression', ['default', 'bootstrap', 'css', 'classes']) 
    110         ("body//table[not(contains(@class, 'table'))]", # TODO: Accurate ? 
    111                 ['table', 'table-condensed']), 
    112     ) 
    113  
    114     implements(IRequestFilter, INavigationContributor, ITemplateProvider, 
    115                ITemplateStreamFilter) 
    116  
    117     # ITemplateStreamFilter methods 
    118  
    119     def filter_stream(self, req, method, filename, stream, data): 
    120         """Insert default Bootstrap CSS classes if rendering  
    121         legacy templates (i.e. determined by template name prefix). 
    122         """ 
    123         tx = Transformer('body') 
    124  
    125         def add_classes(classes): 
    126             """Return a function ensuring CSS classes will be there for element. 
    127             """ 
    128             def attr_modifier(name, event): 
    129                 attrs = event[1][1] 
    130                 class_list = attrs.get(name, '').split() 
    131                 self.log.debug('BH Theme : Element classes ' + str(class_list)) 
    132  
    133                 out_classes = ' '.join(set(class_list + classes)) 
    134                 self.log.debug('BH Theme : Inserting class ' + out_classes) 
    135                 return out_classes 
    136             return attr_modifier 
    137          
    138         # Insert default bootstrap CSS classes if necessary 
    139         for xpath, classes in self.BOOTSTRAP_CSS_DEFAULTS : 
    140             tx = tx.end().select(xpath) \ 
    141                     .attr('class', add_classes(classes)) 
    142         return stream | tx 
    143  
    144     # IRequestFilter methods 
    145  
    146     def pre_process_request(self, req, handler): 
    147         """Pre process request filter""" 
    148         return handler 
    149  
    150     def post_process_request(self, req, template, data, content_type): 
    151         """Post process request filter. 
    152         Removes all trac provided css if required""" 
    153         def is_active_theme(): 
    154             is_active = False 
    155             active_theme = ThemeEngineSystem(self.env).theme 
    156             if active_theme is not None: 
    157                 this_theme_name = self.get_theme_names().next() 
    158                 is_active = active_theme['name'] == this_theme_name 
    159             return is_active 
    160  
    161         links = req.chrome.get('links',{}) 
    162         # replace favicon if appropriate 
    163         if self.env.project_icon == 'common/trac.ico': 
    164             bh_icon = 'theme/img/bh.ico' 
    165             new_icon = {'href': req.href.chrome(bh_icon), 
    166                         'type': get_mimetype(bh_icon)} 
    167             if links.get('icon'): 
    168                 links.get('icon')[0].update(new_icon) 
    169             if links.get('shortcut icon'): 
    170                 links.get('shortcut icon')[0].update(new_icon) 
    171          
    172         is_active_theme = is_active_theme() 
    173         if self.disable_all_trac_css and is_active_theme: 
    174             if self.disable_all_trac_css: 
    175                 stylesheets = links.get('stylesheet',[]) 
    176                 if stylesheets: 
    177                     path = req.base_path + '/chrome/common/css/' 
    178                     _iter = ([ss, ss.get('href', '')] for ss in stylesheets) 
    179                     links['stylesheet'] = [ss for ss, href in _iter  
    180                             if not href.startswith(path) or 
    181                             href.rsplit('/', 1)[-1] in self.BLOODHOUND_KEEP_CSS] 
    182             template, modifier = self.BLOODHOUND_TEMPLATE_MAP.get( 
    183                     template, (template, None)) 
    184             if modifier is not None: 
    185                 modifier = getattr(self, modifier) 
    186                 modifier(req, template, data, content_type, is_active_theme) 
    187         return template, data, content_type 
    188  
    189     # ITemplateProvider methods 
    190  
    191     def get_htdocs_dirs(self): 
    192         """Ensure dashboard htdocs will be there even if 
    193         `bhdashboard.web_ui.DashboardModule` is disabled. 
    194         """ 
    195         if not self.env.is_component_enabled(DashboardModule): 
    196             return DashboardModule(self.env).get_htdocs_dirs() 
    197  
    198     def get_templates_dirs(self): 
    199         """Ensure dashboard templates will be there even if 
    200         `bhdashboard.web_ui.DashboardModule` is disabled. 
    201         """ 
    202         if not self.env.is_component_enabled(DashboardModule): 
    203             return DashboardModule(self.env).get_templates_dirs() 
    204  
    205     # Request modifiers 
    206  
    207     def _modify_search_data(self, req, template, data, content_type, is_active): 
    208         """Insert breadcumbs and context navigation items in search web UI 
    209         """ 
    210         if is_active: 
    211             # Insert query string in search box (see bloodhound_theme.html) 
    212             req.search_query = data.get('query') 
    213             # Breadcrumbs nav 
    214             data['resourcepath_template'] = 'bh_path_search.html' 
    215             # Context nav 
    216             prevnext_nav(req, _('Previous'), _('Next')) 
    217  
    218     def _modify_wiki_page_path(self, req, template, data, content_type, is_active): 
    219         """Override wiki breadcrumbs nav items 
    220         """ 
    221         if is_active: 
    222             data['resourcepath_template'] = 'bh_path_wikipage.html' 
    223  
    224     def _modify_roadmap_css(self, req, template, data, content_type, is_active): 
    225         """Insert roadmap.css 
    226         """ 
    227         add_stylesheet(req, 'dashboard/css/roadmap.css') 
    228  
    229     def _modify_editable(self, req, template, data, content_type, is_active): 
    230         """Insert data needed for inplace edit 
    231         """ 
    232         add_script(req, 'dashboard/js/jquery.json.js') 
    233         add_script(req, 'dashboard/js/jquery.jeditable.js') 
    234         add_script(req, 'dashboard/js/bheditable.js') 
    235         json = load_json() 
    236         if data is not None: 
    237             data['json'] = {'dumps': json.dumps, 'loads':json.loads} 
    238             data['edit_data'] = dict([f['name'], self.field_edit_data(f, data)]  
    239                     for f in data.get('fields', [])) 
    240  
    241     # INavigationContributor methods 
    242  
    243     def get_active_navigation_item(self, req): 
    244         return 
    245  
    246     def get_navigation_items(self, req): 
    247         if 'BROWSER_VIEW' in req.perm and 'VERSIONCONTROL_ADMIN' in req.perm: 
    248             bm = self.env[BrowserModule] 
    249             if bm and not list(bm.get_navigation_items(req)): 
    250                 yield ('mainnav', 'browser',  
    251                        tag.a(_('Browse Source'), 
    252                              href=req.href.wiki('TracRepositoryAdmin'))) 
    253  
    254     # Public API and helper methods 
    255  
    256     EDIT_DEFAULTS = { 
    257             'submit' : tag.button(tag.i(class_='icon-ok'), 
    258                     class_='btn', title='Update'), 
    259             'cancel' : tag.button(_('Cancel'), class_='btn-link'), 
    260         } 
    261  
    262     def field_edit_data(self, field, data): 
    263         """Attributes used to install/trigger jEditable inplace editor for a  
    264         ticket field 
    265         """ 
    266         ticket = data.get('ticket') 
    267         if ticket: 
    268             value = ticket.get_value_or_default(field['name']) 
    269         else: 
    270             value = None 
    271         if field['type'] == 'select': 
    272             options = field.get('options', []) 
    273             if field['optional']: 
    274                 options = [''] + options 
    275             optgroups = [g for g in field.get('optgroups', []) if g['options']] 
    276             return { 
    277                     'data-editdata' : 'javascript:select_value', 
    278                     'data-editselopts' : json.dumps( 
    279                             { 
    280                                 'options' : options, 
    281                                 'optgroups' : optgroups, 
    282                             }), 
    283                     'data-edittype' : 'bhselect', 
    284                 } 
    285         elif field['type'] == 'text': 
    286             return { 
    287                     'data-editsubmit' : unicode(self.EDIT_DEFAULTS['submit']), 
    288                     'data-edittype' : 'bhtext', 
    289                     'data-editheight': 'none', 
    290                     'data-editwidth': 'none', 
    291                     'data-editcssclass' : 'inplace input-append', 
    292                 } 
    293         elif field['type'] == 'textarea': 
    294             return { 
    295                     'data-editsubmit' :  
    296                             unicode(self.EDIT_DEFAULTS['submit']), 
    297                     'data-editcancel' :  
    298                             unicode(self.EDIT_DEFAULTS['cancel']), 
    299                     'data-edittype' : 'bhwiki' if field.get('format') == 'wiki' 
    300                             else 'textarea', 
    301                     'data-editcols': field.get('width'), 
    302                     'data-editrows': field.get('height'), 
    303                 } 
    304         elif field['type'] == 'checkbox': 
    305             return { 
    306                     'data-editdata' : 'javascript:checkbox_value', 
    307                     'data-edittype' : 'checkbox', 
    308                     'data-editnofocus' : 'true', 
    309                 } 
    310         elif field['type'] == 'radio': 
    311             return { 
    312                     'data-editdata' : 'javascript:select_value', 
    313                     'data-editselopts' : json.dumps( 
    314                             { 
    315                                 'options' : field.get('options', []), 
    316                             }), 
    317                     'data-edittype' : 'bhradio', 
    318                 } 
    319         return None 
    320  
    321 class QuickCreateTicketDialog(Component): 
    322     implements(IRequestFilter, IRequestHandler) 
    323  
    324     # IRequestFilter(Interface): 
    325  
    326     def pre_process_request(self, req, handler): 
    327         """Nothing to do. 
    328         """ 
    329         return handler 
    330  
    331     def post_process_request(self, req, template, data, content_type): 
    332         """Append necessary ticket data 
    333         """ 
    334         try: 
    335             tm = self._get_ticket_module() 
    336         except TracError: 
    337             # no ticket module so no create ticket button 
    338             return template, data, content_type 
    339  
    340         if (template, data, content_type) != (None,) * 3: # TODO: Check ! 
    341             if data is None: 
    342                 data = {} 
    343             fakereq = dummy_request(self.env) 
    344             ticket = Ticket(self.env) 
    345             tm._populate(fakereq, ticket, False) 
    346             fields = dict([f['name'], f] \ 
    347                         for f in tm._prepare_fields(fakereq, ticket)) 
    348             data['qct'] = { 'fields' : fields } 
    349         return template, data, content_type 
    350  
    351     # IRequestHandler methods 
    352  
    353     def match_request(self, req): 
    354         """Handle requests sent to /qct 
    355         """ 
    356         return req.path_info == '/qct' 
    357  
    358     def process_request(self, req): 
    359         """Forward new ticket request to `trac.ticket.web_ui.TicketModule` 
    360         but return plain text suitable for AJAX requests. 
    361         """ 
    362         try: 
    363             tm = self._get_ticket_module() 
    364             req.perm.require('TICKET_CREATE') 
    365             summary = req.args.pop('field_summary', '') 
    366             desc = "" 
    367             attrs = dict([k[6:], v] for k,v in req.args.iteritems() \ 
    368                                     if k.startswith('field_')) 
    369             ticket_id = self.create(req, summary, desc, attrs, True) 
    370         except Exception, exc: 
    371             self.log.exception("BH: Quick create ticket failed %s" % (exc,)) 
    372             req.send(str(exc), 'plain/text', 500) 
    373         else: 
    374             req.send(str(ticket_id), 'plain/text') 
    375  
    376     def _get_ticket_module(self): 
    377         ptm = None 
    378         if ProductTicketModule is not None: 
    379             ptm = self.env[ProductTicketModule] 
    380         tm = self.env[TicketModule] 
    381         if not (tm is None) ^ (ptm is None): 
    382             raise TracError('Unable to load TicketModule (disabled)?') 
    383         if tm is None: 
    384             tm = ptm 
    385         return tm 
    386  
    387     # Public API 
    388     def create(self, req, summary, description, attributes = {}, notify=False): 
    389         """ Create a new ticket, returning the ticket ID.  
    390  
    391         PS: Borrowed from XmlRpcPlugin. 
    392         """ 
    393         t = Ticket(self.env) 
    394         t['summary'] = summary 
    395         t['description'] = description 
    396         t['reporter'] = req.authname 
     98def update(env, req, id, comment, attributes={}, notify=False,  
     99        author='', when=None): 
     100    """ 
     101    Update a ticket, returning the new ticket in the same form as 
     102    get(). 'New-style' call requires two additional items in attributes: 
     103    (1) 'action' for workflow support (including any supporting fields 
     104    as retrieved by getActions()), 
     105    (2) '_ts' changetime token for detecting update collisions (as received 
     106    from get() or update() calls). 
     107    ''Calling update without 'action' and '_ts' changetime token is 
     108    deprecated, and will raise errors in a future version.''  
     109    """ 
     110    t = Ticket(env, id) 
     111    # custom author? 
     112    if author and not (req.authname == 'anonymous' \ 
     113                        or 'TICKET_ADMIN' in req.perm(t.resource)): 
     114        # only allow custom author if anonymous is permitted or user is admin 
     115        env.log.warn("RPC ticket.update: %r not allowed to change author " 
     116                "to %r for comment on #%d", req.authname, author, id) 
     117        author = '' 
     118    author = author or req.authname 
     119    # custom change timestamp? 
     120    if when and not 'TICKET_ADMIN' in req.perm(t.resource): 
     121        env.log.warn("RPC ticket.update: %r not allowed to update #%d with " 
     122                "non-current timestamp (%r)", author, id, when) 
     123        when = None 
     124    when = when or to_datetime(None, utc) 
     125    # and action... 
     126    if not 'action' in attributes: 
     127        # FIXME: Old, non-restricted update - remove soon! 
     128        env.log.warning("Rpc ticket.update for ticket %d by user %s " \ 
     129                "has no workflow 'action'." % (id, req.authname)) 
     130        req.perm(t.resource).require('TICKET_MODIFY') 
     131        time_changed = attributes.pop('_ts', None) 
     132        if time_changed and str(time_changed) != str(t.time_changed): 
     133            raise TracError("Ticket has been updated since last get().") 
    397134        for k, v in attributes.iteritems(): 
    398135            t[k] = v 
    399         t['status'] = 'new' 
    400         t['resolution'] = '' 
    401         t.insert() 
    402         # Call ticket change listeners 
    403         ts = TicketSystem(self.env) 
    404         for listener in ts.change_listeners: 
    405             listener.ticket_created(t) 
    406         if notify: 
    407             try: 
    408                 tn = TicketNotifyEmail(self.env) 
    409                 tn.notify(t, newticket=True) 
    410             except Exception, e: 
    411                 self.log.exception("Failure sending notification on creation " 
    412                                    "of ticket #%s: %s" % (t.id, e)) 
    413         return t.id 
     136        t.save_changes(author, comment, when=when) 
     137    else: 
     138        ts = TicketSystem(env) 
     139        tm = TicketModule(env) 
     140        # TODO: Deprecate update without time_changed timestamp 
     141        time_changed = str(attributes.pop('_ts', t.time_changed)) 
     142        action = attributes.get('action') 
     143        avail_actions = ts.get_available_actions(req, t) 
     144        if not action in avail_actions: 
     145            raise TracError("Rpc: Ticket %d by %s " \ 
     146                    "invalid action '%s'" % (id, req.authname, action)) 
     147        controllers = list(tm._get_action_controllers(req, t, action)) 
     148        all_fields = [field['name'] for field in ts.get_ticket_fields()] 
     149        for k, v in attributes.iteritems(): 
     150            if k in all_fields and k != 'status': 
     151                t[k] = v 
     152        # TicketModule reads req.args - need to move things there... 
     153        req.args.update(attributes) 
     154        req.args['comment'] = comment 
     155        req.args['ts'] = time_changed 
     156        changes, problems = tm.get_ticket_changes(req, t, action) 
     157        # 0.13 timestamp compat, see trac:ticket:7145#comment:50 
     158        if time_changed == str(t.time_changed): 
     159            # same -> remake the required timestamp 
     160            req.args['view_time'] = str(to_utimestamp(t.time_changed)) 
     161        else: 
     162            # collision -> make it valid but different 
     163            req.args['view_time'] = '1' 
     164        for warning in problems: 
     165            add_warning(req, "Rpc ticket.update: %s" % warning) 
     166        valid = problems and False or tm._validate_ticket(req, t) 
     167        if not valid: 
     168            raise TracError( 
     169                " ".join([warning for warning in req.chrome['warnings']])) 
     170        else: 
     171            tm._apply_ticket_changes(t, changes) 
     172            env.log.debug("Rpc ticket.update save: %s" % repr(t.values)) 
     173            t.save_changes(author, comment, when=when) 
     174            # Apply workflow side-effects 
     175            for controller in controllers: 
     176                controller.apply_action_side_effects(req, t, action) 
     177    if notify: 
     178        try: 
     179            tn = TicketNotifyEmail(env) 
     180            tn.notify(t, newticket=False, modtime=when) 
     181        except Exception, e: 
     182            env.log.exception("Failure sending notification on change of " 
     183                               "ticket #%s: %s" % (t.id, e)) 
     184    return get(env, req, t.id) 
    414185 
    415186 
  • setup.py

    diff --git a/setup.py b/setup.py
    a b  
    3737  entry_points = { 
    3838      'trac.plugins': [ 
    3939            'bhtheme.theme = bhtheme.theme', 
     40            'bhtheme.editable = bhtheme.editable', 
    4041        ]} 
    4142)