[Trac] Milestone Stats Extension Implementation

Justin Francis trac at justin.meagerman.net
Sun Oct 30 19:57:26 CST 2005


I have refactored the milestone module to implement an extension point 
ITicketGroupStatsProvider with one method 
get_ticket_group_stats(tickets) that allows plugins to provide 
additional statistics on a group of tickets. These stats are displayed 
via the progress bars (both grouped and ungrouped). The additional 
progress bars are displayed beneath the current one.

There is also the addition of multiple "intervals", where more than just 
two intervals (open, closed) may be given by the plugin. For each 
interval, the plugin can define names, query links, css classes, and 
whether the interval counts towards the total completion progress of the 
group. The units of the statistics may also be defined (instead of using 
current "ticket" unit, can use "hours" for example).

There is a default component implementation of the extension point that 
makes the changes backwards-compatible called 
DefaultTicketGroupStatsProvider. The interface looks exactly the same by 
default.

I have written unit tests for all code I wrote except for the resulting 
hdf passed to the templates. I have tested the interface in IE 6 and 
Firefox 1.0. I would request, however, that others test it out.

I would also like to know what the procedure is for getting this 
committed to the trunk. The attached patch is up to date as of revision 
2426 (Oct 30). The patch was generated using subclipse; let me know if 
there is a specific format that is more convenient. If the time is not 
convenient, I will keep my working copy up to date and recreate the 
patch at a better time.

Justin

-------------- next part --------------
Index: /home/lg/projs/trac/htdocs/css/roadmap.css
===================================================================
--- /home/lg/projs/trac/htdocs/css/roadmap.css	(revision 2426)
+++ /home/lg/projs/trac/htdocs/css/roadmap.css	(working copy)
@@ -41,6 +41,7 @@
  font-style: italic;
  margin: 0 1em 2em;
  white-space: nowrap;
+ display: inline;
 }
 .milestone .info dt { display: inline; margin-left: .5em }
 .milestone .info dd { display: inline; margin: 0 1em 0 .5em }
@@ -51,7 +52,7 @@
 .milestone .description { margin: 1em 0 2em }
 
 /* Styles for the milestone statistics table */
-#stats { float: right; margin: 0 0 2em 2em; width: 400px; max-width: 40% }
+#group_stats { float: right; margin: 0 0 2em 2em; width: 400px; max-width: 40% }
 #stats legend { white-space: nowrap }
 #stats table { border-collapse: collapse; width: 100% }
 #stats th, #stats td { font-size: 10px; padding: 0; white-space: nowrap }
