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:
- vertical barchart
- horizontal barchart
- multiple barcharts (2 grouping parameters)
- stacked barchart (again 2 grouping parameters)
as well as a pie chart.
The links for:
docs: https://github.com/UMB-CS682-Team-02/tracker/blob/main/demo/doc/DOCUMENTATION.md
demo tracker: https://github.com/UMB-CS682-Team-02/tracker/blob/main/demo
an example pair of charts:
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:
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('<', '<').replace('>', '>'))
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
--