Roundup Tracker

Expanded pygal implementation

The pygal implementation was used as a basis for a project in the Spring 2024 semester for a graduate software engineering class at U-Mass Boston. It was enhanced to include:

as well as a pie chart.

The links for:

an example pair of charts:

https://github.com/UMB-CS682-Team-02/tracker/blob/main/demo/doc/pie and bar chart.png?raw=true

If you are interested in using or improving it, use the Roundup mailing list (see the contact link in the menu) for support.

Newer pygl implementation

This is a newer plot implementation to create a pie chart using the grouping on an index page. It replaced the older pychart charting library with pygal. It produces charts like:

status_pie.png

which was created from a search grouped on the priority field. To use it you have to install: https://www.pygal.org/en/stable/index.html.

Newer releases of Roundup support multiple (2 at minimum) grouping options this needs to be enhanced to handle that. Currently it only plots the count of elements using the first group property.

Add this link (which works for the original PyChart version too) by adding a link like:

    <a tal:attributes="href python:request.indexargs_url('issue',
            {'@action':'piechart'})" target="_blank" i18n:translate="">Show PieChart</a>

to your index page. Copy this (using raw mode) into chart.py in your tracker's extension directory, then restart the tracker.

   1 from roundup.anypy.strings import bs2b
   2 import roundup.hyperdb as hyperdb
   3 import pygal
   4 
   5 from roundup.cgi.actions import Action
   6 from roundup.cgi import templating
   7 
   8 import logging
   9 logger = logging.getLogger('extension')
  10 
  11 log = []
  12 
  13 class PieChartAction(Action):
  14 
  15     def handle(self):
  16         ''' Show chart for current query results
  17         '''
  18         db = self.db
  19 
  20         # needed to get the query data
  21         request = templating.HTMLRequest(self.client)
  22 
  23         # 'arg' will contain all the data that we need to pass to
  24         # the data retrival step.
  25         arg = {}
  26         
  27         arg['classname']    = request.classname
  28 
  29         if request.filterspec:
  30             arg['filterspec'] = request.filterspec
  31 
  32         if request.group:
  33             # assumption is that this is a list of tuples for most
  34             # cases
  35             arg['group'] = request.group
  36 
  37         if request.search_text:
  38             arg['search_text'] = request.search_text
  39 
  40         # execute the query again and count grouped items
  41         # data looks like list of (grouped_label, count):
  42         '''
  43           data = [ ("admin", 25),
  44                  ("demo", 15),
  45                  ("agent", 45),
  46                  ("provisional", 65),
  47                  ]
  48         '''
  49         data = self.get_data_from_query(db, **arg)
  50 
  51         if not data:
  52             raise ValueError("Failed to obtain data for graph.")
  53 
  54         # build svg image here
  55 
  56         config = pygal.Config()
  57         # Customize CSS
  58         # Almost all font-* here needs to be !important. Base styles include
  59         #  the #custom-chart-anchor which gives the base setting higher
  60         #  specificy/priority.  I don't know how to get that value so I can
  61         #  add it to the rules. I suspect with code reading I could set some
  62         #  of these using pygal.style....
  63 
  64         # make plot title larger and wrap lines
  65         config.css.append('''inline:
  66           g.titles text.plot_title {
  67             font-size: 20px !important;
  68             white-space: pre-line;
  69           }''')
  70         config.css.append('''inline:
  71           g.legend text {
  72             font-size: 8px !important;
  73             white-space: pre-line;
  74           }''')
  75 
  76         # uncomment and fix url to replace internet copy of file
  77         # with local copy
  78         #config.js[0] = "pygal-tooltips.min.js"
  79 
  80         chart = pygal.Pie(config,
  81                           width=400,
  82                           height=400,
  83                           print_values=True,
  84                           )
  85         for d in data:
  86             chart.add(d[0], d[1])
  87 
  88         # WARN this will break if group is not list of tuples
  89         chart.title = "Tickets grouped by %s \n(%s)"%(arg['group'][0][1],
  90                                                       db.config.TRACKER_NAME)
  91         chart.nonce=self.client.client_nonce
  92         image = chart.render(is_unicode=True, pretty_print=True)
  93 
  94 
  95         # display the image
  96         headers = self.client.additional_headers
  97         headers['Content-Type'] = 'image/svg+xml'
  98         headers['Content-Disposition'] = 'inline; filename=pieChart.svg';
  99 
 100         return bs2b(image) # send through Client.write_html()
 101 
 102 
 103     def get_data_from_query(self, db,
 104         classname=None, filterspec=None, group=None, search_text = None):
 105         cl = db.getclass(classname)
 106         log.append('cl=%s'%cl)
 107 
 108         # full-text search
 109         if search_text:
 110             matches =  db.indexer.search(re.findall(r'\b\w{2,25}\b',
 111                                                     search_text), cl)
 112         else:
 113             matches = None
 114         log.append('matches=%s'%matches)
 115 
 116         # if group is a list, extract first property ...
 117         # group may also be a single tuple
 118         # FIXME support multiple property group
 119         if isinstance(group, type([])):
 120             property = group[0][1]
 121         elif isinstance(group, type(())):
 122             property = group[1]
 123 
 124         log.append('property=%s'%property)
 125 
 126         prop_type = cl.getprops()[property]
 127         log.append('prop_type=%s'%prop_type)
 128 
 129         if not isinstance(prop_type, hyperdb.Link) \
 130         and not isinstance(prop_type, hyperdb.Multilink):
 131             raise ValueError(
 132                 'Piecharts can only be created on '
 133                 'linked group properties! %s is not '
 134                 'a linked property\n'%property)
 135             return
 136 
 137         klass = db.getclass(prop_type.classname)
 138         log.append('klass=%s'%klass)
 139 
 140         # build a property dict, eg: { 'new':1, 'assigned':2 }
 141         # in
 142         # case of status
 143         props = {}
 144         issues = cl.filter(matches, filterspec, sort = [('+', 'id')],
 145                            group = group)
 146         log.append('issues=%s'%issues)
 147         for nodeid in issues:
 148             prop_ids = cl.get(nodeid, property)
 149             if prop_ids:
 150                 if not isinstance(prop_ids, type([])):
 151                     prop_ids = [prop_ids]
 152                 for id in prop_ids:
 153                     prop = klass.get(id, klass.labelprop())
 154                     key = prop.replace('/', '-')
 155                     if key in props:
 156                         props[key] += 1
 157                     else:
 158                         props[key] = 1
 159             else:
 160                 prop = '?'
 161                 if prop not in props:
 162                     props[prop] = 0
 163                 key = prop.replace('/', '-')
 164                 if key in props:
 165                     props[key] += 1
 166                 else:
 167                     props[key] = 1
 168         log.append('props=%s'%props)
 169 
 170         chart = [ (k, v) for k, v in props.items() ]
 171         # sort by count. Largest first.
 172         chart.sort(key = lambda i: i[1], reverse=True)
 173 
 174         return chart
 175 
 176 def init(instance):
 177     instance.registerAction('piechart', PieChartAction)