Index: /home/lg/projs/trac/templates/milestone.cs
===================================================================
--- /home/lg/projs/trac/templates/milestone.cs	(revision 2422)
+++ /home/lg/projs/trac/templates/milestone.cs	(working copy)
@@ -1,219 @@
-<?cs include:"header.cs"?>
-<?cs include:"macros.cs"?>
-
-<div id="ctxtnav" class="nav"></div>
-
-<div id="content" class="milestone">
- <?cs if:milestone.mode == "new" ?>
- <h1>New Milestone</h1>
- <?cs elif:milestone.mode == "edit" ?>
- <h1>Edit Milestone <?cs var:milestone.name ?></h1>
- <?cs elif:milestone.mode == "delete" ?>
- <h1>Delete Milestone <?cs var:milestone.name ?></h1>
- <?cs else ?>
- <h1>Milestone <?cs var:milestone.name ?></h1>
- <?cs /if ?>
-
- <?cs if:milestone.mode == "edit" || milestone.mode == "new" ?>
-  <script type="text/javascript">
-    addEvent(window, 'load', function() {
-      document.getElementById('name').focus();
-    });
-  </script>
-  <form id="edit" action="<?cs var:milestone.href ?>" method="post">
-   <input type="hidden" name="id" value="<?cs var:milestone.name ?>" />
-   <input type="hidden" name="action" value="edit" />
-   <div class="field">
-    <label>Name of the milestone:<br />
-    <input type="text" id="name" name="name" size="32" value="<?cs
-      var:milestone.name ?>" /></label>
-   </div>
-   <fieldset>
-    <legend>Schedule</legend>
-    <label>Due:<br />
-     <input type="text" id="duedate" name="duedate" size="<?cs
-       var:len(milestone.date_hint) ?>" value="<?cs
-       var:milestone.due_date ?>" title="Format: <?cs var:milestone.date_hint ?>" />
-     <em>Format: <?cs var:milestone.date_hint ?></em>
-    </label>
-    <div class="field">
-     <label>
-      <input type="checkbox" id="completed" name="completed"<?cs
-        if:milestone.completed ?> checked="checked"<?cs /if ?> />
-      Completed:<br />
-     </label>
-     <label>
-      <input type="text" id="completeddate" name="completeddate" size="<?cs
-        var:len(milestone.date_hint) ?>" value="<?cs
-        alt:milestone.completed_date ?><?cs
-         var:milestone.datetime_now ?><?cs
-        /alt ?>" title="Format: <?cs
-        var:milestone.datetime_hint ?>" />
-      <em>Format: <?cs var:milestone.datetime_hint ?></em>
-     </label>
-     <script type="text/javascript">
-       var completed = document.getElementById("completed");
-       var enableCompletedDate = function() {
-         enableControl("completeddate", completed.checked);
-       };
-       addEvent(window, "load", enableCompletedDate);
-       addEvent(completed, "click", enableCompletedDate);
-     </script>
-    </div>
-   </fieldset>
-   <div class="field">
-    <fieldset class="iefix">
-     <label for="description">Description (you may use <a tabindex="42" href="<?cs
-       var:trac.href.wiki ?>/WikiFormatting">WikiFormatting</a> here):</label>
-     <p><textarea id="description" name="description" class="wikitext" rows="10" cols="78"><?cs
-       var:milestone.description_source ?></textarea></p>
-    </fieldset>
-   </div>
-   <div class="buttons">
-    <?cs if:milestone.mode == "new"
-     ?><input type="submit" value="Add milestone" /><?cs
-    else
-     ?><input type="submit" value="Submit changes" /><?cs
-    /if ?>
-    <input type="submit" name="cancel" value="Cancel" />
-   </div>
-   <script type="text/javascript" src="<?cs
-     var:htdocs_location ?>js/wikitoolbar.js"></script>
-  </form>
- <?cs elif:milestone.mode == "delete" ?>
-  <form action="<?cs var:milestone.href ?>" method="post">
-   <input type="hidden" name="id" value="<?cs var:milestone.name ?>" />
-   <input type="hidden" name="action" value="delete" />
-   <p><strong>Are you sure you want to delete this milestone?</strong></p>
-   <input type="checkbox" id="retarget" name="retarget" checked="checked"
-       onclick="enableControl('target', this.checked)"/>
-   <label for="target">Retarget associated tickets to milestone</label>
-   <select name="target" id="target">
-    <option value="">None</option><?cs
-     each:other = milestones ?><?cs if:other != milestone.name ?>
-      <option><?cs var:other ?></option><?cs 
-     /if ?><?cs /each ?>
-   </select>
-   <div class="buttons">
-    <input type="submit" name="cancel" value="Cancel" />
-    <input type="submit" value="Delete milestone" />
-   </div>
-  </form>
- <?cs else ?>
- <?cs if:milestone.mode == "view" ?>
-  <div class="info">
-   <p class="date"><?cs
-    if:milestone.completed_date ?>
-     Completed <?cs var:milestone.completed_delta ?> ago (<?cs var:milestone.completed_date ?>)<?cs
-    elif:milestone.due_date ?><?cs
-     if:milestone.late ?>
-      <strong><?cs var:milestone.due_delta ?> late</strong><?cs
-     else ?>
-      Due in <?cs var:milestone.due_delta ?><?cs
-     /if ?> (<?cs var:milestone.due_date ?>)<?cs
-    else ?>
-     No date set<?cs
-    /if ?>
-   </p><?cs
-   with:stats = milestone.stats ?><?cs
-    if:#stats.total_tickets > #0 ?>
-     <div class="progress">
-      <a class="closed" href="<?cs
-        var:milestone.queries.closed_tickets ?>" style="width: <?cs
-        var:#stats.percent_closed ?>%" title="<?cs
-        var:#stats.closed_tickets ?> of <?cs
-        var:#stats.total_tickets ?> ticket<?cs
-        if:#stats.total_tickets != #1 ?>s<?cs /if ?> closed"></a>
-      <a class="open" href="<?cs
-        var:milestone.queries.active_tickets ?>" style="width: <?cs
-        var:#stats.percent_active - 1 ?>%" title="<?cs
-        var:#stats.active_tickets ?> of <?cs
-        var:#stats.total_tickets ?> ticket<?cs
-        if:#stats.total_tickets != #1 ?>s<?cs /if ?> active"></a>
-     </div>
-     <p class="percent"><?cs var:#stats.percent_closed ?>%</p>
-     <dl>
-      <dt>Closed tickets:</dt>
-      <dd><a href="<?cs var:milestone.queries.closed_tickets ?>"><?cs
-        var:stats.closed_tickets ?></a></dd>
-      <dt>Active tickets:</dt>
-      <dd><a href="<?cs var:milestone.queries.active_tickets ?>"><?cs
-        var:stats.active_tickets ?></a></dd>
-     </dl><?cs
-    /if ?><?cs
-   /with ?>
-  </div>
-  <form id="stats" action="" method="get">
-   <fieldset>
-    <legend>
-     <label for="by">Ticket status by</label>
-     <select id="by" name="by" onchange="this.form.submit()"><?cs
-     each:group = milestone.stats.available_groups ?>
-      <option value="<?cs var:group.name ?>" <?cs
-        if:milestone.stats.grouped_by == group.name ?> selected="selected"<?cs
-        /if ?>><?cs var:group.label ?></option><?cs
-     /each ?></select>
-     <noscript><input type="submit" value="Update" /></noscript>
-    </legend>
-    <table summary="Shows the milestone completion status grouped by <?cs
-      var:milestone.stats.grouped_by ?>"><?cs
-     each:group = milestone.stats.groups ?>
-      <tr>
-       <th scope="row"><a href="<?cs
-         var:group.queries.all_tickets ?>"><?cs var:group.name ?></a></th>
-       <td style="white-space: nowrap"><?cs if:#group.total_tickets ?>
-        <div class="progress" style="width: <?cs
-          var:#group.percent_total * #80 / #milestone.stats.max_percent_total ?>%">
-         <a class="closed" href="<?cs
-           var:group.queries.closed_tickets ?>" style="width: <?cs
-           var:#group.percent_closed ?>%" title="<?cs
-          var:group.closed_tickets ?> of <?cs
-          var:group.total_tickets ?> ticket<?cs
-          if:group.total_tickets != #1 ?>s<?cs /if ?> closed"></a>
-         <a class="open" href="<?cs
-           var:group.queries.active_tickets ?>" style="width: <?cs
-           var:#group.percent_active - 1 ?>%" title="<?cs
-          var:group.active_tickets ?> of <?cs
-          var:group.total_tickets ?> ticket<?cs
-          if:group.total_tickets != 1 ?>s<?cs /if ?> active"></a>
-        </div>
-        <p class="percent"><?cs var:group.closed_tickets ?>/<?cs
-         var:group.total_tickets ?></p>
-       <?cs /if ?></td>
-      </tr><?cs
-     /each ?>
-    </table><?cs /if ?>
-   </fieldset>
-  </form>
-  <div class="description"><?cs var:milestone.description ?></div><?cs
-  if:trac.acl.MILESTONE_MODIFY || trac.acl.MILESTONE_DELETE ?>
-   <div class="buttons"><?cs
-    if:trac.acl.MILESTONE_MODIFY ?>
-     <form method="get" action=""><div>
-      <input type="hidden" name="action" value="edit" /><?cs
-      if:milestone.id_param ?>
-       <input type="hidden" name="id" value="<?cs var:milestone.name ?>" /><?cs
-      /if ?>
-      <input type="submit" value="Edit milestone info" accesskey="e" />
-     </div></form><?cs
-    /if ?><?cs
-    if:trac.acl.MILESTONE_DELETE ?>
-     <form method="get" action=""><div>
-      <input type="hidden" name="action" value="delete" /><?cs
-      if:milestone.id_param ?>
-       <input type="hidden" name="id" value="<?cs var:milestone.name ?>" /><?cs
-      /if ?>
-      <input type="submit" value="Delete milestone" />
-     </div></form><?cs
-    /if ?>
-   </div><?cs
-  /if ?><?cs
- /if ?>
-
- <div id="help">
-  <strong>Note:</strong> See <a href="<?cs
-    var:trac.href.wiki ?>/TracRoadmap">TracRoadmap</a> for help on using the roadmap.
- </div>
-
-</div>
-<?cs include:"footer.cs"?>
Index: /home/lg/projs/trac/templates/milestone.cs
===================================================================
--- /home/lg/projs/trac/templates/milestone.cs	(revision 0)
+++ /home/lg/projs/trac/templates/milestone.cs	(revision 2422)
@@ -1 +1,224 @@
+<?cs include:"header.cs"?>
+<?cs include:"macros.cs"?>
+
+<div id="ctxtnav" class="nav"></div>
+
+<div id="content" class="milestone">
+ <?cs if:milestone.mode == "new" ?>
+ <h1>New Milestone</h1>
+ <?cs elif:milestone.mode == "edit" ?>
+ <h1>Edit Milestone <?cs var:milestone.name ?></h1>
+ <?cs elif:milestone.mode == "delete" ?>
+ <h1>Delete Milestone <?cs var:milestone.name ?></h1>
+ <?cs else ?>
+ <h1>Milestone <?cs var:milestone.name ?></h1>
+ <?cs /if ?>
+
+ <?cs if:milestone.mode == "edit" || milestone.mode == "new" ?>
+  <script type="text/javascript">
+    addEvent(window, 'load', function() {
+      document.getElementById('name').focus();
+    });
+  </script>
+  <form id="edit" action="<?cs var:milestone.href ?>" method="post">
+   <input type="hidden" name="id" value="<?cs var:milestone.name ?>" />
+   <input type="hidden" name="action" value="edit" />
+   <div class="field">
+    <label>Name of the milestone:<br />
+    <input type="text" id="name" name="name" size="32" value="<?cs
+      var:milestone.name ?>" /></label>
+   </div>
+   <fieldset>
+    <legend>Schedule</legend>
+    <label>Due:<br />
+     <input type="text" id="duedate" name="duedate" size="<?cs
+       var:len(milestone.date_hint) ?>" value="<?cs
+       var:milestone.due_date ?>" title="Format: <?cs var:milestone.date_hint ?>" />
+     <em>Format: <?cs var:milestone.date_hint ?></em>
+    </label>
+    <div class="field">
+     <label>
+      <input type="checkbox" id="completed" name="completed"<?cs
+        if:milestone.completed ?> checked="checked"<?cs /if ?> />
+      Completed:<br />
+     </label>
+     <label>
+      <input type="text" id="completeddate" name="completeddate" size="<?cs
+        var:len(milestone.date_hint) ?>" value="<?cs
+        alt:milestone.completed_date ?><?cs
+         var:milestone.datetime_now ?><?cs
+        /alt ?>" title="Format: <?cs
+        var:milestone.datetime_hint ?>" />
+      <em>Format: <?cs var:milestone.datetime_hint ?></em>
+     </label>
+     <script type="text/javascript">
+       var completed = document.getElementById("completed");
+       var enableCompletedDate = function() {
+         enableControl("completeddate", completed.checked);
+       };
+       addEvent(window, "load", enableCompletedDate);
+       addEvent(completed, "click", enableCompletedDate);
+     </script>
+    </div>
+   </fieldset>
+   <div class="field">
+    <fieldset class="iefix">
+     <label for="description">Description (you may use <a tabindex="42" href="<?cs
+       var:trac.href.wiki ?>/WikiFormatting">WikiFormatting</a> here):</label>
+     <p><textarea id="description" name="description" class="wikitext" rows="10" cols="78"><?cs
+       var:milestone.description_source ?></textarea></p>
+    </fieldset>
+   </div>
+   <div class="buttons">
+    <?cs if:milestone.mode == "new"
+     ?><input type="submit" value="Add milestone" /><?cs
+    else
+     ?><input type="submit" value="Submit changes" /><?cs
+    /if ?>
+    <input type="submit" name="cancel" value="Cancel" />
+   </div>
+   <script type="text/javascript" src="<?cs
+     var:htdocs_location ?>js/wikitoolbar.js"></script>
+  </form>
+ <?cs elif:milestone.mode == "delete" ?>
+  <form action="<?cs var:milestone.href ?>" method="post">
+   <input type="hidden" name="id" value="<?cs var:milestone.name ?>" />
+   <input type="hidden" name="action" value="delete" />
+   <p><strong>Are you sure you want to delete this milestone?</strong></p>
+   <input type="checkbox" id="retarget" name="retarget" checked="checked"
+       onclick="enableControl('target', this.checked)"/>
+   <label for="target">Retarget associated tickets to milestone</label>
+   <select name="target" id="target">
+    <option value="">None</option><?cs
+     each:other = milestones ?><?cs if:other != milestone.name ?>
+      <option><?cs var:other ?></option><?cs
+     /if ?><?cs /each ?>
+   </select>
+   <div class="buttons">
+    <input type="submit" name="cancel" value="Cancel" />
+    <input type="submit" value="Delete milestone" />
+   </div>
+  </form>
+ <?cs else ?>
+ <?cs if:milestone.mode == "view" ?>
+  <div class="info">
+   <p class="date"><?cs
+    if:milestone.completed_date ?>
+     Completed <?cs var:milestone.completed_delta ?> ago (<?cs var:milestone.completed_date ?>)<?cs
+    elif:milestone.due_date ?><?cs
+     if:milestone.late ?>
+      <strong><?cs var:milestone.due_delta ?> late</strong><?cs
+     else ?>
+      Due in <?cs var:milestone.due_delta ?><?cs
+     /if ?> (<?cs var:milestone.due_date ?>)<?cs
+    else ?>
+     No date set<?cs
+    /if ?>
+   </p><?cs
+   each:stats = milestone.stats ?><?cs
+    if:#stats.count > #0 ?>
+     <div class="progress"><?cs
+      each:interval = stats.intervals ?>
+       <a class="<?cs var:interval.class ?>" href="<?cs
+        var:interval.href?>" style="width: <?cs
+        if: interval.last ?><?cs #IE fix ?><?cs
+         var:interval.percent - 1 ?><?cs
+        else ?><?cs
+         var:interval.percent ?><?cs
+        /if ?>%" title="<?cs
+        var:#interval.count ?> of <?cs
+        var:#stats.count ?> <?cs var:stats.unit ?><?cs
+        if:#stats.count != #1 ?>s<?cs /if ?> <?cs var:interval.title ?>"></a><?cs
+      /each ?>
+     </div>
+     <p class="percent"><?cs var:#stats.done_percent ?>%</p>
+     <dl><?cs
+      each:interval = stats.intervals ?>
+      <dt><?cs var:interval.caps_title ?> <?cs var:stats.unit ?>s:</dt>
+      <dd><a href="<?cs var:interval.href ?>"><?cs
+        var:interval.count ?></a></dd><?cs
+      /each ?>
+     </dl><?cs
+    /if ?><?cs
+   /each ?>
+  </div>
+  <div id="group_stats"><?cs
+  each:stats = milestone.stats ?>
+   <form id="stats" action="" method="get">
+    <fieldset>
+     <legend>
+      <label for="by"><?cs var:stats.caps_title ?> by</label>
+      <select id="by" name="by" onchange="this.form.submit()"><?cs
+      each:group = stats.available_groups ?>
+       <option value="<?cs var:group.name ?>" <?cs
+         if:stats.grouped_by == group.name ?> selected="selected"<?cs
+         /if ?>><?cs var:group.label ?></option><?cs
+      /each ?></select>
+      <noscript><input type="submit" value="Update" /></noscript>
+     </legend>
+     <table summary="Shows the milestone <?cs var:stats.title ?> grouped by <?cs
+       var:stats.grouped_by ?>"><?cs
+      each:group = stats.groups ?>
+       <tr>
+        <th scope="row"><a href="<?cs
+           var:group.href ?>"><?cs var:group.name ?></a></th>
+        <td style="white-space: nowrap"><?cs
+         if:#group.count ?>
+          <div class="progress" style="width: <?cs
+           var:group.percent_of_max_total * 80 / 100?>%"><?cs
+           each:interval = group.intervals ?>
+             <a class="<?cs var:interval.class ?>" href="<?cs
+             var:interval.href?>" style="width: <?cs
+              if: interval.last ?><?cs #IE fix ?><?cs
+               var:interval.percent - 1 ?><?cs
+              else ?><?cs
+               var:interval.percent ?><?cs
+              /if ?>%" title="<?cs
+              var:#interval.count ?> of <?cs
+              var:#group.count ?> <?cs var:group.unit ?><?cs
+             if:#group.count != #1 ?>s<?cs /if ?> <?cs var:interval.title ?>"></a><?cs
+           /each ?>
+           </div>         
+          <p class="percent"><?cs var:group.done_count ?>/<?cs
+          var:group.count ?></p>
+         <?cs /if ?></td>
+        </tr><?cs
+       /each ?>
+      </table>
+     </fieldset>
+    </form><?cs
+   /each ?></div><?cs
+  /if ?>
+
+  <div class="description"><?cs var:milestone.description ?></div><?cs
+  if:trac.acl.MILESTONE_MODIFY || trac.acl.MILESTONE_DELETE ?>
+   <div class="buttons"><?cs
+    if:trac.acl.MILESTONE_MODIFY ?>
+     <form method="get" action=""><div>
+      <input type="hidden" name="action" value="edit" /><?cs
+      if:milestone.id_param ?>
+       <input type="hidden" name="id" value="<?cs var:milestone.name ?>" /><?cs
+      /if ?>
+      <input type="submit" value="Edit milestone info" accesskey="e" />
+     </div></form><?cs
+    /if ?><?cs
+    if:trac.acl.MILESTONE_DELETE ?>
+     <form method="get" action=""><div>
+      <input type="hidden" name="action" value="delete" /><?cs
+      if:milestone.id_param ?>
+       <input type="hidden" name="id" value="<?cs var:milestone.name ?>" /><?cs
+      /if ?>
+      <input type="submit" value="Delete milestone" />
+     </div></form><?cs
+    /if ?>
+   </div><?cs
+  /if ?><?cs
+/if ?>
+
+ <div id="help">
+  <strong>Note:</strong> See <a href="<?cs
+    var:trac.href.wiki ?>/TracRoadmap">TracRoadmap</a> for help on using the roadmap.
+ </div>
+
+</div>
+<?cs include:"footer.cs"?>
Index: /home/lg/projs/trac/templates/roadmap.cs
===================================================================
--- /home/lg/projs/trac/templates/roadmap.cs	(revision 2426)
+++ /home/lg/projs/trac/templates/roadmap.cs	(working copy)
@@ -35,33 +35,32 @@
       No date set<?cs
      /if ?>
     </p><?cs
-    with:stats = milestone.stats ?><?cs
-     if:#stats.total_tickets > #0 ?>
-      <div class="progress">
-       <a class="closed" href="<?cs
-         var:milestone.queries.closed_tickets ?>" style="width: <?cs
-         var:#stats.percent_closed ?>%" title="<?cs
-         var:#stats.closed_tickets ?> of <?cs
-         var:#stats.total_tickets ?> ticket<?cs
-         if:#stats.total_tickets != #1 ?>s<?cs /if ?> closed"></a>
-       <a class="open" href="<?cs
-         var:milestone.queries.active_tickets ?>" style="width: <?cs
-         var:#stats.percent_active - 1 ?>%" title="<?cs
-         var:#stats.active_tickets ?> of <?cs
-         var:#stats.total_tickets ?> ticket<?cs
-         if:#stats.total_tickets != #1 ?>s<?cs /if ?> active"></a>
-      </div>
-      <p class="percent"><?cs var:#stats.percent_closed ?>%</p>
-      <dl>
-       <dt>Closed tickets:</dt>
-       <dd><a href="<?cs var:milestone.queries.closed_tickets ?>"><?cs
-         var:stats.closed_tickets ?></a></dd>
-       <dt>Active tickets:</dt>
-       <dd><a href="<?cs var:milestone.queries.active_tickets ?>"><?cs
-         var:stats.active_tickets ?></a></dd>
+    each:stats = milestone.stats ?><?cs
+     if:#stats.count > #0 ?>
+      <div class="progress"><?cs 
+       each:interval = stats.intervals ?>
+        <a class="<?cs var:interval.class ?>" href="<?cs
+         var:interval.href?>" style="width: <?cs
+         if: interval.last ?><?cs #IE fix ?><?cs
+          var:interval.percent - 1 ?><?cs
+         else ?><?cs
+          var:interval.percent ?><?cs
+         /if ?>%" title="<?cs
+         var:#interval.count ?> of <?cs
+         var:#stats.count ?> <?cs var:stats.unit ?><?cs
+         if:#stats.count != #1 ?>s<?cs /if ?> <?cs var:interval.title ?>"></a><?cs
+       /each ?>
+      </div>     
+      <p class="percent"><?cs var:#stats.done_percent ?>%</p>
+      <dl><?cs
+       each:interval = stats.intervals ?>
+       <dt><?cs var:interval.caps_title ?> <?cs var:stats.unit ?>s:</dt>
+       <dd><a href="<?cs var:interval.href ?>"><?cs
+         var:interval.count ?></a></dd><?cs 
+       /each ?>
       </dl><?cs
      /if ?><?cs
-    /with ?>
+    /each ?>
    </div>
    <div class="description"><?cs var:milestone.description ?></div>
   </li><?cs
Index: /home/lg/projs/trac/trac/ticket/roadmap.py
===================================================================
--- /home/lg/projs/trac/trac/ticket/roadmap.py	(revision 2426)
+++ /home/lg/projs/trac/trac/ticket/roadmap.py	(working copy)
@@ -24,6 +24,7 @@
 from trac.util import enum, escape, format_date, format_datetime, \
                       parse_date, pretty_timedelta, shorten_line, CRLF
 from trac.ticket import Milestone, Ticket, TicketSystem
+from trac.ticket.query import Query
 from trac.Timeline import ITimelineEventProvider
 from trac.web import IRequestHandler
 from trac.web.chrome import add_link, add_stylesheet, INavigationContributor
@@ -29,59 +30,100 @@
 from trac.web.chrome import add_link, add_stylesheet, INavigationContributor
 from trac.wiki import wiki_to_html, wiki_to_oneliner, IWikiSyntaxProvider
 
-def get_tickets_for_milestone(env, db, milestone, field='component'):
-    cursor = db.cursor()
-    fields = TicketSystem(env).get_ticket_fields()
-    if field in [f['name'] for f in fields if not f.get('custom')]:
-        cursor.execute("SELECT id,status,%s FROM ticket WHERE milestone=%%s "
-                       "ORDER BY %s" % (field, field), (milestone,))
-    else:
-        cursor.execute("SELECT id,status,value FROM ticket LEFT OUTER "
-                       "JOIN ticket_custom ON (id=ticket AND name=%s) "
-                       "WHERE milestone=%s ORDER BY value", (field, milestone))
-    tickets = []
-    for tkt_id, status, fieldval in cursor:
-        tickets.append({'id': tkt_id, 'status': status, field: fieldval})
-    return tickets
+
+class ITicketGroupStatsProvider(Interface):
+    def get_ticket_group_stats(self, tickets):
+        """ Gather statistics on a group of tickets.
+
+        This method returns a valid TicketGroupStats object.
+        """
+
+class TicketGroupStats(object):
+    """Encapsulates statistics on a group of tickets."""
+
+    def __init__(self, title, unit):
+        """Creates a new TicketGroupStats object.
 
-def get_query_links(env, milestone, grouped_by='component', group=None):
-    q = {}
-    if not group:
-        q['all_tickets'] = env.href.query(milestone=milestone)
-        q['active_tickets'] = env.href.query(milestone=milestone,
-                                             status=('new', 'assigned', 'reopened'))
-        q['closed_tickets'] = env.href.query(milestone=milestone, status='closed')
-    else:
-        q['all_tickets'] = env.href.query({grouped_by: group},
-                                          milestone=milestone)
-        q['active_tickets'] = env.href.query({grouped_by: group},
-                                             milestone=milestone,
-                                             status=('new', 'assigned', 'reopened'))
-        q['closed_tickets'] = env.href.query({grouped_by: group},
-                                             milestone=milestone,
-                                             status='closed')
-    return q
+        title is the display name of this group of stats (eg 'ticket status').
+        unit is the display name of the units for these stats (eg 'hour').
+        """
+        self.title = title
+        self.unit = unit
+        self.count = 0
+        self.qry_args = {}
+        self.intervals = []
+        self.done_percent = 0
+        self.done_count = 0
 
-def calc_ticket_stats(tickets):
-    total_cnt = len(tickets)
-    active = [ticket for ticket in tickets if ticket['status'] != 'closed']
-    active_cnt = len(active)
-    closed_cnt = total_cnt - active_cnt
+    def add_interval(self, title, count, qry_args, css_class, countsToProg=0):
+        """Adds a division to this stats' group's progress bar.
 
-    percent_active, percent_closed = 0, 0
-    if total_cnt > 0:
-        percent_active = round(float(active_cnt) / float(total_cnt) * 100)
-        percent_closed = round(float(closed_cnt) / float(total_cnt) * 100)
-        if percent_active + percent_closed > 100:
-            percent_closed -= 1
+        title is the display name (eg 'closed', 'spent effort') of this interval
+          that will be displayed in front of the unit name.
+        count is the number of units in the interval.
+        qry_args is a dict of extra params that will yield the subset of
+          tickets in this interval on a query.
+        css_class is the css class that will be used to display the division.
+        countsToProg can be set to true to make this interval count towards
+          overall completion of this group of tickets.
+        """
+        self.intervals.append({
+            'title': title,
+            'count': count,
+            'qry_args': qry_args,
+            'class': css_class,
+            'percent': None,
+            'countsToProg': countsToProg
+        })
+        self.count = self.count + count
+        self._refresh_calcs()
 
-    return {
-        'total_tickets': total_cnt,
-        'active_tickets': active_cnt,
-        'percent_active': percent_active,
-        'closed_tickets': closed_cnt,
-        'percent_closed': percent_closed
-    }
+    def _refresh_calcs(self):
+        if self.count < 1:
+            return
+        total_percent = 0
+        self.done_percent = 0
+        self.done_count = 0
+        for interval in self.intervals:
+            interval['percent'] = round(float(interval['count'] / 
+                                        float(self.count) * 100))
+            total_percent = total_percent + interval['percent']
+            if interval['countsToProg']:
+                self.done_percent += interval['percent']
+                self.done_count += interval['count']
+
+        if self.done_count and total_percent != 100:
+            fudge_int = [i for i in self.intervals if i['countsToProg']][0]
+            fudge_amt = 100 - total_percent
+            fudge_int['percent'] += fudge_amt
+            self.done_percent += fudge_amt
+
+
+def get_tickets_for_milestone(env, milestone_name, order='component'):
+    return Query(env, {'milestone': [milestone_name]}, order).execute()
+
+def get_ticket_stats(providers, tickets):
+    stats = []
+    for provider in providers:
+        stat = provider.get_ticket_group_stats(tickets)
+        stats.append(stat)
+    return stats
+
+class DefaultTicketGroupStatsProvider(Component):
+    implements(ITicketGroupStatsProvider)
+
+    def get_ticket_group_stats(self, tickets):
+        total_cnt = len(tickets)
+        active = [ticket for ticket in tickets if ticket['status'] != 'closed']
+        active_cnt = len(active)
+        closed_cnt = total_cnt - active_cnt
+
+        stat = TicketGroupStats('ticket status', 'ticket')
+        stat.add_interval('closed', closed_cnt, {'status': 'closed'},
+                          'closed', True)
+        stat.add_interval('active', active_cnt,
+                     {'status': ['new', 'assigned', 'reopened']}, 'open', False)
+        return stat
 
 def milestone_to_hdf(env, db, req, milestone):
     safe_name = None
@@ -103,6 +145,37 @@
         hdf['completed_delta'] = pretty_timedelta(milestone.completed)
     return hdf
 
+def milestone_stat_to_hdf(env, stat, name, grouped_by='component', group=None):
+    def merge_cp(dict1, dict2):
+        cp = dict1.copy()
+        cp.update(dict2)
+        return cp
+
+    hdf = {}
+    base_args = {'milestone': name, grouped_by: group}
+    hdf['title'] = stat.title
+    hdf['caps_title'] = stat.title.capitalize()
+    hdf['count'] = stat.count
+    hdf['unit']= stat.unit
+    hdf['href'] = env.href.query(merge_cp(base_args, stat.qry_args))
+    hdf['done_percent'] = stat.done_percent
+    hdf['done_count'] = stat.done_count
+    hdf['intervals'] = []
+    i = 0
+    for i in range(len(stat.intervals)):
+        interval = stat.intervals[i]
+        int_hdf = {}
+        for (key,val) in interval.items():
+            if key != 'qry_args':
+                int_hdf[key] = val
+        int_hdf['href'] = env.href.query(
+                                    merge_cp(base_args, interval['qry_args']))
+        int_hdf['caps_title'] = interval['title'].capitalize()
+        int_hdf['last'] = i == len(stat.intervals) - 1
+        hdf['intervals'].append(int_hdf)
+        i += 1
+    return hdf
+
 def _get_groups(env, db, by='component'):
     for field in TicketSystem(env).get_ticket_fields():
         if field['name'] == by:
@@ -119,6 +192,7 @@
 class RoadmapModule(Component):
 
     implements(INavigationContributor, IPermissionRequestor, IRequestHandler)
+    stats_providers = ExtensionPoint(ITicketGroupStatsProvider)
 
     # INavigationContributor methods
 
@@ -157,11 +231,14 @@
 
         for idx,milestone in enum(milestones):
             prefix = 'roadmap.milestones.%d.' % idx
-            tickets = get_tickets_for_milestone(self.env, db, milestone['name'],
-                                                'owner')
-            req.hdf[prefix + 'stats'] = calc_ticket_stats(tickets)
-            for k, v in get_query_links(self.env, milestone['name']).items():
-                req.hdf[prefix + 'queries.' + k] = escape(v)
+            tickets = get_tickets_for_milestone(self.env, milestone['name'],
+                                                 'owner')
+            stats = get_ticket_stats(self.stats_providers, tickets)
+            stat_no = 0
+            for stat in stats:
+                req.hdf['%sstats.%s' % (prefix, stat_no)] = \
+                       milestone_stat_to_hdf(self.env, stat, milestone['name'])
+                stat_no += 1
             milestone['tickets'] = tickets # for the iCalendar view
 
         if req.args.get('format') == 'ics':
@@ -281,6 +358,8 @@
     implements(INavigationContributor, IPermissionRequestor, IRequestHandler,
                ITimelineEventProvider, IWikiSyntaxProvider)
 
+    stats_providers = ExtensionPoint(ITicketGroupStatsProvider)
+
     # INavigationContributor methods
 
     def get_active_navigation_item(self, req):
@@ -456,7 +535,6 @@
                                          'label': field['label']})
                 if field['name'] == 'component':
                     component_group_available = True
