Edgewall Software

Ticket #31: trac_r7937_ticket_relations.patch

File trac_r7937_ticket_relations.patch, 26.7 KB (added by Joachim Hoessler <hoessler@…>, 3 years ago)

Another implementation for links between tickets

  • setup.py

     
    107107        trac.ticket.report = trac.ticket.report 
    108108        trac.ticket.roadmap = trac.ticket.roadmap 
    109109        trac.ticket.web_ui = trac.ticket.web_ui 
     110        trac.ticket.links = trac.ticket.links 
    110111        trac.timeline = trac.timeline.web_ui 
    111112        trac.versioncontrol.admin = trac.versioncontrol.admin 
    112113        trac.versioncontrol.svn_fs = trac.versioncontrol.svn_fs 
  • trac/db_default.py

     
    1717from trac.db import Table, Column, Index 
    1818 
    1919# Database version identifier. Used for automatic upgrades. 
    20 db_version = 21 
     20db_version = 22 
    2121 
    2222def __mkreports(reports): 
    2323    """Utility function used to create report data in same syntax as the 
     
    131131        Column('ticket', type='int'), 
    132132        Column('name'), 
    133133        Column('value')], 
     134    Table('ticket_links', key=('source', 'destination', 'type'))[ 
     135        Column('source', type='int'), 
     136        Column('destination', type='int'), 
     137        Column('type')], 
    134138    Table('enum', key=('type', 'name'))[ 
    135139        Column('type'), 
    136140        Column('name'), 
  • trac/upgrades/db22.py

     
     1from trac.db import Table, Column, Index, DatabaseManager 
     2 
     3def 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

     
    244244        td.className = "filter"; 
    245245        if (property.type == "select") { 
    246246          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")) { 
    248249          focusElement = createText(propertyName, 42); 
    249250        } 
    250251        td.appendChild(focusElement); 
  • trac/ticket/web_ui.py

     
    11211121            type_ = field['type'] 
    11221122  
    11231123            # 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']: 
    11251125                field['rendered'] = self._query_link(req, name, ticket[name]) 
    11261126 
    11271127            # per field settings 
     
    11881188                if value in ('1', '0'): 
    11891189                    field['rendered'] = self._query_link(req, name, value, 
    11901190                                value == '1' and _('yes') or _('no')) 
    1191             elif type_ == 'text': 
     1191            elif type_ in ('text', 'link'): 
    11921192                if field.get('format') == 'wiki': 
    11931193                    field['rendered'] = format_to_oneliner(self.env, context, 
    11941194                                                           ticket[name]) 
  • trac/ticket/links.py

     
     1from trac.ticket.api import ITicketLinkController, ITicketManipulator,\ 
     2    TicketSystem 
     3from trac.ticket.model import Ticket 
     4from copy import copy 
     5from trac.core import Component, implements 
     6 
     7class 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

     
    393393        cols.extend([c for c in self.constraints.keys() if not c in cols]) 
    394394 
    395395        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] 
    396397 
    397398        sql = [] 
    398         sql.append("SELECT " + ",".join(['t.%s AS %s' % (c, c) for c in cols 
    399                                          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)])) 
    400401        sql.append(",priority.value AS priority_value") 
    401402        for k in [k for k in cols if k in custom_fields]: 
    402403            sql.append(",%s.value AS %s" % (k, k)) 
     
    407408           sql.append("\n  LEFT OUTER JOIN ticket_custom AS %s ON " \ 
    408409                      "(id=%s.ticket AND %s.name='%s')" % (k, k, k, k)) 
    409410 
     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 
    410416        # Join with the enum table for proper sorting 
    411417        for col in [c for c in enum_columns 
    412418                    if c == self.order or c == self.group or c == 'priority']: 
     
    421427                       % (col, col, col)) 
    422428 
    423429        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: 
    427431                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 
    428441            value = value[len(mode) + neg:] 
    429442 
    430443            if name in self.time_fields: 
     
    598611        cols = self.get_columns() 
    599612        labels = dict([(f['name'], f['label']) for f in self.fields]) 
    600613        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') 
    602615 
    603616        headers = [{ 
    604617            'name': col, 'label': labels.get(col, _('Ticket')), 
     
    634647            {'name': _("is"), 'value': ""}, 
    635648            {'name': _("is not"), 'value': "!"} 
    636649        ] 
     650        modes['link'] = [ 
     651            {'name': _("contains"), 'value': ""} 
     652        ] 
    637653 
    638654        groups = {} 
    639655        groupsequence = [] 
     
    819835        max = args.get('max') 
    820836        if max is None and format in ('csv', 'tab'): 
    821837            max = 0 # unlimited unless specified explicitly 
     838             
    822839        query = Query(self.env, req.args.get('report'), 
    823840                      constraints, cols, args.get('order'), 
    824841                      'desc' in args, args.get('group'), 
     
    832849            for var in ('query_constraints', 'query_time', 'query_tickets'): 
    833850                if var in req.session: 
    834851                    del req.session[var] 
     852            print query.get_href(req.href) 
    835853            req.redirect(query.get_href(req.href)) 
    836854 
    837855        # Add registered converters 
     
    969987        # Don't allow the user to remove the id column         
    970988        data['all_columns'].remove('id') 
    971989        data['all_textareas'] = query.get_all_textareas() 
     990        data['ticket_indent'] = range(100) 
    972991 
    973992        add_stylesheet(req, 'common/css/report.css') 
    974993        add_script(req, 'common/js/query.js') 
  • trac/ticket/templates/query.html

     
    4343            <py:for each="field_name in field_names" py:with="field = fields[field_name]"> 
    4444              <py:for each="constraint_name, constraint in constraints.items()"> 
    4545                <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')"> 
    4747                  <py:for each="constraint_idx, constraint_value in enumerate(constraint['values'])"> 
    4848                    <tr class="${field_name}" py:if="multiline or constraint_idx == 0"> 
    4949                      <py:choose test="constraint_idx"> 
     
    9393                          <label for="${field_name}_off" class="control">no</label> 
    9494                        </py:when> 
    9595 
    96                         <py:when test="field.type in ('text', 'textarea')"> 
     96                        <py:when test="field.type in ('text', 'textarea', 'link')"> 
    9797                          <input type="text" name="${field_name}" value="$constraint_value" size="42" /> 
    9898                        </py:when> 
    9999                         
  • trac/ticket/model.py

     
    9797            db = self._get_db(db) 
    9898 
    9999            # 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'))] 
    101101            cursor = db.cursor() 
    102102            cursor.execute("SELECT %s FROM ticket WHERE id=%%s" 
    103103                           % ','.join(std_fields), (tkt_id,)) 
     
    123123        for name, value in cursor: 
    124124            if name in custom_fields and value is not None: 
    125125                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) 
    126135 
     136            link_list.sort() 
     137            self.values[end] = ', '.join(['#%s' % v for v in link_list]) 
     138             
    127139    def __getitem__(self, name): 
    128140        return self.values.get(name) 
    129141 
     
    197209        # Insert ticket record 
    198210        std_fields = [] 
    199211        custom_fields = [] 
     212        link_fields = [] 
    200213        for f in self.fields: 
    201214            fname = f['name'] 
    202215            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'): 
    204220                    custom_fields.append(fname) 
    205221                else: 
    206222                    std_fields.append(fname) 
     
    215231            cursor.executemany("INSERT INTO ticket_custom (ticket,name,value) " 
    216232                               "VALUES (%s,%s,%s)", [(tkt_id, name, self[name]) 
    217233                                                     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                         
    218250        if handle_ta: 
    219251            db.commit() 
    220252 
     
    271303            self.values['cc'] = ', '.join(cclist) 
    272304 
    273305        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')] 
    274307        for name in self._old.keys(): 
     308            # ignore link fields here 
     309            if name in link_fields: 
     310                continue 
    275311            if name in custom_fields: 
    276312                cursor.execute("SELECT * FROM ticket_custom "  
    277313                               "WHERE ticket=%s and name=%s", (self.id, name)) 
     
    300336        cursor.execute("UPDATE ticket SET changetime=%s WHERE id=%s", 
    301337                       (when_ts, self.id)) 
    302338 
     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 
    303371        if handle_ta: 
    304372            db.commit() 
    305373        old_values = self._old 
    306374        self._old = {} 
    307375        self.values['changetime'] = when 
    308  
     376         
    309377        for listener in TicketSystem(self.env).change_listeners: 
    310378            listener.ticket_changed(self, comment, author, old_values) 
    311379        return True 
    312  
     380     
    313381    def get_changelog(self, when=None, db=None): 
    314382        """Return the changelog as a list of tuples of the form 
    315383        (time, author, field, oldvalue, newvalue, permanent). 
     
    357425        cursor.execute("DELETE FROM ticket WHERE id=%s", (self.id,)) 
    358426        cursor.execute("DELETE FROM ticket_change WHERE ticket=%s", (self.id,)) 
    359427        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)) 
    360430 
    361431        if handle_ta: 
    362432            db.commit() 
  • trac/ticket/api.py

     
    140140        Must return a list of `(field, message)` tuples, one for each problem 
    141141        detected. `field` can be `None` to indicate an overall problem with the 
    142142        ticket. Therefore, a return value of `[]` means everything is OK.""" 
    143  
     143         
     144class 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.""" 
    144149 
     150    def render_end(end): 
     151        """returns label""" 
     152         
    145153class TicketSystem(Component): 
    146154    implements(IPermissionRequestor, IWikiSyntaxProvider, IResourceManager) 
    147155 
     
    151159        include_missing=False, 
    152160        doc="""Ordered list of workflow controllers to use for ticket actions 
    153161            (''since 0.11'').""") 
     162    link_controllers = ExtensionPoint(ITicketLinkController) 
    154163 
    155164    restrict_owner = BoolOption('ticket', 'restrict_owner', 'false', 
    156165        """Make the owner field of tickets use a drop-down menu. See 
     
    160169    _fields = None 
    161170    _custom_fields = None 
    162171 
     172    # regular expression to match links 
     173    NUMBERS_RE = re.compile(r'\d+', re.U) 
     174 
    163175    def __init__(self): 
    164176        self.log.debug('action controllers for ticket workflow: %r' %  
    165177                [c.__class__.__name__ for c in self.action_controllers]) 
    166178        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         
    167186 
    168187    # Public API 
    169188 
     
    283302                continue 
    284303            field['custom'] = True 
    285304            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) 
    286312 
    287313        return fields 
    288  
     314     
    289315    reserved_field_names = ['report', 'order', 'desc', 'group', 'groupdesc', 
    290316                            'col', 'row', 'format', 'max', 'page', 'verbose'] 
    291317 
     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 
    292327    def get_custom_fields(self): 
    293328        if self._custom_fields is None: 
    294329            self._fields_lock.acquire() 
     
    458493            return "%s (%s)" % (summary, status) 
    459494        else: 
    460495            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

     
     1from trac.ticket.model import Ticket 
     2from trac.test import EnvironmentStub, Mock 
     3from trac.ticket.links import LinksProvider 
     4from trac.ticket.api import TicketSystem 
     5from trac.ticket.query import Query 
     6from trac.util.datefmt import utc 
     7import unittest 
     8 
     9class 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