Commits

Christian Boos committed 8191e68 Merge

Refreshed ticket:31:attachment:trac_r7937_ticket_relations.patch for trunk r9982

Comments (0)

Files changed (12)

         trac.ticket.report = trac.ticket.report
         trac.ticket.roadmap = trac.ticket.roadmap
         trac.ticket.web_ui = trac.ticket.web_ui
+        trac.ticket.links = trac.ticket.links
         trac.timeline = trac.timeline.web_ui
         trac.versioncontrol.admin = trac.versioncontrol.admin
         trac.versioncontrol.svn_authz = trac.versioncontrol.svn_authz

trac/db_default.py

 from trac.db import Table, Column, Index
 
 # Database version identifier. Used for automatic upgrades.
-db_version = 26
+db_version = 27
 
 def __mkreports(reports):
     """Utility function used to create report data in same syntax as the
         Column('ticket', type='int'),
         Column('name'),
         Column('value')],
+    Table('ticket_links', key=('source', 'destination', 'type'))[
+        Column('source', type='int'),
+        Column('destination', type='int'),
+        Column('type')],
     Table('enum', key=('type', 'name'))[
         Column('type'),
         Column('name'),

trac/htdocs/js/query.js

         td = $("<td>").addClass("filter");
         if (property.type == "select") {
           focusElement = createSelect(propertyName, property.options, true);
-        } else if ((property.type == "text") || (property.type == "id")
-                   || (property.type == "textarea")) {
+        } else if ((property.type == "text") || (property.type == "textarea")
+        		|| (property.type == "link")) {
           focusElement = createText(propertyName, 42);
         }
         td.append(focusElement).appendTo(tr);

trac/ticket/api.py

         Must return a list of `(field, message)` tuples, one for each problem
         detected. `field` can be `None` to indicate an overall problem with the
         ticket. Therefore, a return value of `[]` means everything is OK."""
+        
+class ITicketLinkController(Interface):
+    
+    def get_links():
+        """returns iterable of '(end1, end2)' tuples that make up a link. 
+        'end2' can be None for unidirectional links."""
 
+    def render_end(end):
+        """returns label"""
 
 class IMilestoneChangeListener(Interface):
     """Extension point interface for components that require notification
         include_missing=False,
         doc="""Ordered list of workflow controllers to use for ticket actions
             (''since 0.11'').""")
+    link_controllers = ExtensionPoint(ITicketLinkController)
 
     restrict_owner = BoolOption('ticket', 'restrict_owner', 'false',
         """Make the owner field of tickets use a drop-down menu.
         """Default resolution for resolving (closing) tickets
         (''since 0.11'').""")
 
+    # regular expression to match links
+    NUMBERS_RE = re.compile(r'\d+', re.U)
+
     def __init__(self):
         self.log.debug('action controllers for ticket workflow: %r' % 
                 [c.__class__.__name__ for c in self.action_controllers])
+        # initialize dictionary that maps from one end of a link to the other end
+        self.link_ends_map = {}
+        for controller in self.link_controllers:
+            for end1, end2 in controller.get_ends():
+                self.link_ends_map[end1] = end2
+                self.link_ends_map[end2] = end1
+        
 
     # Public API
 
                 continue
             field['custom'] = True
             fields.append(field)
+            
+        # fields for links
+        for controller in self.link_controllers:
+            for end1, end2 in controller.get_ends():
+                self._add_link_field(end1, controller, fields)
+                if end2 != None and end2 != end1:
+                    self._add_link_field(end2, controller, fields)
 
         return fields
-
+    
     reserved_field_names = ['report', 'order', 'desc', 'group', 'groupdesc',
                             'col', 'row', 'format', 'max', 'page', 'verbose',
                             'comment', 'or']
+							
+    def _add_link_field(self, end, controller, fields):
+        label = controller.render_end(end)
+        if end in [f['name'] for f in fields]:
+            self.log.warning('Duplicate field name "%s" (ignoring)', end)
+            return
+        field = {'name': end, 'type': 'link', 'label': label, 'link': True, 'format': 'wiki'}
+        fields.append(field)   
 
     def get_custom_fields(self):
         return copy.deepcopy(self.custom_fields)
             return tag.a(label, href=href, title=title)
         else:
             return label
- 
+			
+    def parse_links(self, value):
+       if not value:
+           return []
+       return [int(id) for id in self.NUMBERS_RE.findall(value)]
+
     # IResourceManager methods
 
     def get_resource_realms(self):