-        req.hdf['milestone.stats.available_groups'] = available_groups
 
         if component_group_available:
             by = req.args.get('by', 'component')
@@ -462,17 +540,22 @@
             by = req.args.get('by', 'component')
         else:
             by = req.args.get('by', available_groups[0]['name'])
-        req.hdf['milestone.stats.grouped_by'] = by
 
-        tickets = get_tickets_for_milestone(self.env, db, milestone.name, by)
-        stats = calc_ticket_stats(tickets)
-        req.hdf['milestone.stats'] = stats
-        for key, value in get_query_links(self.env, milestone.name).items():
-            req.hdf['milestone.queries.' + key] = escape(value)
+        tickets = get_tickets_for_milestone(self.env, milestone.name, by)
+        stats = get_ticket_stats(self.stats_providers, tickets)
+        stat_no = 0
+        for stat in stats:
+            prefix = 'milestone.stats.%s' % stat_no
+            req.hdf['%s.available_groups' % prefix] = available_groups
+            req.hdf['%s.grouped_by' % prefix] = by
+            req.hdf[prefix] = milestone_stat_to_hdf(self.env, stat,
+                                                      milestone.name)
+            stat_no += 1
 
         groups = _get_groups(self.env, db, by)
         group_no = 0
-        max_percent_total = 0
+        max_counts = [0 for prov in self.stats_providers]
+        group_stats = [[] for prov in self.stats_providers]
         for group in groups:
             group_tickets = [t for t in tickets if t[by] == group]
             if not group_tickets:
