1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2003-2021 Edgewall Software
4# Copyright (C) 2003-2005 Jonas Borgström <jonas@edgewall.com>
5# All rights reserved.
6#
7# This software is licensed as described in the file COPYING, which
8# you should have received as part of this distribution. The terms
9# are also available at https://trac.edgewall.org/wiki/TracLicense.
10#
11# This software consists of voluntary contributions made by many
12# individuals. For the exact contribution history, see the revision
13# history and logs, available at https://trac.edgewall.org/log/.
14#
15# Author: Jonas Borgström <jonas@edgewall.com>
16
17import contextlib
18import copy
19import re
20from datetime import datetime
21
22from trac.cache import cached
23from trac.config import (
24    BoolOption, ConfigSection, IntOption, ListOption, Option,
25    OrderedExtensionsOption)
26from trac.core import *
27from trac.perm import IPermissionRequestor, PermissionCache, PermissionSystem
28from trac.resource import IResourceManager
29from trac.util import Ranges, as_bool, as_int
30from trac.util.datefmt import parse_date, user_time
31from trac.util.html import tag
32from trac.util.text import shorten_line, to_unicode
33from trac.util.translation import _, N_, deactivate, gettext, reactivate
34from trac.wiki import IWikiSyntaxProvider, WikiParser
35
36
37class TicketFieldList(list):
38    """Improved ticket field list, allowing access by name."""
39    __slots__ = ['_map']
40
41    def __init__(self, *args):
42        super().__init__(*args)
43        self._map = {value['name']: value for value in self}
44
45    def append(self, value):
46        super().append(value)
47        self._map[value['name']] = value
48
49    def by_name(self, name, default=None):
50        return self._map.get(name, default)
51
52    def __copy__(self):
53        return TicketFieldList(self)
54
55    def __deepcopy__(self, memo):
56        return TicketFieldList(copy.deepcopy(value, memo) for value in self)
57
58    def __contains__(self, name):
59        return name in self._map
60
61
62class ITicketActionController(Interface):
63    """Extension point interface for components willing to participate
64    in the ticket workflow.
65
66    This is mainly about controlling the changes to the ticket ''status'',
67    though not restricted to it.
68    """
69
70    def get_ticket_actions(req, ticket):
71        """Return an iterable of `(weight, action)` tuples corresponding to
72        the actions that are contributed by this component. The list is
73        dependent on the current state of the ticket and the actual request
74        parameter.
75
76        `action` is a key used to identify that particular action.
77        (note that 'history' and 'diff' are reserved and should not be used
78        by plugins)
79
80        The actions will be presented on the page in descending order of the
81        integer weight. The first action in the list is used as the default
82        action.
83
84        When in doubt, use a weight of 0.
85        """
86
87    def get_all_status():
88        """Returns an iterable of all the possible values for the ''status''
89        field this action controller knows about.
90
91        This will be used to populate the query options and the like.
92        It is assumed that the terminal status of a ticket is 'closed'.
93        """
94
95    def render_ticket_action_control(req, ticket, action):
96        """Return a tuple in the form of `(label, control, hint)`
97
98        `label` is a short text that will be used when listing the action,
99        `control` is the markup for the action control and `hint` should
100        explain what will happen if this action is taken.
101
102        This method will only be called if the controller claimed to handle
103        the given `action` in the call to `get_ticket_actions`.
104
105        Note that the radio button for the action has an `id` of
106        `"action_%s" % action`.  Any `id`s used in `control` need to be made
107        unique.  The method used in the default ITicketActionController is to
108        use `"action_%s_something" % action`.
109        """
110
111    def get_ticket_changes(req, ticket, action):
112        """Return a dictionary of ticket field changes.
113
114        This method must not have any side-effects because it will also
115        be called in preview mode (`req.args['preview']` will be set, then).
116        See `apply_action_side_effects` for that. If the latter indeed triggers
117        some side-effects, it is advised to emit a warning
118        (`trac.web.chrome.add_warning(req, reason)`) when this method is called
119        in preview mode.
120
121        This method will only be called if the controller claimed to handle
122        the given `action` in the call to `get_ticket_actions`.
123        """
124
125    def apply_action_side_effects(req, ticket, action):
126        """Perform side effects once all changes have been made to the ticket.
127
128        Multiple controllers might be involved, so the apply side-effects
129        offers a chance to trigger a side-effect based on the given `action`
130        after the new state of the ticket has been saved.
131
132        This method will only be called if the controller claimed to handle
133        the given `action` in the call to `get_ticket_actions`.
134        """
135
136
137class ITicketChangeListener(Interface):
138    """Extension point interface for components that require notification
139    when tickets are created, modified, or deleted."""
140
141    def ticket_created(ticket):
142        """Called when a ticket is created."""
143
144    def ticket_changed(ticket, comment, author, old_values):
145        """Called when a ticket is modified.
146
147        `old_values` is a dictionary containing the previous values of the
148        fields that have changed.
149        """
150
151    def ticket_deleted(ticket):
152        """Called when a ticket is deleted."""
153
154    def ticket_comment_modified(ticket, cdate, author, comment, old_comment):
155        """Called when a ticket comment is modified."""
156
157    def ticket_change_deleted(ticket, cdate, changes):
158        """Called when a ticket change is deleted.
159
160        `changes` is a dictionary of tuple `(oldvalue, newvalue)`
161        containing the ticket change of the fields that have changed."""
162
163
164class ITicketManipulator(Interface):
165    """Miscellaneous manipulation of ticket workflow features."""
166
167    def prepare_ticket(req, ticket, fields, actions):
168        """Not currently called, but should be provided for future
169        compatibility."""
170
171    def validate_ticket(req, ticket):
172        """Validate ticket properties when creating or modifying.
173
174        Must return a list of `(field, message)` tuples, one for each problem
175        detected. `field` can be `None` to indicate an overall problem with the
176        ticket. Therefore, a return value of `[]` means everything is OK."""
177
178    def validate_comment(req, comment):
179        """Validate ticket comment when appending or editing.
180
181        Must return a list of messages, one for each problem detected.
182        The return value `[]` indicates no problems.
183
184        :since: 1.3.2
185        """
186
187
188class IMilestoneChangeListener(Interface):
189    """Extension point interface for components that require notification
190    when milestones are created, modified, or deleted."""
191
192    def milestone_created(milestone):
193        """Called when a milestone is created."""
194
195    def milestone_changed(milestone, old_values):
196        """Called when a milestone is modified.
197
198        `old_values` is a dictionary containing the previous values of the
199        milestone properties that changed. Currently those properties can be
200        'name', 'due', 'completed', or 'description'.
201        """
202
203    def milestone_deleted(milestone):
204        """Called when a milestone is deleted."""
205
206
207class TicketSystem(Component):
208    implements(IPermissionRequestor, IWikiSyntaxProvider, IResourceManager,
209               ITicketManipulator)
210
211    change_listeners = ExtensionPoint(ITicketChangeListener)
212    milestone_change_listeners = ExtensionPoint(IMilestoneChangeListener)
213
214    realm = 'ticket'
215
216    ticket_custom_section = ConfigSection('ticket-custom',
217        """In this section, you can define additional fields for tickets. See
218        TracTicketsCustomFields for more details.""")
219
220    action_controllers = OrderedExtensionsOption('ticket', 'workflow',
221        ITicketActionController, default='ConfigurableTicketWorkflow',
222        include_missing=False,
223        doc="""Ordered list of workflow controllers to use for ticket actions.
224            """)
225
226    restrict_owner = BoolOption('ticket', 'restrict_owner', 'false',
227        """Make the owner field of tickets use a drop-down menu.
228        Be sure to understand the performance implications before activating
229        this option. See
230        [TracTickets#Assign-toasDrop-DownList Assign-to as Drop-Down List].
231
232        Please note that e-mail addresses are '''not''' obfuscated in the
233        resulting drop-down menu, so this option should not be used if
234        e-mail addresses must remain protected.
235        """)
236
237    default_version = Option('ticket', 'default_version', '',
238        """Default version for newly created tickets.""")
239
240    default_type = Option('ticket', 'default_type', 'defect',
241        """Default type for newly created tickets.""")
242
243    default_priority = Option('ticket', 'default_priority', 'major',
244        """Default priority for newly created tickets.""")
245
246    default_milestone = Option('ticket', 'default_milestone', '',
247        """Default milestone for newly created tickets.""")
248
249    default_component = Option('ticket', 'default_component', '',
250        """Default component for newly created tickets.""")
251
252    default_severity = Option('ticket', 'default_severity', '',
253        """Default severity for newly created tickets.""")
254
255    default_summary = Option('ticket', 'default_summary', '',
256        """Default summary (title) for newly created tickets.""")
257
258    default_description = Option('ticket', 'default_description', '',
259        """Default description for newly created tickets.""")
260
261    default_keywords = Option('ticket', 'default_keywords', '',
262        """Default keywords for newly created tickets.""")
263
264    default_owner = Option('ticket', 'default_owner', '< default >',
265        """Default owner for newly created tickets. The component owner
266        is used when set to the value `< default >`.
267        """)
268
269    default_cc = Option('ticket', 'default_cc', '',
270        """Default cc: list for newly created tickets.""")
271
272    default_resolution = Option('ticket', 'default_resolution', 'fixed',
273        """Default resolution for resolving (closing) tickets.""")
274
275    allowed_empty_fields = ListOption('ticket', 'allowed_empty_fields',
276        'milestone, version', doc=
277        """Comma-separated list of `select` fields that can have
278        an empty value. (//since 1.1.2//)""")
279
280    max_comment_size = IntOption('ticket', 'max_comment_size', 262144,
281        """Maximum allowed comment size in characters.""")
282
283    max_description_size = IntOption('ticket', 'max_description_size', 262144,
284        """Maximum allowed description size in characters.""")
285
286    max_summary_size = IntOption('ticket', 'max_summary_size', 262144,
287        """Maximum allowed summary size in characters. (//since 1.0.2//)""")
288
289    def __init__(self):
290        self.log.debug('action controllers for ticket workflow: %r',
291                       [c.__class__.__name__ for c in self.action_controllers])
292
293    # Public API
294
295    def get_available_actions(self, req, ticket):
296        """Returns a sorted list of available actions"""
297        # The list should not have duplicates.
298        actions = {}
299        for controller in self.action_controllers:
300            weighted_actions = controller.get_ticket_actions(req, ticket) or []
301            for weight, action in weighted_actions:
302                if action in actions:
303                    actions[action] = max(actions[action], weight)
304                else:
305                    actions[action] = weight
306        all_weighted_actions = [(weight, action) for action, weight
307                                                 in actions.items()]
308        return [x[1] for x in sorted(all_weighted_actions, reverse=True)]
309
310    def get_all_status(self):
311        """Returns a sorted list of all the states all of the action
312        controllers know about."""
313        valid_states = set()
314        for controller in self.action_controllers:
315            valid_states.update(controller.get_all_status() or [])
316        return sorted(valid_states)
317
318    def get_ticket_field_labels(self):
319        """Produce a (name,label) mapping from `get_ticket_fields`."""
320        labels = {f['name']: f['label'] for f in self.get_ticket_fields()}
321        labels['attachment'] = _("Attachment")
322        return labels
323
324    def get_ticket_fields(self):
325        """Returns list of fields available for tickets.
326
327        Each field is a dict with at least the 'name', 'label' (localized)
328        and 'type' keys.
329        It may in addition contain the 'custom' key, the 'optional' and the
330        'options' keys. When present 'custom' and 'optional' are always `True`.
331        """
332        fields = copy.deepcopy(self.fields)
333        label = 'label' # workaround gettext extraction bug
334        for f in fields:
335            if not f.get('custom'):
336                f[label] = gettext(f[label])
337        return fields
338
339    def reset_ticket_fields(self):
340        """Invalidate ticket field cache."""
341        del self.fields
342
343    @cached
344    def fields(self):
345        """Return the list of fields available for tickets."""
346        from trac.ticket import model
347
348        fields = TicketFieldList()
349
350        # Basic text fields
351        fields.append({'name': 'summary', 'type': 'text',
352                       'label': N_('Summary')})
353        fields.append({'name': 'reporter', 'type': 'text',
354                       'label': N_('Reporter')})
355
356        # Owner field, by default text but can be changed dynamically
357        # into a drop-down depending on configuration (restrict_owner=true)
358        fields.append({'name': 'owner', 'type': 'text',
359                       'label': N_('Owner')})
360
361        # Description
362        fields.append({'name': 'description', 'type': 'textarea',
363                       'format': 'wiki', 'label': N_('Description')})
364
365        # Default select and radio fields
366        selects = [('type', N_('Type'), model.Type),
367                   ('status', N_('Status'), model.Status),
368                   ('priority', N_('Priority'), model.Priority),
369                   ('milestone', N_('Milestone'), model.Milestone),
370                   ('component', N_('Component'), model.Component),
371                   ('version', N_('Version'), model.Version),
372                   ('severity', N_('Severity'), model.Severity),
373                   ('resolution', N_('Resolution'), model.Resolution)]
374        for name, label, cls in selects:
375            options = [val.name for val in cls.select(self.env)]
376            if not options:
377                # Fields without possible values are treated as if they didn't
378                # exist
379                continue
380            field = {'name': name, 'type': 'select', 'label': label,
381                     'value': getattr(self, 'default_' + name, ''),
382                     'options': options}
383            if name in ('status', 'resolution'):
384                field['type'] = 'radio'
385                field['optional'] = True
386            elif name in self.allowed_empty_fields:
387                field['optional'] = True
388            fields.append(field)
389
390        # Advanced text fields
391        fields.append({'name': 'keywords', 'type': 'text', 'format': 'list',
392                       'label': N_('Keywords')})
393        fields.append({'name': 'cc', 'type': 'text',  'format': 'list',
394                       'label': N_('Cc')})
395
396        # Date/time fields
397        fields.append({'name': 'time', 'type': 'time',
398                       'format': 'relative', 'label': N_('Created')})
399        fields.append({'name': 'changetime', 'type': 'time',
400                       'format': 'relative', 'label': N_('Modified')})
401
402        for field in self.custom_fields:
403            if field['name'] in [f['name'] for f in fields]:
404                self.log.warning('Duplicate field name "%s" (ignoring)',
405                                 field['name'])
406                continue
407            fields.append(field)
408
409        return fields
410
411    reserved_field_names = ['report', 'order', 'desc', 'group', 'groupdesc',
412                            'col', 'row', 'format', 'max', 'page', 'verbose',
413                            'comment', 'or', 'id', 'time', 'changetime',
414                            'owner', 'reporter', 'cc', 'summary',
415                            'description', 'keywords']
416
417    def get_custom_fields(self):
418        return copy.deepcopy(self.custom_fields)
419
420    @cached
421    def custom_fields(self):
422        """Return the list of custom ticket fields available for tickets."""
423        fields = TicketFieldList()
424        config = self.ticket_custom_section
425        for name in [option for option, value in config.options()
426                     if '.' not in option]:
427            field = {
428                'name': name,
429                'custom': True,
430                'type': config.get(name),
431                'order': config.getint(name + '.order', 0),
432                'label': config.get(name + '.label') or
433                         name.replace("_", " ").strip().capitalize(),
434                'value': config.get(name + '.value', '')
435            }
436
437            def _get_ticketlink_query():
438                field['ticketlink_query'] = \
439                        config.get(name + '.ticketlink_query', None)
440
441            if field['type'] == 'select' or field['type'] == 'radio':
442                field['options'] = config.getlist(name + '.options', sep='|')
443                if not field['options']:
444                    continue
445                if '' in field['options'] or \
446                        field['name'] in self.allowed_empty_fields:
447                    field['optional'] = True
448                    if '' in field['options']:
449                        field['options'].remove('')
450                _get_ticketlink_query()
451            elif field['type'] == 'checkbox':
452                field['value'] = '1' if as_bool(field['value']) else '0'
453                _get_ticketlink_query()
454            elif field['type'] == 'text':
455                field['format'] = config.get(name + '.format', 'plain')
456                field['max_size'] = config.getint(name + '.max_size', 0)
457                if field['format'] in ('reference', 'list'):
458                    _get_ticketlink_query()
459            elif field['type'] == 'textarea':
460                field['format'] = config.get(name + '.format', 'plain')
461                field['max_size'] = config.getint(name + '.max_size', 0)
462                field['height'] = config.getint(name + '.rows')
463            elif field['type'] == 'time':
464                field['format'] = config.get(name + '.format', 'datetime')
465
466            if field['name'] in self.reserved_field_names:
467                self.log.warning('Field name "%s" is a reserved name '
468                                 '(ignoring)', field['name'])
469                continue
470            if not re.match('^[a-zA-Z][a-zA-Z0-9_]+$', field['name']):
471                self.log.warning('Invalid name for custom field: "%s" '
472                                 '(ignoring)', field['name'])
473                continue
474
475            fields.append(field)
476
477        fields.sort(key=lambda f: (f['order'], f['name']))
478        return fields
479
480    def get_field_synonyms(self):
481        """Return a mapping from field name synonyms to field names.
482        The synonyms are supposed to be more intuitive for custom queries."""
483        # i18n TODO - translated keys
484        return {'created': 'time', 'modified': 'changetime'}
485
486    def eventually_restrict_owner(self, field, ticket=None):
487        """Restrict given owner field to be a list of users having
488        the TICKET_MODIFY permission (for the given ticket)
489        """
490        if self.restrict_owner:
491            field['type'] = 'select'
492            field['options'] = self.get_allowed_owners(ticket)
493            field['optional'] = True
494
495    def get_allowed_owners(self, ticket=None):
496        """Returns a list of permitted ticket owners (those possessing the
497        TICKET_MODIFY permission). Returns `None` if the option `[ticket]`
498        `restrict_owner` is `False`.
499
500        If `ticket` is not `None`, fine-grained permission checks are used
501        to determine the allowed owners for the specified resource.
502
503        :since: 1.0.3
504        """
505        if self.restrict_owner:
506            allowed_owners = []
507            for user in PermissionSystem(self.env) \
508                        .get_users_with_permission('TICKET_MODIFY'):
509                if not ticket or \
510                        'TICKET_MODIFY' in PermissionCache(self.env, user,
511                                                           ticket.resource):
512                    allowed_owners.append(user)
513            allowed_owners.sort()
514            return allowed_owners
515
516    # ITicketManipulator methods
517
518    def prepare_ticket(self, req, ticket, fields, actions):
519        pass
520
521    def validate_ticket(self, req, ticket):
522        # Validate select fields for known values.
523        for field in ticket.fields:
524            if 'options' not in field:
525                continue
526            name = field['name']
527            if name == 'status':
528                continue
529            if name in ticket and name in ticket._old:
530                value = ticket[name]
531                if value:
532                    if value not in field['options']:
533                        yield name, _('"%(value)s" is not a valid value',
534                                      value=value)
535                elif not field.get('optional', False):
536                    yield name, _("field cannot be empty")
537
538        # Validate description length.
539        if len(ticket['description'] or '') > self.max_description_size:
540            yield 'description', _("Must be less than or equal to %(num)s "
541                                   "characters",
542                                   num=self.max_description_size)
543
544        # Validate summary length.
545        if not ticket['summary']:
546            yield 'summary', _("Tickets must contain a summary.")
547        elif len(ticket['summary'] or '') > self.max_summary_size:
548            yield 'summary', _("Must be less than or equal to %(num)s "
549                               "characters", num=self.max_summary_size)
550
551        # Validate custom field length.
552        for field in ticket.custom_fields:
553            field_attrs = ticket.fields.by_name(field)
554            max_size = field_attrs.get('max_size', 0)
555            if 0 < max_size < len(ticket[field] or ''):
556                label = field_attrs.get('label')
557                yield label or field, _("Must be less than or equal to "
558                                        "%(num)s characters", num=max_size)
559
560        # Validate time field content.
561        for field in ticket.time_fields:
562            value = ticket[field]
563            if field in ticket.custom_fields and \
564                    field in ticket._old and \
565                    not isinstance(value, datetime):
566                field_attrs = ticket.fields.by_name(field)
567                format = field_attrs.get('format')
568                try:
569                    ticket[field] = user_time(req, parse_date, value,
570                                              hint=format) \
571                                    if value else None
572                except TracError as e:
573                    # Degrade TracError to warning.
574                    ticket[field] = value
575                    label = field_attrs.get('label')
576                    yield label or field, to_unicode(e)
577
578    def validate_comment(self, req, comment):
579        # Validate comment length
580        if len(comment or '') > self.max_comment_size:
581            yield _("Must be less than or equal to %(num)s characters",
582                    num=self.max_comment_size)
583
584    # IPermissionRequestor methods
585
586    def get_permission_actions(self):
587        return ['TICKET_APPEND', 'TICKET_CREATE', 'TICKET_CHGPROP',
588                'TICKET_VIEW', 'TICKET_EDIT_CC', 'TICKET_EDIT_DESCRIPTION',
589                'TICKET_EDIT_COMMENT',
590                ('TICKET_MODIFY', ['TICKET_APPEND', 'TICKET_CHGPROP']),
591                ('TICKET_ADMIN', ['TICKET_CREATE', 'TICKET_MODIFY',
592                                  'TICKET_VIEW', 'TICKET_EDIT_CC',
593                                  'TICKET_EDIT_DESCRIPTION',
594                                  'TICKET_EDIT_COMMENT'])]
595
596    # IWikiSyntaxProvider methods
597
598    def get_link_resolvers(self):
599        return [('bug', self._format_link),
600                ('issue', self._format_link),
601                ('ticket', self._format_link),
602                ('comment', self._format_comment_link)]
603
604    def get_wiki_syntax(self):
605        yield (
606            # matches #... but not &#... (HTML entity)
607            r"!?(?<!&)#"
608            # optional intertrac shorthand #T... + digits
609            r"(?P<it_ticket>%s)%s" % (WikiParser.INTERTRAC_SCHEME,
610                                      Ranges.RE_STR),
611            lambda x, y, z: self._format_link(x, 'ticket', y[1:], y, z))
612
613    def _format_link(self, formatter, ns, target, label, fullmatch=None):
614        intertrac = formatter.shorthand_intertrac_helper(ns, target, label,
615                                                         fullmatch)
616        if intertrac:
617            return intertrac
618        try:
619            link, params, fragment = formatter.split_link(target)
620            r = Ranges(link)
621            if len(r) == 1:
622                num = r.a
623                ticket = formatter.resource(self.realm, num)
624                from trac.ticket.model import Ticket
625                if Ticket.id_is_valid(num) and \
626                        'TICKET_VIEW' in formatter.perm(ticket):
627                    # TODO: attempt to retrieve ticket view directly,
628                    #       something like: t = Ticket.view(num)
629                    for type, summary, status, resolution in \
630                            self.env.db_query("""
631                            SELECT type, summary, status, resolution
632                            FROM ticket WHERE id=%s
633                            """, (str(num),)):
634                        description = self.format_summary(summary, status,
635                                                          resolution, type)
636                        title = '#%s: %s' % (num, description)
637                        href = formatter.href.ticket(num) + params + fragment
638                        return tag.a(label, title=title, href=href,
639                                     class_='%s ticket' % status)
640            else:
641                ranges = str(r)
642                if params:
643                    params = '&' + params[1:]
644                label_wrap = label.replace(',', ',\u200b')
645                ranges_wrap = ranges.replace(',', ', ')
646                return tag.a(label_wrap,
647                             title=_("Tickets %(ranges)s", ranges=ranges_wrap),
648                             href=formatter.href.query(id=ranges) + params)
649        except ValueError:
650            pass
651        return tag.a(label, class_='missing ticket')
652
653    def _format_comment_link(self, formatter, ns, target, label):
654        resource = None
655        if ':' in target:
656            elts = target.split(':')
657            if len(elts) == 3:
658                cnum, realm, id = elts
659                if cnum != 'description' and cnum and not cnum[0].isdigit():
660                    realm, id, cnum = elts # support old comment: style
661                id = as_int(id, None)
662                if realm in ('bug', 'issue'):
663                    realm = 'ticket'
664                resource = formatter.resource(realm, id)
665        else:
666            resource = formatter.resource
667            cnum = target
668
669        if resource and resource.id and resource.realm == self.realm and \
670                cnum and (cnum.isdigit() or cnum == 'description'):
671            href = title = class_ = None
672            if self.resource_exists(resource):
673                from trac.ticket.model import Ticket
674                ticket = Ticket(self.env, resource.id)
675                if cnum != 'description' and not ticket.get_change(cnum):
676                    title = _("ticket comment does not exist")
677                    class_ = 'missing ticket'
678                elif 'TICKET_VIEW' in formatter.perm(resource):
679                    href = formatter.href.ticket(resource.id) + \
680                           "#comment:%s" % cnum
681                    if resource.id != formatter.resource.id:
682                        summary = self.format_summary(ticket['summary'],
683                                                      ticket['status'],
684                                                      ticket['resolution'],
685                                                      ticket['type'])
686                        if cnum == 'description':
687                            title = _("Description for #%(id)s: %(summary)s",
688                                      id=resource.id, summary=summary)
689                        else:
690                            title = _("Comment %(cnum)s for #%(id)s: "
691                                      "%(summary)s", cnum=cnum,
692                                      id=resource.id, summary=summary)
693                        class_ = ticket['status'] + ' ticket'
694                    else:
695                        title = _("Description") if cnum == 'description' \
696                                                 else _("Comment %(cnum)s",
697                                                        cnum=cnum)
698                        class_ = 'ticket'
699                else:
700                    title = _("no permission to view ticket")
701                    class_ = 'forbidden ticket'
702            else:
703                title = _("ticket does not exist")
704                class_ = 'missing ticket'
705            return tag.a(label, class_=class_, href=href, title=title)
706        return label
707
708    # IResourceManager methods
709
710    def get_resource_realms(self):
711        yield self.realm
712
713    def get_resource_description(self, resource, format=None, context=None,
714                                 **kwargs):
715        if format == 'compact':
716            return '#%s' % resource.id
717        elif format == 'summary':
718            from trac.ticket.model import Ticket
719            ticket = Ticket(self.env, resource.id)
720            args = [ticket[f] for f in ('summary', 'status', 'resolution',
721                                        'type')]
722            return self.format_summary(*args)
723        return _("Ticket #%(shortname)s", shortname=resource.id)
724
725    def format_summary(self, summary, status=None, resolution=None, type=None):
726        summary = shorten_line(summary)
727        if type:
728            summary = type + ': ' + summary
729        if status:
730            if status == 'closed' and resolution:
731                status += ': ' + resolution
732            return "%s (%s)" % (summary, status)
733        else:
734            return summary
735
736    def resource_exists(self, resource):
737        """
738        >>> from trac.test import EnvironmentStub
739        >>> from trac.resource import Resource, resource_exists
740        >>> env = EnvironmentStub()
741
742        >>> resource_exists(env, Resource('ticket', 123456))
743        False
744
745        >>> from trac.ticket.model import Ticket
746        >>> t = Ticket(env)
747        >>> int(t.insert())
748        1
749        >>> resource_exists(env, t.resource)
750        True
751        """
752        try:
753            id_ = int(resource.id)
754        except (TypeError, ValueError):
755            return False
756        if self.env.db_query("SELECT id FROM ticket WHERE id=%s", (id_,)):
757            if resource.version is None:
758                return True
759            revcount = self.env.db_query("""
760                SELECT count(DISTINCT time) FROM ticket_change WHERE ticket=%s
761                """, (id_,))
762            return revcount[0][0] >= resource.version
763        else:
764            return False
765
766
767@contextlib.contextmanager
768def translation_deactivated(ticket=None):
769    t = deactivate()
770    if ticket is not None:
771        ts = TicketSystem(ticket.env)
772        translated_fields = ticket.fields
773        ticket.fields = ts.get_ticket_fields()
774    try:
775        yield
776    finally:
777        if ticket is not None:
778            ticket.fields = translated_fields
779        reactivate(t)
780