trac/ticket/links.py

+from trac.ticket.api import ITicketLinkController, ITicketManipulator,\
+    TicketSystem
+from trac.ticket.model import Ticket
+from copy import copy
+from trac.core import Component, implements
+
+class LinksProvider(Component):
+    """Link controller that provides links as specified in the [ticket-links]
+    section in the trac.ini configuration file.
+    """
+    
+    implements(ITicketLinkController, ITicketManipulator)
+    
+    def __init__(self):
+        self._links, self._labels, self._validators = self._get_links_config()
+
+    def get_ends(self):
+        return self._links
+    
+    def render_end(self, end):
+        return self._labels[end]
+        
+    def prepare_ticket(self, req, ticket, fields, actions):
+        pass
+        
+    def validate_ticket(self, req, ticket):
+        for end, validator in self._validators.items():
+            check = validator(ticket, end)
+            if check:
+                yield None, check
+        
+    def validate_no_cyle(self, ticket, end):
+        cycle = self.find_cycle(ticket, end, [])
+        if cycle != None:
+            cycle_str = ['#%s'%id for id in cycle]
+            return 'Cycle in ''%s'': %s' % (self.render_end(end), ' -> '.join(cycle_str))
+        return None
+
+    def _get_links_config(self):
+        links = []
+        labels = {}
+        validators = {}
+        config = self.config['ticket-links']
+        for name in [option for option, _ in config.options()
+                     if '.' not in option]:
+            ends = config.get(name).split(',')
+            if len(ends) < 1:
+                continue
+            end1 = ends[0]
+            end2 = None
+            if len(ends) > 1:
+                end2 = ends[1]
+            links.append((end1, end2))
+            label1 = config.get(end1 + '.label') or end1.capitalize()
+            labels[end1] = label1
+            if end2:
+                label2 = config.get(end2 + '.label') or end2.capitalize()
+                labels[end2] = label2
+            validator = config.get(name + '.validator')
+            if validator == 'no_cycle':
+                validators[end1] = self.validate_no_cyle
+                if end2:
+                    validators[end2] = self.validate_no_cyle
+
+        return links, labels, validators
+
+
+    def find_cycle(self, ticket, field, path):
+        if ticket.id in path:
+            path.append(ticket.id)
+            return path
+
+        path.append(ticket.id)
+
+        ticket_system = TicketSystem(self.env)
+        links = ticket_system.parse_links(ticket[field])
+        for link in links:
+            linked_ticket= Ticket(self.env, link)
+            cycle = self.find_cycle(linked_ticket, field, copy(path))
+            if  cycle != None:
+                return cycle
+        return None
+                

trac/ticket/model.py

             db = self._get_db(db)
 
             # Fetch the standard ticket fields
-            std_fields = [f['name'] for f in self.fields
-                          if not f.get('custom')]
+            std_fields = [f['name'] for f in self.fields 
+			              if not (f.get('custom') or f.get('link'))]
             cursor = db.cursor()
             cursor.execute("SELECT %s FROM ticket WHERE id=%%s"
                            % ','.join(std_fields), (tkt_id,))
                     self.values[name] = empty
                 else:
                     self.values[name] = value
+                
+        # Fetch links 
+        link_fields = [f['name'] for f in self.fields if f.get('link')]
+        for end in link_fields:
+            cursor.execute("SELECT destination FROM ticket_links WHERE source=%s and type=%s",
+                   (tkt_id, end))
+            link_list = []
+            for destination in cursor:
+                link_list.append(destination)
+
+            link_list.sort()
+            self.values[end] = ', '.join(['#%s' % v for v in link_list])
+            
 
     def __getitem__(self, name):
         return self.values.get(name)
         # Insert ticket record
         std_fields = []
         custom_fields = []
+        link_fields = []
         for f in self.fields:
             fname = f['name']
             if fname in self.values:
-                if f.get('custom'):
+                # ignore link fields
+                if f.get('link'):
+                    link_fields.append(fname)
+                elif f.get('custom'):
                     custom_fields.append(fname)
                 else:
                     std_fields.append(fname)
                 cursor.executemany("""
                 INSERT INTO ticket_custom (ticket,name,value) VALUES (%s,%s,%s)
                 """, [(tkt_id[0], name, self[name]) for name in custom_fields])