Original PyChart implementation

Because managers and supervisors always want to see nice graphics, I picked Richard Jones's SimpleStatusCharts example and used it as a basis to create pie charts dependent on query results.

If you want to use this feature, make sure that your server has "PyChart":https://github.com/Infinite-Code/PyChart (formerly http://home.gna.org/pychart/) installed and if you use a Windows machine as server, you will need "GhostScript":http://sourceforge.net/projects/ghostscript/ too. Pychart is available for python 2 only. You can install version 1.39 (latest release) via pypi.

To '<tracker-home>/extensions' you add a file named 'piechart_action.py'. The content will be::

   1 import time, random, os, binascii, pickle
   2 
   3 try:
   4     from subprocess import Popen, PIPE
   5 except: ImportError
   6 
   7 from roundup.cgi.actions import Action
   8 from roundup.cgi import templating
   9 
  10 class PieChartAction(Action):
  11     def handle(self):
  12         ''' Show piechart for current query results
  13         '''
  14         db = self.db
  15 
  16         # needed to get the query data
  17         request = templating.HTMLRequest(self.client)
  18 
  19         # 'arg' will contain all the data that we need to pass to
  20         # the sub piechart process
  21         arg = {}
  22 
  23         arg['tracker_home'] = db.config.TRACKER_HOME
  24         arg['user']         = db.user.get(db.getuid(), 'username')
  25         arg['classname']    = request.classname
  26 
  27         if request.filterspec:
  28             arg['filterspec'] = request.filterspec
  29 
  30         if request.group:
  31             arg['group'] = request.group
  32 
  33         if request.search_text:
  34             arg['search_text'] = request.search_text
  35 
  36         # calculate a crc to be used as part of the temporary output filename
  37         # used by the sub process
  38         crc = binascii.crc32(' '.join([ '%s %s'%(option, value) \
  39                                         for option, value in arg.items() ]), 0)
  40         crc = binascii.crc32(''.join([ '%02d'%part \
  41                                        for part in time.gmtime()[:6]]), crc)
  42         crc = '%08X'%crc
  43 
  44         temp_folder = os.path.normpath(os.path.join(db.config.TRACKER_HOME,
  45                                                     'temp'))
  46 
  47         # create <tracker-home>/temp folder if it ain't not there
  48         if not os.path.exists(temp_folder):
  49             os.makedirs(temp_folder)
  50 
  51         # create a temporary filename for the output file
  52         image_file = os.path.normpath(os.path.join(temp_folder,
  53                                                    'pieChart_%s.png'%crc[-8:]))
  54 
  55         # add the temporary output filename to the arguments passed
  56         # to the sub process
  57         arg['output_file'] = image_file
  58 
  59         # build the command to run the sub process:
  60         # this uses the python from your path; if this
  61         # not the one required to run Roundup, use instead:
  62         #   '%s/bin/python %s'%(sys.prefix, ...
  63         command = 'python %s'%(os.path.normpath(
  64                                os.path.join(db.config.TRACKER_HOME,
  65                                             'extensions',
  66                                             'piechart.py')))
  67 
  68         # run the sub process (will create the piechart)
  69         try: # try newer subprocess
  70             p = Popen(command, shell=True, bufsize=8192,
  71                       stdin=PIPE, stdout=PIPE, stderr=PIPE, close_fds=True)
  72             (stdin, stdout, stderr) = (p.stdin, p.stdout, p.stderr)
  73         # Method exists, and was used.  
  74         except AttributeError:
  75             # Method does not exist.  What now?
  76             # use os.popen3
  77             stdin, stdout, stderr = os.popen3(command)
  78 
  79         # pass values in 'arg' to sub process
  80         pickle.dump(arg, stdin)
  81         stdin.close()
  82 
  83         # read any errors from error pipe
  84         error = stderr.read()
  85 
  86         stdout.close()
  87         stderr.close()
  88 
  89         image = None
  90 
  91         # if there aren't any errors
  92         if not error:
  93             # read the piechart image
  94             fp = os.open(image_file, os.O_RDONLY | getattr(os, 'O_BINARY', 0))
  95             try:
  96                 length = os.fstat(fp)[6]
  97                 image  = os.read(fp, length)
  98             finally:
  99                 os.close(fp)
 100 
 101         # remove the temporary piechart image
 102         try:
 103             os.unlink(image_file)
 104         except:
 105             pass
 106 
 107         if not error and not image:
 108             error = 'Received an empty pie chart'
 109 
 110         if not error:
 111             # display the image
 112 
 113             headers = self.client.additional_headers
 114             headers['Content-Type'] = 'image/png'
 115             headers['Content-Disposition'] = 'inline; filename=pieChart.png'
 116 
 117             self.client.header()
 118 
 119             if self.client.env['REQUEST_METHOD'] == 'HEAD':
 120                 # all done, return a dummy string
 121                 return 'dummy'
 122 
 123             # write the piechart to client
 124             self.client.request.wfile.write(image)
 125         else:
 126             # we have an error
 127 
 128             headers = self.client.additional_headers
 129             headers['Content-Type'] = 'text/html'
 130 
 131             self.client.header()
 132 
 133             if self.client.env['REQUEST_METHOD'] == 'HEAD':
 134                 # all done, return a dummy string
 135                 return 'dummy'
 136 
 137             # write the error to client
 138             self.client.request.wfile.write('<pre>%s</pre>'%error)
 139 
 140         return '\n'
 141 
 142 def init(instance):
 143     instance.registerAction('piechart', PieChartAction)