@@ -477,21 +560,34 @@
             group_tickets = [t for t in tickets if t[by] == group]
             if not group_tickets:
                 continue
-            prefix = 'milestone.stats.groups.%s' % group_no
-            req.hdf['%s.name' % prefix] = group
-            percent_total = 0
-            if len(tickets) > 0:
-                percent_total = float(len(group_tickets)) / float(len(tickets))
-                if percent_total > max_percent_total:
-                    max_percent_total = percent_total
-            req.hdf['%s.percent_total' % prefix] = percent_total * 100
-            stats = calc_ticket_stats(group_tickets)
-            req.hdf[prefix] = stats
-            for key, value in get_query_links(self.env, milestone.name,
-                                              by, group).items():
-                req.hdf['%s.queries.%s' % (prefix, key)] = escape(value)
+
+            gstats = get_ticket_stats(self.stats_providers, group_tickets)
+            stat_no = 0
+            for gstat in gstats:
+                prefix = 'milestone.stats.%s.groups.%s' % (stat_no, group_no)
+                req.hdf['%s.name' % prefix] = group
+                req.hdf[prefix] = milestone_stat_to_hdf(self.env, gstat,
+                                                     milestone.name, by, group)
+                if gstat.count > max_counts[stat_no]:                    
+                    max_counts[stat_no] = gstat.count
+                    
+                group_stats[stat_no].append(gstat)
+                stat_no += 1
+
             group_no += 1
