source: trunk/plugins/dashboardreportsplugin/dashboardreports/api.py @ 375

Revision 375, 20.4 KB checked in by aculapov, 5 years ago (diff)
  • added group by custom fields
Line 
1
2from datetime import date, datetime
3import random
4import time
5
6from trac.core import *
7from trac.wiki.macros import WikiMacroBase
8from genshi.builder import tag
9from trac.web.chrome import add_script, add_stylesheet, ITemplateProvider, Chrome
10from trac.ticket.api import TicketSystem
11from trac.ticket.query import Query
12from trac.util.datefmt import to_timestamp, utc
13
14
15def get_param_value(param_store, key): 
16    if type(param_store) is dict and param_store.has_key(key) : 
17        return param_store[key] 
18    else : 
19        return None
20
21class DashboardBaseMacro(WikiMacroBase):
22   
23    abstract = True
24
25    severities_sql = "select distinct %s from %s order by 1"
26   
27    def _process_macro_variables(self, content, req=None):
28        """
29        This method scans the content to check for variable names. If it find a
30        variable name then it replaces the name with the value.
31        """
32        prefix=""
33        return_value = ""
34        variable = content
35        # cut the prefix and store it
36        if content[0] in ['<', '>'] :
37            prefix = content[0]
38            variable = content[1:]
39        # test to see if we have a variable
40        if variable[0] != '$' :
41            return_value = variable
42        elif variable[1:] == 'NOW' :
43            return_value = to_timestamp(datetime.now(utc))
44        elif variable[1:] == 'TODAY' :
45            now = datetime.now(utc)
46            seconds_today = now.hour * 3600 + now.minute * 60 + now.second
47            return_value = to_timestamp(datetime.fromtimestamp(int(time.time())
48                                                                - seconds_today, utc))
49        elif variable[1:] == 'YESTERDAY' :
50            return_value = to_timestamp(datetime.fromtimestamp(int(time.time())
51                                                                - 86400, utc))
52        elif variable[1:] == 'USER' :
53            return req.authname
54   
55        return unicode(prefix + str(return_value))
56   
57    def get_parameters(self, content, req=None):
58        global get_param_value
59        """
60        This method processes the parameters this macro supports and that are
61        defined in the self.parameters variable declared in the implementing
62        macro class.
63        This method searches for the filter parameter, if not found then it
64        does the processing automatically. Also if the implementing class
65        defines the id and name parameters but the id is not set then the id
66        value is get from the name parameter.
67       
68        """
69        global get_param_value
70        values = content.split(',')
71        if len(values) < 1:
72            return None
73       
74        # copy the default values
75        param_store = self.parameters.copy()
76        filter = get_param_value(param_store, self.get_filter())
77        if filter is None :
78             
79            # split the trac query filter
80            where = values[0].split('&')
81            filter = {}
82            for col in where :
83                name, value = col.split('=')
84                name = name.strip()
85                value = value.strip()
86                filter[name] = [self._process_macro_variables(val, req) 
87                                for val in value.split('|')]
88            # delete the first parameter
89            values = values[1:]
90        # put back the value
91        param_store[self.get_filter()] = filter
92        updated_params = []
93        for param in values :
94            if len(param.split('=')) < 2 :
95                continue
96            name, value = param.split('=')
97            name = name.strip()
98            value = value.strip()
99            value = [self._process_macro_variables(val, req) 
100                                for val in value.split('|')]
101           
102            if len(value) == 1 :
103                value = value[0]
104           
105            if name in self.parameters.keys() :
106                if type(param_store[name]) is list :
107                    if value in param_store[name] :
108                        param_store[name] = value
109                    else :
110                        # bad value given then using the default
111                        param_store[name] = param_store[name][0]
112                else :
113                    param_store[name] = value
114            updated_params.append(name)
115        # check that the macro was called with all mandatory params
116        for name in param_store.keys() :
117            # we handled this param
118            if name in updated_params:
119                continue
120            # we diodn't received a value for this mandatory param
121            if param_store[name] is None :
122                return None
123            # get the default value for this param
124            if type(param_store[name]) is list :
125                param_store[name] = param_store[name][0]
126            # put the default div id
127            #if name == 'id' and param_store.has_key('name'):
128            #    param_store[name] = param_store['name'].replace(' ', '_').lower()
129            #    param_store[name] += '_' + str(random.randint(1, 100000000))
130        return param_store
131   
132    def generate_trac_query(self, resource, attach=False):
133        """
134        Returns a string that is like a trac ticket query filter.
135        The resource parameter can be a dictionary or a list.
136        """
137        sql = ""
138        join_by = '&'
139        #put a separator at the beginning
140        if attach :
141            sql += join_by
142        if type(resource) is dict :
143            for field in resource.keys() :
144                values = resource[field]
145                if type(values) is list :
146                    sql += field + '=' + '|'.join(values) + join_by
147                else :
148                    if values is None :
149                        values = '' 
150                    sql += field + '=' + str(values) + join_by
151        elif type(resource) is list :
152            for col in resource :
153                sql += 'col=' + col + join_by
154
155        return sql[:-1]
156   
157    def generate_sql(self, param_store, req=None):
158        """
159        This method generates a sql query from the parameters received.
160        """
161        global get_param_value
162        filter = get_param_value(param_store, self.get_filter())
163        group_by = get_param_value(param_store, self.get_group_by())
164        component = get_param_value(param_store, self.get_component())
165        cols = self.get_columns(param_store)
166        enum_columns = ('resolution', 'priority', 'severity')
167        fields =TicketSystem(self.env).get_all_ticket_fields()
168        custom_fields = [f['name'] for f in fields if 'custom' in f]
169       
170        def get_field_value(field):
171            if field in custom_fields :
172                return field + "_table.value"
173            return field
174       
175        sql = ["SELECT ",]
176        sql_col = []
177        for col in cols :
178            if col == 'id' :
179                sql_col.append(' count(t.' + col + ') AS ' + col + ' ') 
180            else :
181                if col in custom_fields :
182                    sql_col.append(col + '_table.value AS ' + col + ' ')
183                else :
184                    sql_col.append('t.' + col + ' AS ' + col)
185        sql.append(','.join(sql_col))
186        # add the from
187        sql.append(' FROM ticket t ')
188       
189        # Join with ticket_custom table as necessary
190        for k in [k for k in cols if k in custom_fields]:
191           sql.append("\n  INNER JOIN ticket_custom AS %s ON " \
192                      "(id=%s.ticket AND %s.name='%s')" % ((k +'_table', ) * 3 + (k,)))
193
194        # Join with the enum table for proper sorting
195        for col in [c for c in enum_columns
196                    if c == group_by or c == 'priority']:
197            sql.append("\n  LEFT OUTER JOIN enum AS %s ON "
198                       "(%s.type='%s' AND %s.name=%s)"
199                       % ((col,) * 5))
200
201        # Join with the version/milestone tables for proper sorting
202        for col in [c for c in ['milestone', 'version']
203                    if c == component or c == group_by]:
204            sql.append("\n  LEFT OUTER JOIN %s %s ON (%s.name=t.%s)"
205                       % (col, col[0], col[0], col))
206
207        def get_constraint_sql(name, value, mode, neg):
208            if name not in custom_fields:
209                name = 't.' + name
210            else:
211                name = name + '.value'
212            value = value[len(mode) + neg:]
213
214            if mode == '':
215                try :
216                    value = int(value)
217                except :
218                    value = "'" + value + "'" 
219                return "COALESCE(%s,'')%s=" % (name, neg and '!' or '') + str(value)
220            if not value:
221                return None
222            db = self.env.get_db_cnx()
223            value = db.like_escape(value)
224            if mode == '~':
225                value = '%' + value + '%'
226            elif mode == '^':
227                value = value + '%'
228            elif mode == '$':
229                value = '%' + value
230            return "COALESCE(%s,'') %s%s %s" % (name, neg and 'NOT ' or '',
231                                              db.like(),  value)
232
233        clauses = []
234        args = []
235        for k, v in filter.items():
236            # Determine the match mode of the constraint (contains,
237            # starts-with, negation, etc.)
238            neg = v[0].startswith('!')
239            mode = ''
240            if len(v[0]) > neg and v[0][neg] in ('~', '^', '$'):
241                mode = v[0][neg]
242
243            # Special case id ranges
244            if k == 'id':
245                ranges = Ranges()
246                for r in v:
247                    r = r.replace('!', '')
248                    ranges.appendrange(r)
249                ids = []
250                id_clauses = []
251                for a,b in ranges.pairs:
252                    if a == b:
253                        ids.append(str(a))
254                    else:
255                        id_clauses.append('id BETWEEN %s AND %s' % (a, b))
256                if ids:
257                    id_clauses.append('id IN (%s)' % (','.join(ids)))
258                if id_clauses:
259                    clauses.append('%s(%s)' % (neg and 'NOT ' or '',
260                                               ' OR '.join(id_clauses)))
261            # Special case for exact matches on multiple values
262            elif not mode and len(v) > 1:
263                if k not in custom_fields:
264                    col = 't.' + k
265                else:
266                    col = k + '.value'
267                clauses.append("COALESCE(%s,'') %sIN (%s)"
268                               % (col, neg and 'NOT ' or '',
269                                  ','.join(["'" + val[neg:] + "'" for val in v])))
270            elif len(v) > 1:
271                constraint_sql = filter(None,
272                                        [get_constraint_sql(k, val, mode, neg)
273                                         for val in v])
274                if not constraint_sql:
275                    continue
276                if neg:
277                    clauses.append("(" + " AND ".join(
278                        [item for item in constraint_sql]) + ")")
279                else:
280                    clauses.append("(" + " OR ".join(
281                        [item for item in constraint_sql]) + ")")
282            elif len(v) == 1:
283                constraint_sql = get_constraint_sql(k, v[0], mode, neg)
284                if constraint_sql:
285                    clauses.append(constraint_sql)
286        #clauses = filter(None, clauses)
287        if clauses:
288            sql.append("\nWHERE ")
289        if clauses:
290            sql.append(" AND ".join(clauses))
291       
292        # add the group by
293        groups = ' GROUP BY ' + get_field_value(group_by)
294        if component not in [ None, ''] :
295            groups += ', ' + get_field_value(component) 
296        sql.append(groups)
297       
298        # add the order by
299        if component == 'milestone' :
300            sql.append(' ORDER BY m.due')
301        elif component not in [None, '']:
302            sql.append(' ORDER BY ' + get_field_value(component))
303        else :
304            sql.append(' ORDER BY ' + get_field_value(group_by))
305           
306        return ' '.join(sql)
307   
308    def _run_sql(self, formatter, sql):
309        """
310        This method runs a sql query against the database and returns a list
311        with rows.
312        """
313        self.log.debug(sql)
314        db = formatter.db
315        cursor = db.cursor()
316        cursor.execute(sql)
317        sql_return = cursor.fetchall()
318        cursor.close()
319       
320        # return with striped null values
321        return [row for row in sql_return if row != ""]
322   
323    def get_severities(self, formatter, group_by, table="ticket"):
324        """
325        This method returnes a list with the values of the group-by field used
326        in the project.
327        """
328        custom_fields = TicketSystem(self.env).get_custom_fields()
329       
330        self.log.warning([field['name'] for field in custom_fields ])
331       
332        if group_by in [field['name'] for field in custom_fields ] :
333            sql = "SELECT DISTINCT tc.value FROM ticket t JOIN ticket_custom tc ON t.id = tc.ticket WHERE tc.name='%s'" % (group_by)
334        elif group_by in ['priority', 'severity']:
335            sql = "select distinct %s, e.value from ticket t join enum e on t.%s = e.name where e.type='%s' order by e.value" % ((group_by, ) *3)
336        elif group_by == 'milestone' :
337            sql = "select distinct t.milestone, m.due from ticket t left join milestone m on t.milestone = m.name order by m.due"
338        else :
339            sql = self.severities_sql % (group_by, table)
340       
341        values = self._run_sql(formatter, sql)
342        return [ unicode(val[0]) for val in values ]
343
344    def _get_filter_query(self, filter, add=None):
345        """
346        Returns a Href to the query page that has the '''filter''' as
347        parameter.
348        """
349        sql = self.generate_trac_query(filter)
350        if add is not None :
351            sql += self.generate_trac_query(add, True)
352        query = Query.from_string(self.env, sql)
353        return query.get_href(self.env.href)
354   
355    def add_href_to_fields(self, field_name, values):
356        """
357        """
358        if type(values) in [str, unicode] :
359            return self._get_filter_query({field_name: values})
360       
361        elif type(values) is list :
362            return_values = []
363            for value in values :
364               return_values.append({'value': value, 
365                                      'label_href': self._get_filter_query({field_name: value})})
366            return return_values
367       
368        elif type(values) is dict :
369            for key in values.keys() :
370                values[key]['label_href'] = self._get_filter_query({field_name: 
371                                                                      key})
372            return values
373       
374        return values
375   
376    def get_filter(self):
377        """
378        It MUST be overwritten by the implementing macro class.
379        """
380        return None
381   
382    def get_component(self):
383        """
384        It MUST be overwritten by the implementing macro class.
385        """
386        return None
387   
388    def get_group_by(self):
389        """
390        It MUST be overwritten by the implementing macro class.
391        """
392        return None
393   
394    def get_columns(self, param_store):
395        """
396        This method returns a list with the ticket fields that will be used as
397        column headers of the table view, and also it will be used to create
398        the sql query.
399        It receives as parameter the dictionary with the processed macro
400        parameters. The default method returns only the id.
401        This method MUST be overwritted by the implementing macro class.
402        """
403        return ['id']
404   
405    def update_parameter_store(self, param_store):
406        """
407        It CAN be overwritten by the implementing macro class.
408        """
409        return param_store
410
411class FilterReportMacro(DashboardBaseMacro):
412    """
413    This class represents the base macro for the macros that displayes
414    information about tickets based on the query like parameter received.
415    As base class it offerss support for:
416     - parsing and verifing the parameters
417     - generating a sql from the parameters received
418     - generating links to a query page that displayes the tickets for a
419     particular sql query
420     - processes the sql query result and returns a summary
421     - implements the NOW and YESTERDAY query variables
422   
423    The overwriting class must:
424     - define the '''template''' attribute which should contain the genshi
425     template used for rendering the output.
426     - define the '''reportName''' and '''div_id''' variables
427     - implement the '''create_view''' method and use the processed data
428     obtained in the create_view method.
429   
430   
431    Parameters:
432   
433    The macro supports two different types of parameters:
434     - ticket query filter
435     - name - value parameters
436    The parameters are separated by comma.
437   
438    The first parameter must be the a trac query filter. The syntax is like:
439       ticket_field1=[OPERATOR]value1|value2|value3[&ticket_field2......]
440    The known operators are:
441     - !
442     - ~
443     - >
444     - <
445     - ^
446     - $
447     
448    After the first parameter, a number of maximum four other parameters are
449    possible. Some of them are optional and some are mandatory.
450   
451    Mandatatory parameters:
452     - id - the html id div of the widget
453     - group-by - the ticekt field after which the sum is done
454    Optional parameters:
455     - name - the name of the widget displayed
456     - field - the ticket field after which a grouping is done
457     
458    """
459    abstract = True
460
461    template = None
462   
463    def expand_macro(self, formatter, name, content):
464        """
465        This method starts the processing and calls all other methods.
466        """
467        global get_param_value
468        # process the params of the calling macro
469       
470        param_store = self.get_parameters(content, formatter.req)
471       
472        if param_store is None :
473            return None
474       
475       
476        # call the hook to alter the param store
477       
478        param_store = self.update_parameter_store(param_store)
479        # get the values from the database
480        values = self._run_sql(formatter, 
481                               self.generate_sql(param_store, formatter.req))
482       
483        output =  self.display_sql(formatter, 
484                                self.compute_values(values, param_store), 
485                                param_store)
486       
487        self.log.debug(output)
488        return output
489   
490   
491    def compute_values(self, values, param_store):
492        """
493        This method receives the values from the database and calculates totals
494        based on the group-by and component fields.
495        """
496        runningTotal = 0
497        sum_per_severity = {}
498        components = {}
499        component = get_param_value(param_store, self.get_component())
500        # define the index
501        # TODO: make the flow after what the get_columns returns
502        id_index = 0
503        component_index = 2
504        group_by_index = 1
505        due_index=3
506        for row in values:
507            if sum_per_severity.has_key(row[group_by_index]):
508                sum_per_severity[row[group_by_index]] += row[id_index]
509            else:
510                sum_per_severity[row[group_by_index]] = row[id_index]
511            if component not in [None, '']:
512                if not components.has_key(row[component_index]):
513                    components[row[component_index]]={}
514                    components[row[component_index]]['total'] = 0
515                    if 'due' in self.get_columns(param_store) :
516                        components[row[component_index]]['due'] = ''
517                components[row[component_index]][row[group_by_index]]=row[id_index]
518                components[row[component_index]]['total'] += row[id_index]
519                if 'due' in self.get_columns(param_store) :
520                    components[row[component_index]]['due'] = row[due_index]
521            runningTotal+=row[id_index]
522        return sum_per_severity, components, runningTotal
523   
524    def display_sql(self, formatter, sum_per_severity, param_store):
525        """
526        It renders the output of the macro.
527        """
528       
529        data = self.create_view(formatter, 
530                                sum_per_severity,
531                                param_store)
532        add_stylesheet(formatter.req, 'dashboardreports/css/widgets.css')
533        self.log.debug(data)
534        return Chrome(self.env).render_template(formatter.req, 
535                                                self.template, data)
536   
537    def create_view(self, formatter, sum_per_severity, param_store):
538        """
539        It must be overwritten by the implementing macro class.
540        """
541        return None
542   
Note: See TracBrowser for help on using the repository browser.