+				
+            # Insert links
+            for end in link_fields:
+                ticket_system = TicketSystem(self.env)
+                dst_ids =  ticket_system.parse_links(self[end])
+                # TODO: check if target exists!
+                if len(dst_ids)>0:
+                    cursor.executemany("INSERT INTO ticket_links (source,destination,type) "
+                                                "VALUES (%s,%s,%s)", [(tkt_id, int(dst_id), end)
+                                                                      for dst_id in dst_ids])
+                    # insert other side for bidirectional links
+                    other_end = ticket_system.link_ends_map[end]
+                    if other_end:
+                        cursor.executemany("INSERT INTO ticket_links (source,destination,type) "
+                                        "VALUES (%s,%s,%s)", [(int(dst_id), tkt_id, other_end)
+                                                              for dst_id in dst_ids])
+                            
 
         self.id = tkt_id[0]
         self.resource = self.resource(id=tkt_id[0])
 
             # store fields
             custom_fields = [f['name'] for f in self.fields if f.get('custom')]
-
+            link_fields = [f['name'] for f in self.fields if f.get('link')]
             for name in self._old.keys():
+                # ignore link fields here
+                if name in link_fields:
+                    continue
                 if name in custom_fields:
                     cursor.execute("""
                         SELECT * FROM ticket_custom 
             cursor.execute("UPDATE ticket SET changetime=%s WHERE id=%s",
                            (when_ts, self.id))
 
+            # update links
+            for end in link_fields:
+                if end in self._old:
+                    new_ids = TicketSystem(self.env).parse_links(self[end])
+                    old_ids = TicketSystem(self.env).parse_links(self._old[end])
+                    list_changed = False
+                    for id in new_ids + old_ids:
+                        if id in new_ids and id not in old_ids:
+                            # New link added
+                            cursor.execute("INSERT INTO ticket_links (source,destination,type) "
+                                                "VALUES (%s,%s,%s)", [self.id, id, end])
+                            other_end = TicketSystem(self.env).link_ends_map[end]
+                            list_changed = True
+                            if other_end:
+                                cursor.execute("INSERT INTO ticket_links (source,destination,type) "
+                                                    "VALUES (%s,%s,%s)", [id, self.id, other_end])
+                        elif id not in new_ids and id in old_ids:
+                            # Old link removed
+                            cursor.execute("DELETE FROM ticket_links WHERE source=%s AND destination=%s "
+                                                "AND type=%s", [self.id, id, end])
+                            other_end = TicketSystem(self.env).link_ends_map[end]
+                            list_changed = True
+                            if other_end:
+                                cursor.execute("DELETE FROM ticket_links WHERE source=%s AND destination=%s "
+                                                "AND type=%s", [id, self.id, other_end])
+                    if list_changed:
+                        cursor.execute("INSERT INTO ticket_change "
+                                       "(ticket,time,author,field,oldvalue,newvalue) "
+                                       "VALUES (%s, %s, %s, %s, %s, %s)",
+                                       (self.id, when_ts, author, end, self._old[end],
+                                        self[end]))               
+
         old_values = self._old
         self._old = {}
         self.values['changetime'] = when
-
+        
         for listener in TicketSystem(self.env).change_listeners:
             listener.ticket_changed(self, comment, author, old_values)
         return True
-
+    
     def get_changelog(self, when=None, db=None):
         """Return the changelog as a list of tuples of the form
         (time, author, field, oldvalue, newvalue, permanent).
                            (self.id,))
             cursor.execute("DELETE FROM ticket_custom WHERE ticket=%s",
                            (self.id,))
+            cursor.execute("DELETE FROM ticket_links WHERE source=%s OR destination=%s", 
+                           (self.id,self.id))
 
         for listener in TicketSystem(self.env).change_listeners:
             listener.ticket_deleted(self)

trac/ticket/query.py

         cols.extend([c for c in self.constraint_cols if not c in cols])
 
         custom_fields = [f['name'] for f in self.fields if 'custom' in f]
+        link_fields = [f['name'] for f in self.fields if 'link' in f]
 
         sql = []
-        sql.append("SELECT " + ",".join(['t.%s AS %s' % (c, c) for c in cols
-                                         if c not in custom_fields]))
+        sql.append("SELECT DISTINCT " + ",".join(['t.%s AS %s' % (c, c) for c in cols
+                                         if c not in (custom_fields + link_fields)]))
         sql.append(",priority.value AS priority_value")
         for k in [db.quote(k) for k in cols if k in custom_fields]:
             sql.append(",%s.value AS %s" % (k, k))
             sql.append("\n  LEFT OUTER JOIN ticket_custom AS %s ON " \
                        "(id=%s.ticket AND %s.name='%s')" % (qk, qk, qk, k))
 
