import
[web.mtrack] / inc / report.php
1 <?php # vim:ts=2:sw=2:et:
2 /* For licensing and copyright terms, see the file named LICENSE */
3
4 class MTrackReport {
5   public $rid = null;
6   public $summary = null;
7   public $description = null;
8   public $query = null;
9   public $changed = null;
10
11   static function loadByID($id) {
12     return new MTrackReport($id);
13   }
14
15   static function loadBySummary($summary) {
16     list($row) = MTrackDB::q('select rid from reports where summary = ?',
17       $summary)->fetchAll();
18     if (isset($row[0])) {
19       return new MTrackReport($row[0]);
20     }
21     return null;
22   }
23
24   function __construct($id = null) {
25     $this->rid = $id;
26     if ($this->rid) {
27       $q = MTrackDB::q('select * from reports where rid = ?', $this->rid);
28       foreach ($q->fetchAll() as $row) {
29         $this->summary = $row['summary'];
30         $this->description = $row['description'];
31         $this->query = $row['query'];
32         $this->changed = (int)$row['changed'];
33         return;
34       }
35       throw new Exception("report $id not found");
36     }
37   }
38
39   function save(MTrackChangeset $changeset) {
40     if ($this->rid) {
41       
42       /* figure what we actually changed */
43       $q = MTrackDB::q('select * from reports where rid = ?', $this->rid);
44       list($row) = $q->fetchAll();
45     
46       $changeset->add("report:" . $this->rid . ":summary",
47         $row['summary'], $this->summary);
48       $changeset->add("report:" . $this->rid . ":description",
49         $row['description'], $this->description);
50       $changeset->add("report:" . $this->rid . ":query",
51         $row['query'], $this->query);
52
53       $q = MTrackDB::q('update reports set summary = ?, description = ?, query = ?, changed = ? where rid = ?',
54             $this->summary, $this->description, $this->query,
55             $changeset->cid, $this->rid);
56     } else {
57       $q = MTrackDB::q('insert into reports (summary, description, query, changed) values (?, ?, ?, ?)',
58             $this->summary, $this->description, $this->query,
59             $changeset->cid);
60       $this->rid = MTrackDB::lastInsertId('reports', 'rid');
61       $changeset->add("report:" . $this->rid . ":summary",
62         null, $this->summary);
63       $changeset->add("report:" . $this->rid . ":description",
64         null, $this->description);
65       $changeset->add("report:" . $this->rid . ":query",
66         null, $this->query);
67
68     }
69   }
70   static function renderReport($repstring, $passed_params = null,
71       $format = 'html') {
72     global $ABSWEB;
73     static $jquery_init = false;
74
75     $db = MTrackDB::get();
76
77     /* process the report string; any $PARAM in there is recognized
78      * as a parameter and the query munged accordingly to pass in the data */
79
80     $params = array();
81     try {
82       $n = preg_match_all("/\\$([A-Z]+)/m", $repstring, $matches);
83       for ($i = 1; $i <= $n; $i++) {
84         /* default the parameter to no value */
85         $params[$matches[$i][0]] = '';
86         /* replace with query placeholder */
87         $repstring = str_replace('$' . $matches[$i][0], ':' . $matches[$i][0],
88           $repstring);
89       }
90
91       /* now to summon parameters */
92       if (isset($params['USER'])) {
93         $params['USER'] = MTrackAuth::whoami();
94       }
95       foreach ($params as $p => $v) {
96         if (isset($_GET[$p])) {
97           $params[$p] = $_GET[$p];
98         }
99       }
100       if (is_array($passed_params)) {
101         foreach ($params as $p => $v) {
102           if (isset($passed_params[$p])) {
103             $params[$p] = $passed_params[$p];
104           }
105         }
106       }
107
108       $q = $db->prepare($repstring);
109       $q->execute($params);
110
111       $results = $q->fetchAll(PDO::FETCH_ASSOC);
112     } catch (Exception $e) {
113       return "<div class='error'>" . $e->getMessage() . "<br>" . 
114         htmlentities($repstring, ENT_QUOTES, 'utf-8') . "</div>";
115     }
116
117     $out = '';
118
119     if (count($results) == 0) {
120       return "No records matched";
121     }
122
123     /* figure out the table headings */
124     $captions = array();
125     $span = array();
126     $rules = array();
127     foreach ($results[0] as $name => $value) {
128       if (preg_match("/^__.*__$/", $name)) {
129         if ($format == 'html') {
130           /* special meaning, not a column */
131           continue;
132         }
133       }
134       $captions[$name] = preg_replace("/^_(.*)_$/", "\\1", $name);
135     }
136     /* for spanning purposes, calculate the longest row */
137     $max_width = 0;
138     $width = 0;
139     foreach ($captions as $name => $caption) {
140       if ($name[0] == '_' && substr($name, -1) == '_') {
141         $width = 1;
142       } else {
143         $width++;
144       }
145       if ($width > $max_width) {
146         $max_width = $width;
147       }
148       if (substr($name, -1) == '_') {
149         $width = 1;
150       }
151     }
152
153     $group = null;
154     foreach ($results as $nrow => $row) {
155       $starting_new_group = false;
156
157       if ($nrow == 0) {
158         $starting_new_group = true;
159       } else if ($format == 'html' &&
160           (isset($row['__group__']) && $group !== $row['__group__'])) {
161         $starting_new_group = true;
162       }
163
164       if ($starting_new_group) {
165         /* starting a new group */
166         if ($nrow) {
167           /* close the old one */
168           if ($format == 'html') {
169             $out .= "</tbody></table>\n";
170           }
171         }
172         if ($format == 'html' && isset($row['__group__'])) {
173           $out .= "<h2 class='reportgroup'>" .
174             htmlentities($row['__group__'], ENT_COMPAT, 'utf-8') .
175             "</h2>\n";
176           $group = $row['__group__'];
177         }
178
179         if ($format == 'html') {
180           $out .= "<table class='report'><thead><tr>";
181         }
182
183         foreach ($captions as $name => $caption) {
184
185           /* figure out sort info for javascript bits */
186           $sort = null;
187           switch (strtolower($caption)) {
188             case 'priority':
189             case 'ticket':
190             case 'severity':
191               $sort = strtolower($caption);
192               break;
193             case 'created':
194             case 'modified':
195             case 'date':
196             case 'due':
197               $sort = 'mtrackdate';
198               break;
199             case 'remaining':
200               $sort = 'digit';
201               break;
202             case 'updated':
203             case 'time':
204             case 'content':
205             case 'summary':
206             default:
207               break;
208           }
209
210           $caption = ucfirst($caption);
211           if ($name[0] == '_' && substr($name,-1) == '_') {
212             if ($format == 'html') {
213               $out .= "</tr><tr><th colspan='$max_width'>$caption</th></tr><tr>";
214             } else if ($format == 'tab') {
215               $out .= "$caption\t";
216             }
217           } elseif ($name[0] == '_') {
218             continue;
219           } else {
220             if ($format == 'html') {
221               $out .= "<th";
222               if ($sort !== null) {
223                 $out .= " class=\"{sorter: '$sort'}\"";
224               }
225               $out .= ">$caption</th>";
226               if (substr($name, -1) == '_') {
227                 $out .= "</tr><tr>";
228               }
229             } else if ($format == 'tab') {
230               $out .= "$caption\t";
231             }
232           }
233         }
234         if ($format == 'html') {
235           $out .= "</tr></thead><tbody>\n";
236         } else if ($format == 'tab') {
237           $out .= "\n";
238         }
239       }
240
241       /* and now the column data itself */
242       if (isset($row['__style__'])) {
243         $style = " style=\"$row[__style__]\"";
244       } else {
245         $style = "";
246       }
247       $class = $nrow % 2 ? "even" : "odd";
248       if (isset($row['__color__'])) {
249         $class .= " color$row[__color__]";
250       }
251       if (isset($row['__status__'])) {
252         $class .= " status$row[__status__]";
253       }
254
255       if ($format == 'html') {
256         $begin_row = "<tr class=\"$class\"$style>";
257         $out .= $begin_row;
258       }
259       $href = null;
260
261       /* determine if we should link to something for this row */
262       if (isset($row['ticket'])) {
263         $href = $ABSWEB . "ticket.php/$row[ticket]";
264       }
265
266       foreach ($captions as $name => $caption) {
267         $v = $row[$name];
268
269         /* apply special formatting rules */
270         if ($format == 'html') {
271           switch (strtolower($caption)) {
272             case 'created':
273             case 'modified':
274             case 'date':
275             case 'due':
276             case 'updated':
277             case 'time':
278               if ($v !== null) {
279                 $v = mtrack_date($v);
280               }
281               break;
282             case 'content':
283               $v = MTrackWiki::format_to_html($v);
284               break;
285             case 'owner':
286               $v = mtrack_username($v, array('no_image' => true));
287               break;
288             case 'docid':
289             case 'ticket':
290               $v = mtrack_ticket($row);
291               break;
292             case 'summary':
293               if ($href) {
294                 $v = htmlentities($v, ENT_QUOTES, 'utf-8');
295                 $v = "<a href=\"$href\">$v</a>";
296               } else {
297                 $v = htmlentities($v, ENT_QUOTES, 'utf-8');
298               }
299               break;
300             case 'milestone':
301               $oldv = $v;
302               $v = '';
303               foreach (preg_split("/\s*,\s*/", $oldv) as $m) {
304                 if (!strlen($m)) continue;
305                 $v .= "<span class='milestone'>" .
306                       "<a href=\"{$ABSWEB}milestone.php/" .
307                       urlencode($m) . "\">" .
308                       htmlentities($m, ENT_QUOTES, 'utf-8') .
309                       "</a></span> ";
310               }
311               break;
312             case 'keyword':
313               $oldv = $v;
314               $v = '';
315               foreach (preg_split("/\s*,\s*/", $oldv) as $m) {
316                 if (!strlen($m)) continue;
317                 $v .= mtrack_keyword($m) . ' ';
318               }
319               break;
320             default:
321               $v = htmlentities($v, ENT_QUOTES, 'utf-8');
322           }
323         } else if ($format == 'tab') {
324           $v = trim(preg_replace("/[\t\n\r]+/sm", " ", $v));
325         }
326
327         if ($name[0] == '_' && substr($name, -1) == '_') {
328           if ($format == 'html') {
329             $out .= "</tr>$begin_row<td class='$caption' colspan='$max_width'>$v</td></tr>$begin_row";
330           } else if ($format == 'tab') {
331             $out .= "$v\t";
332           }
333         } elseif ($name[0] == '_') {
334           if ($format == 'tab') {
335             $out .= "$v\t";
336           } else {
337             continue;
338           }
339         } else {
340           if ($format == 'html') {
341             $out .= "<td class='$caption'>$v</td>";
342             if (substr($name, -1) == '_') {
343               $out .= "</tr>$begin_row";
344             }
345           } else if ($format == 'tab') {
346             $out .= "$v\t";
347           }
348         }
349       }
350       if ($format == 'html') {
351         $out .= "</tr>\n";
352       } else if ($format == 'tab') {
353         $out .= "\n";
354       }
355     }
356     if ($format == 'html') {
357       $out .= "</tbody></table>";
358     } else if ($format == 'tab') {
359       $out = str_replace("\t\n", "\n", $out);
360     }
361
362     return $out;
363   }
364
365   static function macro_RunReport($name, $url_style_params = null) {
366     $params = array();
367     parse_str($url_style_params, $params);
368     $rep = self::loadBySummary($name);
369     if ($rep) {
370       if (MTrackACL::hasAllRights("report:" . $rep->rid, 'read')) {
371         return $rep->renderReport($rep->query, $params);
372       } else {
373         return "Not authorized to run report $name";
374       }
375     } else {
376       return "Unable to find report $name";
377     }
378   }
379
380   static function parseQuery()
381   {
382     $macro_params = array(
383       'group' => true,
384       'col' => true,
385       'order' => true,
386       'desc' => true,
387       'format' => true,
388       'compact' => true,
389       'count' => true,
390       'max' => true
391     );
392
393     $mparams = array(
394       'col' => array('ticket', 'summary', 'state', 
395                 'priority',
396                 'owner', 'type', 'component',
397                 'remaining'),
398       'order' => array('pri.value'),
399       'desc' => array('0'),
400     );
401     $params = array();
402
403     $args = func_get_args();
404     foreach ($args as $arg) {
405       if ($arg === null) continue;
406       $p = explode('&', $arg);
407
408       foreach ($p as $a) {
409         $a = urldecode($a);
410         preg_match('/^([a-zA-Z_]+)(!?(?:=|~=|\^=|\$=))(.*)$/', $a, $M);
411
412         $k = $M[1];
413         $op = $M[2];
414         $pat = explode('|', $M[3]);
415
416         if (isset($macro_params[$k])) {
417           $mparams[$k] = $pat;
418         } else if (isset($params[$k])) {
419           if ($params[$k][0] == $op) {
420             // compatible operator; add $pat to possible set
421             $params[$k][1] = array_merge($pat, $params[$k][1]);
422           } else {
423             // ignore
424           }
425         } else {
426           $params[$k] = array($op, $pat);
427         }
428       }
429     }
430     return array($params, $mparams);
431   }
432
433   static function macro_TicketQuery()
434   {
435     $args = func_get_args();
436     list($params, $mparams) = call_user_func_array(array(
437       'MTrackReport', 'parseQuery'), $args);
438
439     /* compose that info into a query */
440     $sql = 'select ';
441
442     $colmap = array(
443       'ticket' => '(case when t.nsident is null then t.tid else t.nsident end) as ticket',
444       'component' => '(select mtrack_group_concat(name) from ticket_components
445             tcm left join components c on (tcm.compid = c.compid)
446             where tcm.tid = t.tid) as component',
447       'keyword' => '(select mtrack_group_concat(keyword) from ticket_keywords
448             tk left join keywords k on (tk.kid = k.kid)
449             where tk.tid = t.tid) as keyword',
450       'type' => 'classification as type',
451       'remaining' => "(case when t.status = 'closed' then 0 else (t.estimated - (select sum(expended) from effort where effort.tid = t.tid)) end) as remaining",
452       'state' => "(case when t.status = 'closed' then coalesce(t.resolution, 'closed') else t.status end) as state",
453       'milestone' => '(select mtrack_group_concat(name) from ticket_milestones
454             tmm left join milestones tmmm on (tmm.mid = tmmm.mid)
455             where tmm.tid = t.tid) as milestone',
456     );
457
458     $cols = array(
459      ' pri.value as __color__ ',
460      ' (case when t.nsident is null then t.tid else t.nsident end) as ticket ',
461      " t.status as __status__ ",
462     );
463
464     foreach ($mparams['col'] as $colname) {
465       if ($colname == 'ticket') {
466         continue;
467       }
468       if (isset($colmap[$colname])) {
469         $cols[$colname] = $colmap[$colname];
470       } else {
471         if (!preg_match("/^[a-zA-Z_]+$/", $colname)) {
472           throw new Exception("column name $colname is invalid");
473         }
474         $cols[$colname] = $colname;
475       }
476     }
477
478     $sql .= join(', ', $cols);
479    
480     if (!isset($params['milestone'])) {
481       $sql .= <<<SQL
482
483 FROM
484 tickets t 
485 left join priorities pri on (t.priority = pri.priorityname)
486 left join severities sev on (t.severity = sev.sevname)
487 WHERE
488  1 = 1
489
490 SQL;
491     } else {
492       $sql .= <<<SQL
493
494 FROM milestones m 
495 left join ticket_milestones tm on (m.mid = tm.mid)
496 left join tickets t on (tm.tid = t.tid)
497 left join priorities pri on (t.priority = pri.priorityname)
498 left join severities sev on (t.severity = sev.sevname)
499 WHERE
500  1 = 1
501
502 SQL;
503     }
504
505     $critmap = array(
506       'milestone' => 'm.name',
507       'tid' => 't.tid',
508       'id' => 't.tid',
509       'ticket' => 't.tid',
510     );
511
512     foreach ($params as $k => $v) {
513       list($op, $values) = $v;
514
515       if (isset($critmap[$k])) {
516         $k = $critmap[$k];
517       }
518
519       $sql .= " AND ";
520
521       if ($op[0] == '!') {
522         $sql .= " NOT ";
523         $op = substr($op, 1);
524       }
525       $sql .= "(";
526
527       if ($op == '=') {
528
529         if ($k == 't.tid' && count($values) == 1 &&
530             preg_match('/[,-]/', $values[0])) {
531
532           $crit = array();
533           foreach (explode(',', $values[0]) as $range) {
534             list($rfrom, $rto) = explode('-', $range, 2);
535             $type = 'integer';
536             if (!ctype_digit($rfrom)) {
537               $rfrom = MTrackDB::esc($rfrom);
538               $type = 'text';
539             }
540             if ($rto) {
541               if (!ctype_digit($rto)) {
542                 $rto = MTrackDB::esc($rto);
543                 $type = 'text';
544               }
545               $crit[] = "(cast(t.tid as $type) between $rfrom and $rto)";
546               $crit[] = "(cast(t.nsident as $type) between $rfrom and $rto)";
547             } else {
548               $crit[] = "(t.tid = $rfrom)";
549               $crit[] = "(t.nsident = $rfrom)";
550             }
551           }
552           $sql .= join(' OR ', $crit);
553         } else if (count($values) == 1) {
554           $sql .= " $k = " . MTrackDB::esc($values[0]) . " ";
555         } else {
556
557           $sql .= " $k in (";
558           foreach ($values as $i => $val) {
559             $values[$i] = MTrackDB::esc($val);
560           }
561           $sql .= join(', ', $values) . ") ";
562         }
563       } else {
564         /* variations on like */
565         if ($op == '~=') {
566           $start = '%';
567           $end = '%';
568         } else if ($op == '^=') {
569           $start = '';
570           $end = '%';
571         } else {
572           $start = '%';
573           $end = '';
574         }
575       
576         $crit = array();
577
578         foreach ($values as $val) {
579           $crit[] = "($k LIKE " . MTrackDB::esc("$start$val$end") . ")";
580         }
581         $sql .= join(" OR ", $crit);
582       }
583
584       $sql .= ") ";
585
586     }
587     if (isset($mparams['group'])) {
588       $g = $mparams['group'][0];
589       if (!ctype_alpha($g)) {
590         throw new Exception("group $g is not alpha");
591       }
592       $sql .= ' GROUP BY ' . $g;
593     }
594
595     if (isset($mparams['order'])) {
596       $k = $mparams['order'][0];
597       if ($k == 'tid') {
598         $k = 't.tid';
599       }
600
601       $sql .= ' ORDER BY ' . $k;
602       if (isset($mparams['desc']) && $mparams['desc'][0]) {
603         $sql .= ' DESC';
604       }
605     }
606
607     if (isset($mparams['max'])) {
608       $sql .= ' LIMIT ' . (int)$mparams['max'][0];
609     }
610 #    return htmlentities($sql);
611 #    return var_export($sql, true); 
612
613     return self::renderReport($sql);
614
615
616   }
617 };
618
619 MTrackWiki::register_macro('RunReport',
620   array('MTrackReport', 'macro_RunReport'));
621
622 MTrackWiki::register_macro('TicketQuery',
623   array('MTrackReport', 'macro_TicketQuery'));
624
625 MTrackACL::registerAncestry('report', 'Reports');
626