Ticket #31: trac_r7937_ticket_relations.patch
| File trac_r7937_ticket_relations.patch, 26.7 KB (added by Joachim Hoessler <hoessler@…>, 3 years ago) |
|---|
-
setup.py
107 107 trac.ticket.report = trac.ticket.report 108 108 trac.ticket.roadmap = trac.ticket.roadmap 109 109 trac.ticket.web_ui = trac.ticket.web_ui 110 trac.ticket.links = trac.ticket.links 110 111 trac.timeline = trac.timeline.web_ui 111 112 trac.versioncontrol.admin = trac.versioncontrol.admin 112 113 trac.versioncontrol.svn_fs = trac.versioncontrol.svn_fs -
trac/db_default.py
17 17 from trac.db import Table, Column, Index 18 18 19 19 # Database version identifier. Used for automatic upgrades. 20 db_version = 2 120 db_version = 22 21 21 22 22 def __mkreports(reports): 23 23 """Utility function used to create report data in same syntax as the … … 131 131 Column('ticket', type='int'), 132 132 Column('name'), 133 133 Column('value')], 134 Table('ticket_links', key=('source', 'destination', 'type'))[ 135 Column('source', type='int'), 136 Column('destination', type='int'), 137 Column('type')], 134 138 Table('enum', key=('type', 'name'))[ 135 139 Column('type'), 136 140 Column('name'), -
trac/upgrades/db22.py
1 from trac.db import Table, Column, Index, DatabaseManager 2 3 def do_upgrade(env, ver, cursor): 4 """Add new table for links 5 """ 6 table = Table('ticket_links', key=('source', 'destination', 'type'))[ 7 Column('source', type='int'), 8 Column('destination', type='int'), 9 Column('type')] 10 db_connector, _ = DatabaseManager(env)._get_connector() 11 for stmt in db_connector.to_sql(table): 12 cursor.execute(stmt) -
trac/htdocs/js/query.js
244 244 td.className = "filter"; 245 245 if (property.type == "select") { 246 246 focusElement = createSelect(propertyName, property.options, true); 247 } else if ((property.type == "text") || (property.type == "textarea")) { 247 } else if ((property.type == "text") || (property.type == "textarea") 248 || (property.type == "link")) { 248 249 focusElement = createText(propertyName, 42); 249 250 } 250 251 td.appendChild(focusElement); -
trac/ticket/web_ui.py
1121 1121 type_ = field['type'] 1122 1122 1123 1123 # enable a link to custom query for all choice fields 1124 if type_ not in ['text', 'textarea' ]:1124 if type_ not in ['text', 'textarea', 'link']: 1125 1125 field['rendered'] = self._query_link(req, name, ticket[name]) 1126 1126 1127 1127 # per field settings … … 1188 1188 if value in ('1', '0'): 1189 1189 field['rendered'] = self._query_link(req, name, value, 1190 1190 value == '1' and _('yes') or _('no')) 1191 elif type_ == 'text':1191 elif type_ in ('text', 'link'): 1192 1192 if field.get('format') == 'wiki': 1193 1193 field['rendered'] = format_to_oneliner(self.env, context, 1194 1194 ticket[name]) -
trac/ticket/links.py
1 from trac.ticket.api import ITicketLinkController, ITicketManipulator,\ 2 TicketSystem 3 from trac.ticket.model import Ticket 4 from copy import copy 5 from trac.core import Component, implements 6 7 class LinksProvider(Component): 8 """Link controller that provides links as specified in the [ticket-links] 9 section in the trac.ini configuration file. 10 """ 11 12 implements(ITicketLinkController, ITicketManipulator) 13 14 def __init__(self): 15 self._links, self._labels, self._validators = self._get_links_config() 16 17 def get_ends(self): 18 return self._links 19 20 def render_end(self, end): 21 return self._labels[end] 22 23 def prepare_ticket(self, req, ticket, fields, actions): 24 pass 25 26 def validate_ticket(self, req, ticket): 27 for end, validator in self._validators.items(): 28 check = validator(ticket, end) 29 if check: 30 yield None, check 31 32 def validate_no_cyle(self, ticket, end): 33 cycle = self.find_cycle(ticket, end, []) 34 if cycle != None: 35 cycle_str = ['#%s'%id for id in cycle] 36 return 'Cycle in ''%s'': %s' % (self.render_end(end), ' -> '.join(cycle_str)) 37 return None 38 39 def _get_links_config(self): 40 links = [] 41 labels = {} 42 validators = {} 43 config = self.config['ticket-links'] 44 for name in [option for option, _ in config.options() 45 if '.' not in option]: 46 ends = config.get(name).split(',') 47 if len(ends) < 1: 48 continue 49 end1 = ends[0] 50 end2 = None 51 if len(ends) > 1: 52 end2 = ends[1] 53 links.append((end1, end2)) 54 label1 = config.get(end1 + '.label') or end1.capitalize() 55 labels[end1] = label1 56 if end2: 57 label2 = config.get(end2 + '.label') or end2.capitalize() 58 labels[end2] = label2 59 validator = config.get(name + '.validator') 60 if validator == 'no_cycle': 61 validators[end1] = self.validate_no_cyle 62 if end2: 63 validators[end2] = self.validate_no_cyle 64 65 return links, labels, validators 66 67 68 def find_cycle(self, ticket, field, path): 69 if ticket.id in path: 70 path.append(ticket.id) 71 return path 72 73 path.append(ticket.id) 74 75 ticket_system = TicketSystem(self.env) 76 links = ticket_system.parse_links(ticket[field]) 77 for link in links: 78 linked_ticket= Ticket(self.env, link) 79 cycle = self.find_cycle(linked_ticket, field, copy(path)) 80 if cycle != None: 81 return cycle 82 return None 83 84 No newline at end of file -
trac/ticket/query.py
393 393 cols.extend([c for c in self.constraints.keys() if not c in cols]) 394 394 395 395 custom_fields = [f['name'] for f in self.fields if 'custom' in f] 396 link_fields = [f['name'] for f in self.fields if 'link' in f] 396 397 397 398 sql = [] 398 sql.append("SELECT " + ",".join(['t.%s AS %s' % (c, c) for c in cols399 if c not in custom_fields]))399 sql.append("SELECT DISTINCT " + ",".join(['t.%s AS %s' % (c, c) for c in cols 400 if c not in (custom_fields + link_fields)])) 400 401 sql.append(",priority.value AS priority_value") 401 402 for k in [k for k in cols if k in custom_fields]: 402 403 sql.append(",%s.value AS %s" % (k, k)) … … 407 408 sql.append("\n LEFT OUTER JOIN ticket_custom AS %s ON " \ 408 409 "(id=%s.ticket AND %s.name='%s')" % (k, k, k, k)) 409 410 411 # Join with ticket_links table as necessary 412 for k in [k for k in cols if k in link_fields]: 413 sql.append("\n LEFT OUTER JOIN ticket_links AS %s ON " \ 414 "(id=%s.source AND %s.type='%s')" % (k, k, k, k)) 415 410 416 # Join with the enum table for proper sorting 411 417 for col in [c for c in enum_columns 412 418 if c == self.order or c == self.group or c == 'priority']: … … 421 427 % (col, col, col)) 422 428 423 429 def get_constraint_sql(name, value, mode, neg): 424 if name not in custom_fields: 425 col = 't.' + name 426 else: 430 if name in custom_fields: 427 431 col = name + '.value' 432 elif name in link_fields: 433 col = name + '.destination' 434 # for now, search for any value and ignore all modes 435 dst_ids = TicketSystem(self.env).parse_links(value) 436 sql_snippets = " OR ".join([("%s%s=CAST(%%s AS int)" % (col, neg and '!' or '')) 437 for _ in dst_ids]) 438 return sql_snippets, [dst_id for dst_id in dst_ids] 439 else: 440 col = 't.' + name 428 441 value = value[len(mode) + neg:] 429 442 430 443 if name in self.time_fields: … … 598 611 cols = self.get_columns() 599 612 labels = dict([(f['name'], f['label']) for f in self.fields]) 600 613 wikify = set(f['name'] for f in self.fields 601 if f['type'] == 'text'and f.get('format') == 'wiki')614 if f['type'] in ('text', 'link') and f.get('format') == 'wiki') 602 615 603 616 headers = [{ 604 617 'name': col, 'label': labels.get(col, _('Ticket')), … … 634 647 {'name': _("is"), 'value': ""}, 635 648 {'name': _("is not"), 'value': "!"} 636 649 ] 650 modes['link'] = [ 651 {'name': _("contains"), 'value': ""} 652 ] 637 653 638 654 groups = {} 639 655 groupsequence = [] … … 819 835 max = args.get('max') 820 836 if max is None and format in ('csv', 'tab'): 821 837 max = 0 # unlimited unless specified explicitly 838 822 839 query = Query(self.env, req.args.get('report'), 823 840 constraints, cols, args.get('order'), 824 841 'desc' in args, args.get('group'), … … 832 849 for var in ('query_constraints', 'query_time', 'query_tickets'): 833 850 if var in req.session: 834 851 del req.session[var] 852 print query.get_href(req.href) 835 853 req.redirect(query.get_href(req.href)) 836 854 837 855 # Add registered converters … … 969 987 # Don't allow the user to remove the id column 970 988 data['all_columns'].remove('id') 971 989 data['all_textareas'] = query.get_all_textareas() 990 data['ticket_indent'] = range(100) 972 991 973 992 add_stylesheet(req, 'common/css/report.css') 974 993 add_script(req, 'common/js/query.js') -
trac/ticket/templates/query.html
43 43 <py:for each="field_name in field_names" py:with="field = fields[field_name]"> 44 44 <py:for each="constraint_name, constraint in constraints.items()"> 45 45 <tbody py:if="field_name == constraint_name" 46 py:with="multiline = field.type in ('select', 'text', 'textarea', 'time' )">46 py:with="multiline = field.type in ('select', 'text', 'textarea', 'time', 'link')"> 47 47 <py:for each="constraint_idx, constraint_value in enumerate(constraint['values'])"> 48 48 <tr class="${field_name}" py:if="multiline or constraint_idx == 0"> 49 49 <py:choose test="constraint_idx"> … … 93 93 <label for="${field_name}_off" class="control">no</label> 94 94 </py:when> 95 95 96 <py:when test="field.type in ('text', 'textarea' )">96 <py:when test="field.type in ('text', 'textarea', 'link')"> 97 97 <input type="text" name="${field_name}" value="$constraint_value" size="42" /> 98 98 </py:when> 99 99 -
trac/ticket/model.py
97 97 db = self._get_db(db) 98 98 99 99 # Fetch the standard ticket fields 100 std_fields = [f['name'] for f in self.fields if not f.get('custom')]100 std_fields = [f['name'] for f in self.fields if not (f.get('custom') or f.get('link'))] 101 101 cursor = db.cursor() 102 102 cursor.execute("SELECT %s FROM ticket WHERE id=%%s" 103 103 % ','.join(std_fields), (tkt_id,)) … … 123 123 for name, value in cursor: 124 124 if name in custom_fields and value is not None: 125 125 self.values[name] = value 126 127 # Fetch links 128 link_fields = [f['name'] for f in self.fields if f.get('link')] 129 for end in link_fields: 130 cursor.execute("SELECT destination FROM ticket_links WHERE source=%s and type=%s", 131 (tkt_id, end)) 132 link_list = [] 133 for destination in cursor: 134 link_list.append(destination) 126 135 136 link_list.sort() 137 self.values[end] = ', '.join(['#%s' % v for v in link_list]) 138 127 139 def __getitem__(self, name): 128 140 return self.values.get(name) 129 141 … … 197 209 # Insert ticket record 198 210 std_fields = [] 199 211 custom_fields = [] 212 link_fields = [] 200 213 for f in self.fields: 201 214 fname = f['name'] 202 215 if fname in self.values: 203 if f.get('custom'): 216 # ignore link fields 217 if f.get('link'): 218 link_fields.append(fname) 219 elif f.get('custom'): 204 220 custom_fields.append(fname) 205 221 else: 206 222 std_fields.append(fname) … … 215 231 cursor.executemany("INSERT INTO ticket_custom (ticket,name,value) " 216 232 "VALUES (%s,%s,%s)", [(tkt_id, name, self[name]) 217 233 for name in custom_fields]) 234 # Insert links 235 for end in link_fields: 236 ticket_system = TicketSystem(self.env) 237 dst_ids = ticket_system.parse_links(self[end]) 238 # TODO: check if target exists! 239 if len(dst_ids)>0: 240 cursor.executemany("INSERT INTO ticket_links (source,destination,type) " 241 "VALUES (%s,%s,%s)", [(tkt_id, int(dst_id), end) 242 for dst_id in dst_ids]) 243 # insert other side for bidirectional links 244 other_end = ticket_system.link_ends_map[end] 245 if other_end: 246 cursor.executemany("INSERT INTO ticket_links (source,destination,type) " 247 "VALUES (%s,%s,%s)", [(int(dst_id), tkt_id, other_end) 248 for dst_id in dst_ids]) 249 218 250 if handle_ta: 219 251 db.commit() 220 252 … … 271 303 self.values['cc'] = ', '.join(cclist) 272 304 273 305 custom_fields = [f['name'] for f in self.fields if f.get('custom')] 306 link_fields = [f['name'] for f in self.fields if f.get('link')] 274 307 for name in self._old.keys(): 308 # ignore link fields here 309 if name in link_fields: 310 continue 275 311 if name in custom_fields: 276 312 cursor.execute("SELECT * FROM ticket_custom " 277 313 "WHERE ticket=%s and name=%s", (self.id, name)) … … 300 336 cursor.execute("UPDATE ticket SET changetime=%s WHERE id=%s", 301 337 (when_ts, self.id)) 302 338 339 # update links 340 for end in link_fields: 341 if end in self._old: 342 new_ids = TicketSystem(self.env).parse_links(self[end]) 343 old_ids = TicketSystem(self.env).parse_links(self._old[end]) 344 list_changed = False 345 for id in new_ids + old_ids: 346 if id in new_ids and id not in old_ids: 347 # New link added 348 cursor.execute("INSERT INTO ticket_links (source,destination,type) " 349 "VALUES (%s,%s,%s)", [self.id, id, end]) 350 other_end = TicketSystem(self.env).link_ends_map[end] 351 list_changed = True 352 if other_end: 353 cursor.execute("INSERT INTO ticket_links (source,destination,type) " 354 "VALUES (%s,%s,%s)", [id, self.id, other_end]) 355 elif id not in new_ids and id in old_ids: 356 # Old link removed 357 cursor.execute("DELETE FROM ticket_links WHERE source=%s AND destination=%s " 358 "AND type=%s", [self.id, id, end]) 359 other_end = TicketSystem(self.env).link_ends_map[end] 360 list_changed = True 361 if other_end: 362 cursor.execute("DELETE FROM ticket_links WHERE source=%s AND destination=%s " 363 "AND type=%s", [id, self.id, other_end]) 364 if list_changed: 365 cursor.execute("INSERT INTO ticket_change " 366 "(ticket,time,author,field,oldvalue,newvalue) " 367 "VALUES (%s, %s, %s, %s, %s, %s)", 368 (self.id, when_ts, author, end, self._old[end], 369 self[end])) 370 303 371 if handle_ta: 304 372 db.commit() 305 373 old_values = self._old 306 374 self._old = {} 307 375 self.values['changetime'] = when 308 376 309 377 for listener in TicketSystem(self.env).change_listeners: 310 378 listener.ticket_changed(self, comment, author, old_values) 311 379 return True 312 380 313 381 def get_changelog(self, when=None, db=None): 314 382 """Return the changelog as a list of tuples of the form 315 383 (time, author, field, oldvalue, newvalue, permanent). … … 357 425 cursor.execute("DELETE FROM ticket WHERE id=%s", (self.id,)) 358 426 cursor.execute("DELETE FROM ticket_change WHERE ticket=%s", (self.id,)) 359 427 cursor.execute("DELETE FROM ticket_custom WHERE ticket=%s", (self.id,)) 428 cursor.execute("DELETE FROM ticket_links WHERE source=%s OR destination=%s", 429 (self.id,self.id)) 360 430 361 431 if handle_ta: 362 432 db.commit() -
trac/ticket/api.py
140 140 Must return a list of `(field, message)` tuples, one for each problem 141 141 detected. `field` can be `None` to indicate an overall problem with the 142 142 ticket. Therefore, a return value of `[]` means everything is OK.""" 143 143 144 class ITicketLinkController(Interface): 145 146 def get_links(): 147 """returns iterable of '(end1, end2)' tuples that make up a link. 148 'end2' can be None for unidirectional links.""" 144 149 150 def render_end(end): 151 """returns label""" 152 145 153 class TicketSystem(Component): 146 154 implements(IPermissionRequestor, IWikiSyntaxProvider, IResourceManager) 147 155 … … 151 159 include_missing=False, 152 160 doc="""Ordered list of workflow controllers to use for ticket actions 153 161 (''since 0.11'').""") 162 link_controllers = ExtensionPoint(ITicketLinkController) 154 163 155 164 restrict_owner = BoolOption('ticket', 'restrict_owner', 'false', 156 165 """Make the owner field of tickets use a drop-down menu. See … … 160 169 _fields = None 161 170 _custom_fields = None 162 171 172 # regular expression to match links 173 NUMBERS_RE = re.compile(r'\d+', re.U) 174 163 175 def __init__(self): 164 176 self.log.debug('action controllers for ticket workflow: %r' % 165 177 [c.__class__.__name__ for c in self.action_controllers]) 166 178 self._fields_lock = threading.RLock() 179 # initialize dictionary that maps from one end of a link to the other end 180 self.link_ends_map = {} 181 for controller in self.link_controllers: 182 for end1, end2 in controller.get_ends(): 183 self.link_ends_map[end1] = end2 184 self.link_ends_map[end2] = end1 185 167 186 168 187 # Public API 169 188 … … 283 302 continue 284 303 field['custom'] = True 285 304 fields.append(field) 305 306 # fields for links 307 for controller in self.link_controllers: 308 for end1, end2 in controller.get_ends(): 309 self._add_link_field(end1, controller, fields) 310 if end2 != None and end2 != end1: 311 self._add_link_field(end2, controller, fields) 286 312 287 313 return fields 288 314 289 315 reserved_field_names = ['report', 'order', 'desc', 'group', 'groupdesc', 290 316 'col', 'row', 'format', 'max', 'page', 'verbose'] 291 317 318 def _add_link_field(self, end, controller, fields): 319 label = controller.render_end(end) 320 if end in [f['name'] for f in fields]: 321 self.log.warning('Duplicate field name "%s" (ignoring)', 322 end) 323 return 324 field = {'name': end, 'type': 'link', 'label': label, 'link': True, 'format': 'wiki'} 325 fields.append(field) 326 292 327 def get_custom_fields(self): 293 328 if self._custom_fields is None: 294 329 self._fields_lock.acquire() … … 458 493 return "%s (%s)" % (summary, status) 459 494 else: 460 495 return summary 496 497 def parse_links(self, value): 498 if not value: 499 return [] 500 return [int(id) for id in self.NUMBERS_RE.findall(value)] -
trac/ticket/tests/links.py
1 from trac.ticket.model import Ticket 2 from trac.test import EnvironmentStub, Mock 3 from trac.ticket.links import LinksProvider 4 from trac.ticket.api import TicketSystem 5 from trac.ticket.query import Query 6 from trac.util.datefmt import utc 7 import unittest 8 9 class TicketTestCase(unittest.TestCase): 10 def setUp(self): 11 self.env = EnvironmentStub(default_data=True) 12 self.env.config.set('ticket-links', 'dependency', 'dependson,dependent') 13 self.env.config.set('ticket-links', 'dependency.validator', 'no_cycle') 14 self.req = Mock(href=self.env.href, authname='anonymous', tz=utc) 15 16 def _insert_ticket(self, summary, **kw): 17 """Helper for inserting a ticket into the database""" 18 ticket = Ticket(self.env) 19 for k,v in kw.items(): 20 ticket[k] = v 21 return ticket.insert() 22 23 def _create_a_ticket(self): 24 ticket = Ticket(self.env) 25 ticket['reporter'] = 'santa' 26 ticket['summary'] = 'Foo' 27 ticket['foo'] = 'This is a custom field' 28 return ticket 29 30 # TicketSystem tests 31 32 def test_get_ticket_fields(self): 33 ticket_system = TicketSystem(self.env) 34 fields = ticket_system.get_ticket_fields() 35 link_fields = [f['name'] for f in fields if f.get('link')] 36 self.assertEquals(2, len(link_fields)) 37 38 def test_update_links(self): 39 ticket = self._create_a_ticket() 40 ticket.insert() 41 ticket = self._create_a_ticket() 42 ticket['dependson'] = '#1, #2' 43 ticket.insert() 44 45 # Check if ticket link in #1 has been updated 46 ticket = Ticket(self.env, 1) 47 self.assertEqual(1, ticket.id) 48 self.assertEqual('#2', ticket['dependent']) 49 50 # Remove link from #2 to #1 51 ticket = Ticket(self.env, 2) 52 ticket['dependson'] = '#2' 53 ticket.save_changes("me", "testing") 54 55 # Check if ticket link in #1 has been updated 56 ticket = Ticket(self.env, 1) 57 self.assertEqual(1, ticket.id) 58 self.assertEqual('', ticket['dependent']) 59 60 def test_save_retrieve_links(self): 61 ticket = self._create_a_ticket() 62 ticket.insert() 63 ticket = self._create_a_ticket() 64 ticket['dependson'] = '#1, #2' 65 ticket.insert() 66 67 # Check if ticket link in #1 has been updated 68 ticket = Ticket(self.env, 1) 69 self.assertEqual(1, ticket.id) 70 self.assertEqual('#2', ticket['dependent']) 71 72 def test_query_by_result(self): 73 ticket = self._create_a_ticket() 74 ticket.insert() 75 ticket = self._create_a_ticket() 76 ticket['dependson'] = '#1' 77 ticket.insert() 78 query = Query.from_string(self.env, 'dependson=1', order='id') 79 sql, args = query.get_sql() 80 tickets = query.execute(self.req) 81 self.assertEqual(len(tickets), 1) 82 self.assertEqual(tickets[0]['id'], 2) 83 84 def test_query_by_result2(self): 85 ticket = self._create_a_ticket() 86 ticket.insert() 87 ticket = self._create_a_ticket() 88 ticket['dependson'] = '#1' 89 ticket.insert() 90 query = Query.from_string(self.env, 'dependent=2', order='id') 91 sql, args = query.get_sql() 92 tickets = query.execute(self.req) 93 self.assertEqual(len(tickets), 1) 94 self.assertEqual(tickets[0]['id'], 1) 95 96 97 def test_validator_no_cyle(self): 98 ticket1 = self._create_a_ticket() 99 ticket1.insert() 100 ticket2 = self._create_a_ticket() 101 ticket2['dependson'] = '#1' 102 links_provider = LinksProvider(self.env) 103 issues = links_provider.validate_ticket(self.req, ticket2) 104 self.assertEquals(sum(1 for _ in issues), 0) 105 ticket2.insert() 106 ticket1['dependson'] = '#2' 107 issues = links_provider.validate_ticket(self.req, ticket1) 108 self.assertEquals(sum(1 for _ in issues), 1) 109 110 No newline at end of file