-        req.hdf['milestone.stats.max_percent_total'] = max_percent_total * 100
+        
+        stat_no = 0
+
+        for stat in stats:
+            prefix = 'milestone.stats.%s' % stat_no            
+            grp_no = 0
+            for gstat in group_stats[stat_no]:
+                req.hdf['%s.groups.%s.percent_of_max_total' % (prefix, grp_no)]\
+                                     = round((float(gstat.count) /
+                                              float(max_counts[stat_no]) * 100))
+                grp_no +=1
+
+            stat_no += 1
 
     # IWikiSyntaxProvider methods
 
@@ -504,3 +600,4 @@
     def _format_link(self, formatter, ns, name, label):
         return '<a class="milestone" href="%s">%s</a>' \
                % (formatter.href.milestone(name), label)
+
Index: /home/lg/projs/trac/trac/ticket/tests/__init__.py
===================================================================
--- /home/lg/projs/trac/trac/ticket/tests/__init__.py	(revision 2426)
+++ /home/lg/projs/trac/trac/ticket/tests/__init__.py	(working copy)
@@ -1,6 +1,6 @@
 import unittest
 
-from trac.ticket.tests import api, model, query
+from trac.ticket.tests import api, model, query, roadmap
 
 def suite():
     suite = unittest.TestSuite()
