Edgewall Software

Ticket #31: trac-0.8.1-ticket-relations.patch

File trac-0.8.1-ticket-relations.patch, 18.3 kB (added by havarden@…, 3 years ago)

Another implementation for ticket relations. Quick and dirty, but can be extended to support backlinks. Added to show the design ideas rather than the implementation.

  • templates/macros.cs

    diff -Naur trac-0.8.1-orig/templates/macros.cs trac-0.8.1/templates/macros.cs
    old new  
    1010 </select><?cs 
    1111/def?> 
    1212 
     13<?cs def:hdf_select_with_values(options, name, selected) ?> 
     14 <select size="1" id="<?cs var:name ?>" name="<?cs var:name ?>"><?cs 
     15  each:option = options ?><?cs 
     16   if option.name == $selected ?> 
     17    <option value="<?cs var:option.value ?>" selected="selected"> 
     18    <?cs var:option.name ?></option><?cs 
     19   else ?> 
     20    <option value="<?cs var:option.value ?>"><?cs var:option.name ?></option><?cs 
     21   /if ?><?cs 
     22  /each ?> 
     23 </select><?cs 
     24/def?> 
     25 
    1326<?cs def:hdf_select_multiple(options, name, size) ?> 
    1427 <select size="<?cs var:size ?>" id="<?cs var:name ?>" name="<?cs 
    1528   var:name ?>" multiple="multiple"><?cs 
  • templates/ticket.cs

    diff -Naur trac-0.8.1-orig/templates/ticket.cs trac-0.8.1/templates/ticket.cs
    old new  
    2929   /if ?></td><?cs if numprops % #2 && !$last_prop || fullrow ?> 
    3030 </tr><tr><?cs /if ?><?cs set numprops = $numprops + #1 - fullrow ?><?cs 
    3131 /def ?> 
     32 <?cs def:ticketrel(rel) ?> 
     33 <tr> 
     34 <form id="removerelation" method="post" action="<?cs var:cgi_location?>/ticket/<?cs 
     35  var:ticket.id ?>"> 
     36  <input type="hidden" name="rel_from" value="<?cs var:rel.from_id ?>" /> 
     37  <input type="hidden" name="rel_type" value="<?cs var:rel.type ?>" /> 
     38  <input type="hidden" name="rel_to" value="<?cs var:rel.to_id ?>" /> 
     39  <td colspan="2"> 
     40    <div title="<?cs var:rel.description ?>"><?cs var:rel.relation ?></div> 
     41  <td><input type="submit" name="removerel_submit" value="Remove" /></td> 
     42 </form> 
     43 </tr><?cs 
     44 /def ?> 
    3245 
    3346<div id="ticket"> 
    3447 <div class="date"><?cs var:ticket.opened ?></div> 
     
    6679  /each ?> 
    6780 </tr></table><?cs /if ?> 
    6881 <hr /> 
     82 <table> 
     83 <tr><th colspan="3">Ticket relations</th></tr> 
     84 <?cs each:rel = ticket.relations ?> 
     85     <?cs call:ticketrel(rel) ?> 
     86 <?cs /each ?> 
     87 <?cs if trac.acl.TICKET_MODIFY ?> 
     88  <form id="newrelation" method="post" action="<?cs var:cgi_location?>/ticket/<?cs 
     89    var:ticket.id ?>"> 
     90  <tr> 
     91  <td align="center"><?cs call:hdf_select_with_values(ticket.all_tickets, "rel_from", "This ticket") ?></td> 
     92  <td align="center"><?cs call:hdf_select(ticket.valid_relations, "rel_type", 0) ?><br /> 
     93  <input type="submit" name="newrel_submit" value="Add Relation" /></td> 
     94  <td align="center"><?cs call:hdf_select_with_values(ticket.all_tickets, "rel_to", "this ticket") ?></td> 
     95  </tr> 
     96 </form> 
     97 <?cs /if ?> 
     98 </table> 
     99  
     100 <hr /> 
    69101 <h3>Description<?cs if:ticket.reporter ?> by <?cs 
    70102   var:ticket.reporter ?><?cs /if ?>:</h3> 
    71103 <div class="description"> 
     
    131163</div><?cs /if ?> 
    132164 
    133165<?cs if $trac.acl.TICKET_MODIFY ?> 
    134 <form action="<?cs var:cgi_location ?>#preview" method="post"> 
    135166 <hr /> 
    136167 <h3><a name="edit" onfocus="document.getElementById('comment').focus()">Add/Change #<?cs 
    137168   var:ticket.id ?> (<?cs var:ticket.summary ?>)</a></h3> 
     169<form action="<?cs var:cgi_location ?>#preview" method="post"> 
    138170 <div class="field"> 
    139171  <input type="hidden" name="mode" value="ticket" /> 
    140172  <input type="hidden" name="id"   value="<?cs var:ticket.id ?>" /> 
  • trac/Ticket.py

    diff -Naur trac-0.8.1-orig/trac/Ticket.py trac-0.8.1/trac/Ticket.py
    old new  
    2626from UserDict import UserDict 
    2727 
    2828import perm 
     29import relation_hooks 
     30import sqlite 
    2931import util 
    3032from Module import Module 
    3133from WikiFormatter import wiki_to_html 
     
    4244    def __init__(self, *args): 
    4345        UserDict.__init__(self) 
    4446        self._old = {} 
     47        self.relations = [] 
    4548        if len(args) == 2: 
    4649            self._fetch_ticket(*args) 
    4750 
     
    7881        if rows: 
    7982            for r in rows: 
    8083                self['custom_' + r[0]] = r[1] 
     84        cursor.close() 
     85 
     86        cursor = db.cursor() 
     87        cursor.execute("SELECT * FROM xref WHERE source='ticket:%i' OR target='ticket:%i' ORDER BY context, source", id, id) 
     88        rows = cursor.fetchall() 
     89        if rows: 
     90            for r in rows: 
     91                self.relations.append({'from':r['source'], 'type':r['context'], 
     92                        'to':r['target']}) 
     93        cursor.close() 
     94 
    8195        self._forget_changes() 
    8296 
    8397    def populate(self, dict): 
     
    266280            hdf.setValue('%s.height' % pfx, f['height']) 
    267281        i += 1 
    268282 
     283def insert_relation_fields(env, db, hdf, relations, id): 
     284    i = 0 
     285    for rel in relations: 
     286        name = 'ticket.relations.%i' % i 
     287        rel_string = '' 
     288        if rel['from'] == id: 
     289            rel_string += 'This ticket ' 
     290        else: 
     291            rel_string += '%s ' % rel['from'] 
     292        rel_string += rel['type'] 
     293        if rel['to'] == id: 
     294            rel_string += ' this ticket.' 
     295        else: 
     296            rel_string += ' %s.' % rel['to'] 
     297        hdf.setValue('%s.relation' % name, wiki_to_html(rel_string, hdf, env, db)) 
     298        hdf.setValue('%s.from_id' % name, str(rel['from'])) 
     299        hdf.setValue('%s.to_id' % name, str(rel['to'])) 
     300        hdf.setValue('%s.type' % name, str(rel['type'])) 
     301        hdf.setValue('%s.description' % name, env.get_config('relation:' + rel['type'], 'description', 'No description available')) 
     302        i += 1 
     303 
     304def _valid_ticket(ticket, db): 
     305    cursor = db.cursor() 
     306    cursor.execute('SELECT COUNT(*) FROM ticket WHERE id=%s', ticket) 
     307    row = cursor.fetchone() 
     308    if row: 
     309        if row['COUNT(*)'] > 0: 
     310            return True 
     311    return False 
     312 
     313def _valid_relation(relation, env): 
     314    relations = _get_valid_relations(env) 
     315    for i in relations: 
     316        if relation == i: 
     317            return True 
     318    return False 
     319 
     320def _get_valid_relations(env): 
     321    """Returns a list of all valid relations (fetched from configuration file). 
     322    To set valid relations, edit the configuration and change this part: 
     323     
     324        [ticket] 
     325        valid_relations = depends on, related to, duplicate of 
     326 
     327    and add/remove the relations you want, i.e. 
     328 
     329        [ticket] 
     330        valid_relations = depends on, blocks, duplicate of, is subissue of 
     331 
     332    Each relation may also have a set of hooks associated with it. The valid hooks 
     333    to put functions in are: 
     334        hooks_ticket_modify 
     335        hooks_relation_add 
     336        hooks_relation_remove 
     337 
     338    The hooks are defined in the configuration file as follows: 
     339        [relation:<relation name>] 
     340        hooks_ticket_modify   = <function_name> [ , <function_name> ... ] 
     341        hooks_relation_add    = <function_name> [ , <function_name> ... ] 
     342        hooks_relation_remove = <function_name> [ , <function_name> ... ] 
     343 
     344    An example: 
     345        [relation:depends on] 
     346        hooks_relation_add    = check_circular 
     347    """ 
     348 
     349    rels = env.get_config('ticket', 'valid_relations') 
     350    return [i.strip() for i in rels.split(',')] 
     351 
    269352 
    270353class NewticketModule(Module): 
    271354    template_name = 'newticket.cs' 
     
    369452 
    370453        now = int(time.time()) 
    371454 
     455        cursor = self.db.cursor() 
     456        cursor.execute("SELECT source, target, context FROM xref WHERE source='ticket:%s' OR target='ticket:%s'", id, id) 
     457        rows = cursor.fetchall() 
     458        if rows: 
     459            for r in rows: 
     460                self.call_relation_hooks('hooks_ticket_modify', action, r[0], r[1], r[2], id) 
     461 
    372462        ticket.save_changes(self.db, 
    373463                            self.args.get('author', self.req.authname), 
    374464                            self.args.get('comment'), 
     
    378468        tn.notify(ticket, newticket=0, modtime=now) 
    379469        self.req.redirect(self.env.href.ticket(id)) 
    380470 
     471    def save_relation(self, id): 
     472        self.perm.assert_permission(perm.TICKET_MODIFY) 
     473        ticket = Ticket(self.db, id) 
     474        newrel_from = self.args.get('rel_from') 
     475        newrel_type = self.args.get('rel_type') 
     476        newrel_to = self.args.get('rel_to') 
     477 
     478        # Check sanity of arguments 
     479        if not newrel_from or not newrel_type or not newrel_to: 
     480            raise util.TracError('Ticket relations must have to, from and type fields.') 
     481        if not _valid_ticket(newrel_from, self.db) or not _valid_ticket(newrel_to, self.db): 
     482            raise util.TracError('Ticket relations must use existing tickets.') 
     483        if not _valid_relation(newrel_type, self.env): 
     484            raise util.TracError('Ticket relations must be in the set of valid relations: %s' % str(_get_valid_relations(self.env))) 
     485        if (not newrel_from == str(id) and not newrel_to == str(id)) or (newrel_from == newrel_to): 
     486            raise util.TracError('Either the from field or the to field, but not both, must be this ticket.') 
     487 
     488        # Call hooks 
     489        self.call_relation_hooks('hooks_relation_add', self.args.get('action', None), newrel_from, newrel_to, newrel_type, id) 
     490 
     491        # Insert into database and commit 
     492        cursor = self.db.cursor() 
     493        try: 
     494            cursor.execute("INSERT INTO xref VALUES (%s, %s, %s)", 'ticket:' + str(newrel_from), 'ticket:' + str(newrel_to), newrel_type) 
     495        except sqlite.IntegrityError: 
     496            self.db.rollback() 
     497            raise util.TracError('This relation already exists.') 
     498        cursor.close() 
     499        self.db.commit() 
     500 
     501    def remove_relation(self, id): 
     502        self.perm.assert_permission(perm.TICKET_MODIFY) 
     503        ticket = Ticket(self.db, id) 
     504        rel_from = self.args.get('rel_from') 
     505        rel_type = self.args.get('rel_type') 
     506        rel_to = self.args.get('rel_to') 
     507 
     508        # Check sanity. 
     509        if not rel_from or not rel_type or not rel_to: 
     510            raise util.TracError('Cannot remove relation: Required information is missing.') 
     511 
     512        # Call hooks 
     513        self.call_relation_hooks('hooks_relation_remove', self.args.get('action', None), rel_from, rel_to, rel_type, id) 
     514 
     515        # Commit to database. 
     516        cursor = self.db.cursor() 
     517        cursor.execute("DELETE FROM xref WHERE source=%s AND target=%s and context=%s", rel_from, rel_to, rel_type) 
     518        cursor.close() 
     519        self.db.commit() 
     520 
     521    def call_relation_hooks(self, hook_type, action, rel_from, rel_to, rel_type, id): 
     522        """Calls all hooks of the given hook_type.""" 
     523        for hook_name in self.env.get_config('relation:' + rel_type, 
     524                hook_type).split(','): 
     525            hook_name = hook_name.strip() 
     526            if hook_name == '': 
     527                continue 
     528            if not hasattr(relation_hooks, hook_name): 
     529                raise util.TracError('Could not add relation: Hook "%s" not found.' % hook_name) 
     530            getattr(relation_hooks, hook_name)(self.db, self.env, rel_from, rel_to, 
     531                    rel_type, action, id) 
     532 
    381533    def insert_ticket_data(self, hdf, id, ticket, reporter_id): 
    382534        """Insert ticket data into the hdf""" 
    383535        evals = util.mydict(zip(ticket.keys(), 
     
    400552        util.hdf_add_if_missing(self.req.hdf, 'enums.severity', ticket['severity']) 
    401553        util.hdf_add_if_missing(self.req.hdf, 'enums.resolution', 'fixed') 
    402554 
     555 
     556 
    403557        self.req.hdf.setValue('ticket.reporter_id', util.escape(reporter_id)) 
    404558        self.req.hdf.setValue('title', '#%d (%s)' % (id, util.escape(ticket['summary']))) 
    405559        self.req.hdf.setValue('ticket.description.formatted', 
     
    427581            idx = idx + 1 
    428582 
    429583        insert_custom_fields(self.env, hdf, ticket) 
     584        # Insert all relations in the database 
     585        insert_relation_fields(self.env, self.db, hdf, ticket.relations, id) 
     586 
     587        # Add list of all available tickets and valid relations 
     588        cursor = self.db.cursor() 
     589        cursor.execute('SELECT SUBSTR(id, 0, 1024) FROM ticket ORDER BY id') 
     590        rows = cursor.fetchall() 
     591        ctr = 0 
     592        if rows: 
     593            for r in rows: 
     594                if str(r[0]) == str(id): 
     595                    hdf.setValue('ticket.all_tickets.%d.name' % ctr, 'This ticket') 
     596                else: 
     597                    hdf.setValue('ticket.all_tickets.%d.name' % ctr, r[0]) 
     598                hdf.setValue('ticket.all_tickets.%d.value' % ctr, r[0]) 
     599                ctr += 1 
     600 
     601        ctr = 0 
     602        for i in _get_valid_relations(self.env): 
     603            hdf.setValue('ticket.valid_relations.%d.name' % ctr, i) 
     604            ctr += 1 
     605 
    430606        # List attached files 
    431607        self.env.get_attachments_hdf(self.db, 'ticket', str(id), self.req.hdf, 
    432608                                     'ticket.attachments') 
     
    440616        if not self.args.has_key('id'): 
    441617            self.req.redirect(self.env.href.wiki()) 
    442618 
     619 
    443620        id = int(self.args.get('id')) 
    444621 
    445622        if not preview \ 
    446623               and action in ['leave', 'accept', 'reopen', 'resolve', 'reassign']: 
    447624            self.save_changes (id) 
    448625 
     626        if not preview and self.args.has_key('newrel_submit'): 
     627            self.save_relation(id) 
     628        if not preview and self.args.has_key('removerel_submit'): 
     629            self.remove_relation(id) 
     630 
    449631        ticket = Ticket(self.db, id) 
    450632        reporter_id = util.get_reporter_id(self.req) 
    451633 
  • trac/db_default.py

    diff -Naur trac-0.8.1-orig/trac/db_default.py trac-0.8.1/trac/db_default.py
    old new  
    111111       value            text, 
    112112       UNIQUE(ticket,name) 
    113113); 
     114 
     115CREATE TABLE xref ( 
     116       source           text, 
     117       target           text, 
     118       context          text 
     119); 
     120 
    114121CREATE TABLE report ( 
    115122        id              integer PRIMARY KEY, 
    116123        author          text, 
  • trac/relation_hooks.py

    diff -Naur trac-0.8.1-orig/trac/relation_hooks.py trac-0.8.1/trac/relation_hooks.py
    old new  
     1import util 
     2 
     3def dummy(db, env, ticket_from, ticket_to, relation_type, action, target_ticket): 
     4    """A dummy hook that does nothing.""" 
     5    return 
     6 
     7def check_circular(db, env, ticket_from, ticket_to, relation_type, action, target_ticket): 
     8    """Checks for a circular relation between the two tickets.""" 
     9    _circular(db, ticket_from, ticket_to, relation_type) 
     10    return 
     11 
     12def _circular(db, target_ticket, origin, relation_type): 
     13    cursor = db.cursor() 
     14    cursor.execute('SELECT target FROM xref WHERE source=%s AND context=%s', origin, relation_type) 
     15    rows = cursor.fetchall() 
     16    if rows: 
     17        for r in rows: 
     18            if r[0] == target_ticket: 
     19                cursor.close() 
     20                raise util.TracError('This relation creates a circular relationship between tickets, and cannot be created.') 
     21            _circular(db, target_ticket, r[0], relation_type) 
     22    cursor.close() 
     23 
     24 
     25def check_excludes(db, env, ticket_from, ticket_to, relation_type, action, target_ticket): 
     26    """Checks if there already is a relation between the two tickets that excludes 
     27    the relation we are trying to add.""" 
     28    excludes = env.get_config('relation:' + relation_type, 'excludes_relations') 
     29    if not excludes: 
     30        return 
     31    excludes = excludes.split(',') 
     32    cursor = db.cursor() 
     33    sql = 'SELECT COUNT(*) FROM xref WHERE source=%s AND target=%s AND' 
     34    where = [] 
     35    args = [ticket_from, ticket_to] 
     36    for i in excludes: 
     37        where.append('context=%s') 
     38        args.append(i) 
     39    sql += ('(%s)') % " OR ".join(where) 
     40    cursor.execute(sql, *args) 
     41    row = cursor.fetchone() 
     42    if row: 
     43        if row[0] > 0: 
     44            raise util.TracError('Cannot add relation; another relation which excludes this one already exists.') 
     45 
     46def check_from_tickets_closed(db, env, ticket_from, ticket_to, relation_type, action, target_ticket): 
     47    """This hook checks that closed(source) => closed(target) and 
     48    open(source) => open(target) for all targets which have a relation 
     49    of type relation_type from source to target.""" 
     50    if not (action == 'resolve' or action == 'reopen'): 
     51        return 
     52    cursor = db.cursor() 
     53    if action == 'resolve' and ticket_to == target_ticket: 
     54        cursor.execute("SELECT COUNT(*) FROM xref r, ticket t WHERE r.context=%s AND r.source='ticket:'+t.id AND t.status != 'closed' AND r.target=%s", relation_type, target_ticket) 
     55    elif action == 'reopen' and ticket_from == target_ticket: 
     56        cursor.execute("SELECT COUNT(*) FROM xref r, ticket t WHERE r.context=%s AND r.source=%s AND t.status=\'closed\' AND r.target='ticket:'+t.id", relation_type, target_ticket) 
     57    else: 
     58        return 
     59    row = cursor.fetchone() 
     60    if row and row[0] > 0: 
     61        if action == 'resolve': 
     62            raise util.TracError('One or more of this ticket\'s relations require another ticket to be resolved before this one can be resolved.') 
     63        elif action == 'reopen': 
     64            raise util.TracError('One or more of this ticket\'s relations require another ticket to be reopened before this one can be reopened.') 
     65 
     66def check_to_tickets_closed(db, env, ticket_from, ticket_to, relation_type, action, target_ticket): 
     67    """This hook checks that closed(source) => closed(target) and 
     68    open(source) => open(target) for all targets which have a relation 
     69    of type relation_type from source to target.""" 
     70    if not (action == 'resolve' or action == 'reopen'): 
     71        return 
     72    cursor = db.cursor() 
     73    if action == 'resolve' and ticket_from == target_ticket: 
     74        cursor.execute("SELECT COUNT(*) FROM xref r, ticket t WHERE r.context=%s AND r.target='ticket:'+t.id AND t.status != 'closed' AND r.source=%s", relation_type, target_ticket) 
     75    elif action == 'reopen' and ticket_to == target_ticket: 
     76        cursor.execute("SELECT COUNT(*) FROM xref r, ticket t WHERE r.context=%s AND r.target=%s AND t.status='closed' AND r.source='ticket:'+t.id", relation_type, target_ticket) 
     77    else: 
     78        return 
     79    row = cursor.fetchone() 
     80    if row and row[0] > 0: 
     81        if action == 'resolve': 
     82            raise util.TracError('One or more of this ticket\'s relations require another ticket to be resolved before this one can be resolved.') 
     83        elif action == 'reopen': 
     84            raise util.TracError('One or more of this ticket\'s relations require another ticket to be reopened before this one can be reopened.') 
     85    return 
     86 
     87def failing(db, env, ticket_from, ticket_to, relation_type, action, target_ticket): 
     88    """A hook that always fails, for testing purposes.""" 
     89    raise util.TracError("The failing hook failed.")