+        # Join with ticket_links table as necessary
+            qk = db.quote(k)
+            sql.append("\n  LEFT OUTER JOIN ticket_links AS %s ON " \
+                       "(id=%s.source AND %s.type='%s')" % (qk, qk, qk, qk))
+
         # Join with the enum table for proper sorting
         for col in [c for c in enum_columns
                     if c == self.order or c == self.group or c == 'priority']:
             return None
 
         def get_constraint_sql(name, value, mode, neg):
-            if name not in custom_fields:
+            if name in custom_fields:
+                col = '%s.value' % db.quote(name)
+            elif name in link_fields:
+                col = name + '.destination'
+                # for now, search for any value and ignore all modes
+                dst_ids = TicketSystem(self.env).parse_links(value)
+                sql_snippets = " OR ".join([("%s%s=CAST(%%s AS int)" % (col, neg and '!' or '')) 
+                                for _ in dst_ids])
+                return sql_snippets, [dst_id for dst_id in dst_ids] 
+            else:
                 col = 't.' + name
-            else:
-                col = '%s.value' % db.quote(name)
             value = value[len(mode) + neg:]
 
             if name in self.time_fields:
             {'name': _("is"), 'value': ""},
             {'name': _("is not"), 'value': "!"},
         ]
+        modes['link'] = [
+            {'name': _("contains"), 'value': ""}
+        ]
         return modes
 
     def template_data(self, context, tickets, orig_list=None, orig_time=None,
         cols = self.get_columns()
         labels = TicketSystem(self.env).get_ticket_field_labels()
         wikify = set(f['name'] for f in self.fields 
-                     if f['type'] == 'text' and f.get('format') == 'wiki')
+                     if f['type'] in ('text', 'link') and f.get('format') == 'wiki')
 
         headers = [{
             'name': col, 'label': labels.get(col, _('Ticket')),
         max = args.get('max')
         if max is None and format in ('csv', 'tab'):
             max = 0 # unlimited unless specified explicitly
+            
         query = Query(self.env, req.args.get('report'),
                       constraints, cols, args.get('order'),
                       'desc' in args, args.get('group'),

trac/ticket/templates/query.html

                   <tbody py:for="field_name in field_names" py:if="field_name in constraints"
                          py:with="field = fields[field_name]; n_field_name = clause_pre + field_name;
                                   constraint = constraints[field_name];
-                                  multiline = field.type in ('select', 'text', 'textarea', 'time')">
+                                  multiline = field.type in ('select', 'text', 'textarea', 'time', 'link')">
                     <tr py:for="constraint_idx, constraint_value in enumerate(constraint['values'])"
                         class="${field_name}" py:if="multiline or constraint_idx == 0">
                       <td>
                           <label for="_${n_field_name}_off" class="control">no</label>
                         </py:when>
 
-                        <py:when test="field.type in ('text', 'textarea', 'id')">
+                        <py:when test="field.type in ('text', 'textarea', 'link')">
                           <input type="text" name="${n_field_name}" value="$constraint_value" size="42" />
                         </py:when>
                         

trac/ticket/tests/links.py

+from trac.ticket.model import Ticket
+from trac.test import EnvironmentStub, Mock
+from trac.ticket.links import LinksProvider
+from trac.ticket.api import TicketSystem
+from trac.ticket.query import Query
+from trac.util.datefmt import utc
+import unittest
+
+class TicketTestCase(unittest.TestCase):
+    def setUp(self):
+        self.env = EnvironmentStub(default_data=True)
+        self.env.config.set('ticket-links', 'dependency', 'dependson,dependent')
+        self.env.config.set('ticket-links', 'dependency.validator', 'no_cycle')
+        self.req = Mock(href=self.env.href, authname='anonymous', tz=utc)
+
+    def _insert_ticket(self, summary, **kw):
+        """Helper for inserting a ticket into the database"""
+        ticket = Ticket(self.env)
+        for k,v in kw.items():
+            ticket[k] = v
+        return ticket.insert()
+
+    def _create_a_ticket(self):
+        ticket = Ticket(self.env)
+        ticket['reporter'] = 'santa'
+        ticket['summary'] = 'Foo'
+        ticket['foo'] = 'This is a custom field'
+        return ticket
+
+    # TicketSystem tests
+    
+    def test_get_ticket_fields(self):
+        ticket_system = TicketSystem(self.env)
+        fields = ticket_system.get_ticket_fields()
+        link_fields = [f['name'] for f in fields if f.get('link')]
+        self.assertEquals(2, len(link_fields))
+
+    def test_update_links(self):
+        ticket = self._create_a_ticket()
+        ticket.insert()
+        ticket = self._create_a_ticket()
+        ticket['dependson'] = '#1, #2'
+        ticket.insert()
+
+        # Check if ticket link in #1 has been updated
+        ticket = Ticket(self.env, 1)
+        self.assertEqual(1, ticket.id)
+        self.assertEqual('#2', ticket['dependent'])
+        
+        # Remove link from #2 to #1
+        ticket = Ticket(self.env, 2)
+        ticket['dependson'] = '#2'
+        ticket.save_changes("me", "testing")
+
+        # Check if ticket link in #1 has been updated
+        ticket = Ticket(self.env, 1)
+        self.assertEqual(1, ticket.id)
+        self.assertEqual('', ticket['dependent'])
+        
+    def test_save_retrieve_links(self):
+        ticket = self._create_a_ticket()
+        ticket.insert()
+        ticket = self._create_a_ticket()
+        ticket['dependson'] = '#1, #2'
+        ticket.insert()
+
+        # Check if ticket link in #1 has been updated
+        ticket = Ticket(self.env, 1)
+        self.assertEqual(1, ticket.id)
+        self.assertEqual('#2', ticket['dependent'])
+
+    def test_query_by_result(self):
+        ticket = self._create_a_ticket()
+        ticket.insert()
+        ticket = self._create_a_ticket()
+        ticket['dependson'] = '#1'
+        ticket.insert()
+        query = Query.from_string(self.env, 'dependson=1', order='id')
+        sql, args = query.get_sql()
+        tickets = query.execute(self.req)
+        self.assertEqual(len(tickets), 1)
+        self.assertEqual(tickets[0]['id'], 2)
+    
+    def test_query_by_result2(self):
+        ticket = self._create_a_ticket()
+        ticket.insert()
+        ticket = self._create_a_ticket()
+        ticket['dependson'] = '#1'
+        ticket.insert()
+        query = Query.from_string(self.env, 'dependent=2', order='id')
+        sql, args = query.get_sql()
+        tickets = query.execute(self.req)
+        self.assertEqual(len(tickets), 1)
+        self.assertEqual(tickets[0]['id'], 1)
+    
+
+    def test_validator_no_cyle(self):
+        ticket1 = self._create_a_ticket()
+        ticket1.insert()
+        ticket2 = self._create_a_ticket()
+        ticket2['dependson'] = '#1'
+        links_provider = LinksProvider(self.env)
+        issues = links_provider.validate_ticket(self.req, ticket2)
+        self.assertEquals(sum(1 for _ in issues), 0)
+        ticket2.insert()
+        ticket1['dependson'] = '#2'
+        issues = links_provider.validate_ticket(self.req, ticket1)
+        self.assertEquals(sum(1 for _ in issues), 1)
+        

trac/ticket/web_ui.py

             type_ = field['type']
  
             # enable a link to custom query for all choice fields
-            if type_ not in ['text', 'textarea']:
+            if type_ not in ['text', 'textarea', 'link']:
                 field['rendered'] = self._query_link(req, name, ticket[name])
 
             # per field settings
                 if value in ('1', '0'):
                     field['rendered'] = self._query_link(req, name, value,
                                 value == '1' and _("yes") or _("no"))
-            elif type_ == 'text':
+            elif type_ in ('text', 'link'):
                 if field.get('format') == 'wiki':
                     field['rendered'] = format_to_oneliner(self.env, context,
                                                            ticket[name])

trac/upgrades/db22.py

File contents unchanged.

trac/upgrades/db27.py

+from trac.db import Table, Column, Index, DatabaseManager
+
+def do_upgrade(env, ver, cursor):
+    """Add new table for links
+    """
+    table = Table('ticket_links', key=('source', 'destination', 'type'))[
+        Column('source', type='int'),
+        Column('destination', type='int'),
+        Column('type')]
+    db_connector, _ = DatabaseManager(env)._get_connector()
+    for stmt in db_connector.to_sql(table):
+        cursor.execute(stmt)