@@ -7,6 +7,7 @@
     suite.addTest(api.suite())
     suite.addTest(model.suite())
     suite.addTest(query.suite())
+    suite.addTest(roadmap.suite())
     return suite
 
 if __name__ == '__main__':
Index: /home/lg/projs/trac/trac/ticket/tests/roadmap.py
===================================================================
--- /home/lg/projs/trac/trac/ticket/tests/roadmap.py	(revision 0)
+++ /home/lg/projs/trac/trac/ticket/tests/roadmap.py	(revision 0)
@@ -1 +1,150 @@
+from trac.config import Configuration
+from trac.test import EnvironmentStub
+from trac.ticket.roadmap import *
+from trac.core import ComponentManager
+
+import unittest
+
+class TicketGroupStatsTestCase(unittest.TestCase):
+
+    def setUp(self):
+        self.stats = TicketGroupStats('title', 'unit')
+
+    def test_init(self):
+        self.assertEquals('title', self.stats.title, 'title incorrect')
+        self.assertEquals('unit', self.stats.unit, 'unit incorrect')
+        self.assertEquals(0, self.stats.count, 'count not zero')
+        self.assertEquals(0, len(self.stats.intervals), 'intervals not empty')
+
+    def test_add_iterval(self):
+        self.stats.add_interval('intTitle', 3, {'k1': 'v1'}, 'css', 0)
+        self.assertEquals(3, self.stats.count, 'count not incremented')
+        int = self.stats.intervals[0]
+        self.assertEquals('intTitle', int['title'], 'title incorrect')
+        self.assertEquals(3, int['count'], 'count incorrect')
+        self.assertEquals({'k1': 'v1'}, int['qry_args'], 'query args incorrect')
+        self.assertEquals('css', int['class'], 'css class incorrect')
+        self.assertEquals(100, int['percent'], 'percent incorrect')
+        self.stats.add_interval('intTitle', 3, {'k1': 'v1'}, 'css', 0)
+        self.assertEquals(50, int['percent'], 'percent not being updated')
+
+    def test_add_interval_no_prog(self):
+        self.stats.add_interval('intTitle', 3, {'k1': 'v1'}, 'css', 0)
+        self.stats.add_interval('intTitle', 5, {'k1': 'v1'}, 'css', 0)
+        int = self.stats.intervals[1]
+        self.assertEquals(0, self.stats.done_count, 'count added for no prog')
+        self.assertEquals(0, self.stats.done_percent, 'percent incremented')
+
+    def test_add_interval_prog(self):
+        self.stats.add_interval('intTitle', 3, {'k1': 'v1'}, 'css', 0)
+        self.stats.add_interval('intTitle', 1, {'k1': 'v1'}, 'css', 1)
+        self.assertEquals(4, self.stats.count, 'count not incremented')
+        self.assertEquals(1, self.stats.done_count, 'count not added to prog')
+        self.assertEquals(25, self.stats.done_percent, 'done percent not incr')
+
+    def test_add_interval_fudging(self):
+        self.stats.add_interval('intTitle', 3, {'k1': 'v1'}, 'css', 0)
+        self.stats.add_interval('intTitle', 5, {'k1': 'v1'}, 'css', 1)
+        self.assertEquals(8, self.stats.count, 'count not incremented')
+        self.assertEquals(5, self.stats.done_count, 'count not added to prog')
+        self.assertEquals(62, self.stats.done_percent,
+                          'done percnt not fudged downward')
+        self.assertEquals(62, self.stats.intervals[1]['percent'],
+                          'interval percent not fudged downward')
+        self.assertEquals(38, self.stats.intervals[0]['percent'],
+                          'interval percent not fudged upward')
+
+
+class DefaultTicketGroupStatsProviderTestCase(unittest.TestCase):
+
+    def setUp(self):
+        self.env = EnvironmentStub(default_data=True)
+        tkt1 = Ticket(self.env)
+        tkt1.populate({'summary': 'Foo', 'milestone': 'Test', 'owner': 'foman',
+                        'status': 'open'})
+        tkt2 = Ticket(self.env)
+        tkt2.populate({'summary': 'Bar', 'milestone': 'Test',
+                        'status': 'closed', 'owner': 'barman'})
+        tkt3 = Ticket(self.env)
+        tkt3.populate({'summary': 'Sum', 'milestone': 'Test', 'owner': 'suman',
+                        'status': 'reopened'})
+        prov = DefaultTicketGroupStatsProvider(ComponentManager())
+        self.stats = prov.get_ticket_group_stats([tkt1, tkt2, tkt3])
+
+    def test_stats(self):
+        self.assertEquals(self.stats.title, 'ticket status', 'title incorrect')
+        self.assertEquals(self.stats.unit, 'ticket', 'unit incorrect')
+        self.assertEquals(2, len(self.stats.intervals), 'more than 2 intervals')
+
+    def test_closed_interval(self):
+        closed = self.stats.intervals[0]
+        self.assertEquals('closed', closed['title'], 'closed title incorrect')
+        self.assertEquals('closed', closed['class'], 'closed class incorrect')
+        self.assertEquals(True, closed['countsToProg'],
+                          'closed not count to prog')
+        self.assertEquals({'status': 'closed'}, closed['qry_args'],
+                          'qry_args incorrect')
+        self.assertEquals(1, closed['count'], 'closed count incorrect')
+        self.assertEquals(33, closed['percent'], 'closed percent incorrect')
+
+    def test_open_interval(self):
+        open = self.stats.intervals[1]
+        self.assertEquals('active', open['title'], 'open title incorrect')
+        self.assertEquals('open', open['class'], 'open class incorrect')
+        self.assertEquals(False, open['countsToProg'],
+                          'open not count to prog')
+        self.assertEquals({'status': ['new', 'assigned', 'reopened']},
+                          open['qry_args'], 'qry_args incorrect')
+        self.assertEquals(2, open['count'], 'open count incorrect')
+        self.assertEquals(67, open['percent'], 'open percent incorrect')
+
+
+def in_tlist(ticket, list):
+    return len([t for t in list if t['id'] == ticket.id]) > 0
+
+class RoadmapModuleTestCase(unittest.TestCase):
+
+    def setUp(self):
+        self.env = EnvironmentStub(default_data=True)
+
+        self.milestone1 = Milestone(self.env)
+        self.milestone1.name = 'Test'
+        self.milestone1.insert()
+        self.milestone2 = Milestone(self.env)
+        self.milestone2.name = 'Test2'
+        self.milestone2.insert()
+
+        tkt1 = Ticket(self.env)
+        tkt1.populate({'summary': 'Foo', 'milestone': 'Test', 'owner': 'foman'})
+        tkt1.insert()
+        tkt2 = Ticket(self.env)
+        tkt2.populate({'summary': 'Bar', 'milestone': 'Test2',
+                        'status': 'closed', 'owner': 'barman'})
+        tkt2.insert()
+        tkt3 = Ticket(self.env)
+        tkt3.populate({'summary': 'Sum', 'milestone': 'Test', 'owner': 'suman'})
+        tkt3.insert()
+        self.tkt1 = tkt1
+        self.tkt2 = tkt2
+        self.tkt3 = tkt3
+
+    def test_get_tickets_for_milestone(self):
+        tkts1 = get_tickets_for_milestone(self.env, self.milestone1.name)
+        tkts2 = get_tickets_for_milestone(self.env, self.milestone2.name)
+        self.assertTrue(in_tlist(self.tkt1, tkts1) and in_tlist(self.tkt3, tkts1),
+                         'tickets that should be returned were not')
+        self.assertFalse(in_tlist(self.tkt2, tkts1),
+                         'tickets that should not have been returned were')
+        self.assertTrue(in_tlist(self.tkt2, tkts2), 'multiple milestones wrong')
+
+
+def suite():
+    suite = unittest.TestSuite()
+    suite.addTest(unittest.makeSuite(TicketGroupStatsTestCase, 'test'))
+    suite.addTest(unittest.makeSuite(DefaultTicketGroupStatsProviderTestCase,
+                                      'test'))
+    suite.addTest(unittest.makeSuite(RoadmapModuleTestCase, 'test'))
+    return suite
+
+if __name__ == '__main__':
+    unittest.main(defaultTest='suite')
\ No newline at end of file

Property changes on: /home/lg/projs/trac/trac/ticket/tests/roadmap.py
___________________________________________________________________
Name: svn:executable
   + *



More information about the Trac mailing list