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