In that same directory you add a stand-alone script which will be called by the above action handler. This standalone script is needed because for some unknown reason "PyChart":http://home.gna.org/pychart/ fails to create a pie chart if it is called directly within a roundup session. The only solution was to create a child process which will call "PyChart":http://home.gna.org/pychart/ and pass the output back to the action handler.

Here is the content of that stand-alone script (named 'piechart.py')::

   1     import os, sys, re, time, types, pickle, random
   2     import roundup.instance
   3 
   4     from roundup import hyperdb
   5 
   6     from pychart import *
   7 
   8     log = []
   9 
  10     def openTracker(tracker_home, user):
  11         ''' open tracker and return a database instance
  12         '''
  13         if not tracker_home:
  14             tracker_home = os.getenv('TRACKER_HOME')
  15 
  16         if not os.path.exists(os.path.normpath('%s/config.ini'%tracker_home)):
  17             sys.stderr.write('ERROR: TRACKER_HOME is not a falid path')
  18             sys.exit(-1)
  19 
  20         tracker = roundup.instance.open(tracker_home)
  21         db = tracker.open(user)
  22 
  23         return db
  24 
  25 
  26     def createPieChart(tracker_home=None, user='anonymous', classname='issue',
  27                        filterspec={},
  28                        group=('+', 'status'),
  29                        search_text=None,
  30                        output_file=None):
  31         ''' create piechart
  32         '''
  33         try:
  34             db = openTracker(tracker_home, user)
  35 
  36             try:
  37                 cl = db.getclass(classname)
  38                 log.append('cl=%s'%cl)
  39 
  40                 # full-text search
  41                 if search_text:
  42                     matches = db.indexer.search(re.findall(r'\b\w{2,25}\b',
  43                                                 search_text), cl)
  44                 else:
  45                     matches = None
  46                 log.append('matches=%s'%matches)
  47 
  48                 # some trackers do place the group settings in a list object
  49                 # for those we have to correct it, because the piechart.py
  50                 # script expects a tuple and not a tuple within a list
  51                 if len(group) == 1 and isinstance(group, types.ListType) \
  52                 and len(group[0]) == 2 and isinstance(group[0], types.TupleType):
  53                     # yep, the group tuple was placed in a list, now we correct it
  54                     group = group[0]
  55                 property = group[1]
  56                 log.append('property=%s'%property)
  57 
  58                 prop_type = cl.getprops()[property]
  59                 log.append('prop_type=%s'%prop_type)
  60 
  61                 if not isinstance(prop_type, hyperdb.Link) \
  62                 and not isinstance(prop_type, hyperdb.Multilink):
  63                     os.write(2, 'Piecharts can only be created on' \
  64                                 'linked group properties!\n')
  65                     return
  66 
  67                 klass = db.getclass(prop_type.classname)
  68                 log.append('klass=%s'%klass)
  69 
  70                 # build a property dict, eg: { 'new':1, 'assigned':2 } in
  71                 # case of status
  72                 props = {}
  73                 chart = {}
  74                 issues = cl.filter(matches, filterspec, group)
  75                 log.append('issues=%s'%issues)
  76                 for nodeid in issues:
  77                     prop_ids = cl.get(nodeid, property)
  78                     if prop_ids:
  79                         if not isinstance(prop_ids, types.ListType):
  80                             prop_ids = [prop_ids]
  81                         for id in prop_ids:
  82                             prop = klass.get(id, klass.labelprop())
  83                             key = prop.replace('/', '-')
  84                             if props.has_key(key):
  85                                 props[key] += 1
  86                             else:
  87                                 props[key] = 1
  88                     else:
  89                         prop = '?'
  90                         if not props.has_key(prop):
  91                             props[prop] = 0
  92                             chart[prop] = (5, fill_style.white)
  93                         key = prop.replace('/', '-')
  94                         if props.has_key(key):
  95                             props[key] += 1
  96                         else:
  97                             props[key] = 1
  98                 log.append('props=%s'%props)
  99 
 100                 # create chart color/fill table
 101                 random.seed(0.5)
 102                 for key in props.keys():
 103                     col  = color.T(r=random.random(),
 104                                    g=random.random(),
 105                                    b=random.random())
 106                     fill = fill_style._intern_color(fill_style.Plain(bgcolor=col))
 107                     chart[key] = (5, fill)
 108 
 109                 # create a sorted keylist
 110                 order = props.keys()
 111                 order.sort()
 112                 log.append('order=%s'%order)
 113 
 114                 # convert to structure accepted by PyChart
 115                 data = [ (key, props[key]) for key in order ]
 116                 log.append('data=%s'%data)
 117 
 118                 if len(data) > 0:
 119                     # format pie style
 120                     arc_offsets = []
 121                     fill_styles = []
 122                     offset = 10
 123                     for index, item in enumerate(data):
 124                         key = item[0]
 125                         data[index] = ('%s (%s)'%(item[0], item[1]), item[1])
 126                         if chart.has_key(key):
 127                             arc_offsets.append(offset)
 128                             fill_styles.append(chart[key][1])
 129                         else:
 130                             arc_offsets.append(offset)
 131                             fill_styles.append(fill_style.gray50)
 132                         offset += 10
 133                         if offset > 20:
 134                             offset = 10
 135                     log.append('data=%s'%data)
 136 
 137                     if output_file:
 138                         try:
 139                             os.unlink(output_file)
 140                         except:
 141                             pass
 142 
 143                     # now call PyChart for the chart generation
 144                     if output_file:
 145                         theme.output_file = output_file
 146                     theme.output_format = 'png'
 147                     theme.use_color     = True
 148                     theme.default_font_size = 16
 149                     theme.reinitialize()
 150                     ar   = area.T(size=(800, 750), legend=legend.T(),
 151                                         x_grid_style=None, y_grid_style=None)
 152                     plot = pie_plot.T(data=data,
 153                                       arc_offsets=arc_offsets,
 154                                       fill_styles=fill_styles,
 155                                       label_offset=40,
 156                                       arrow_style=arrow.a3,
 157                                       radius=200,
 158                                       center=(400,300))
 159                     ar.add_plot(plot)
 160                     ar.draw()
 161                 else:
 162                     os.write(2, 'No data to display\n')
 163             finally:
 164                 db.close()
 165 
 166         except:
 167             # in case of an error, write some additional info
 168             # to the error pipe
 169             os.write(2, 'Failed to create a pie chart due to' \
 170                         'a Python exception!\n')
 171             os.write(2, '\n')
 172             os.write(2, 'tracker_home : %s\n'%str(tracker_home))
 173             os.write(2, 'user         : %s\n'%str(user))
 174             os.write(2, 'classname    : %s\n'%str(classname))
 175             os.write(2, 'filterspec   : %s\n'%str(filterspec))
 176             os.write(2, 'group        : %s\n'%str(group))
 177             os.write(2, 'search_text  : %s\n'%str(search_text))
 178             os.write(2, 'output_file  : %s\n'%str(output_file))
 179             os.write(2, '\n')
 180             if log:
 181                 os.write(2, 'log:\n')
 182                 os.write(2, '\n'.join(log).replace('<', '&lt;').replace('>', '&gt;'))
 183                 os.write(2, '\n\n')
 184             raise
 185 
 186 
 187     def init(instance):
 188         # this dummy 'init' is needed to fool roundup
 189         pass
 190 
 191     class devnull:
 192         def write(self, *args):
 193             pass
 194         def writelines(self, *args):
 195             pass
 196         def flush(self, *args):
 197             pass
 198 
 199     if __name__ == '__main__':
 200         arguments = {}
 201 
 202         # throw away anything that might be written to stdout
 203         # because the real stdout is a pipe that our caller does not
 204         # read from and that therefore might block
 205         sys.stdout = devnull()
 206 
 207         # no arguments (must be passed through stdin by piechart action handler)
 208         arguments = pickle.load(sys.stdin)
 209         log.append('arguments=%s'%arguments)
 210 
 211         createPieChart(**arguments)

Finally we need to call it somewhere. The simplest way is to add a link to the 'issue.index.html' page like it has a link for the **csv_export** action.

A typical pie chart link could look like::

    <a tal:attributes="href python:request.indexargs_url('issue',
            {'@action':'piechart'})" target="_blank" i18n:translate="">Show PieChart</a>

Regards,<br> Marlon

History:

- 07.03.2007: added remark abour Python prefix, added stdout redirection to avoid deadlock -- Patrick Ohly

- 03.19.2012: use subprocess module if available otherwise use depricated os.popen3 -- John Rouillard

--


CategoryActions CategoryChart