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