Roundup Tracker

NOTE this example is designed to work with Roundup 0.7 and hasn't been updated to 0.8!

If you're happy with your TimeLog implementation, you may want to take it one step further.

We want to not only track time spent, but also:

Redefinine the timelog class

To do this, we first need some extra fields on the timelog class definition in dbinit.py:

timelog = Class(db, "timelog", period=Interval(), onsite=Boolean(), bill=Number())

Create templating macros

Next, we need to do some templating work. The code below assumes you consolidate your customizations in a macro library $TRACKER_HOME/html/lib.html. I'll first give you the macros, then will show how to call them from your issue templates.

The macro for the timelog form element is as follows:

<tal:block metal:define-macro="timelog_form">
 <tr>
  <th>Time Log</th>
  <td>
   <input type="text" name="timelog-1@period" size="5"/>[hh:mm]
   <input type="checkbox" name="timelog-1@onsite" value="1" /> onsite
   <input type="hidden" name="@link@timelog" value="timelog-1" />
  </td>
 </tr>
</tal:block>

This adds just the checkbox for onsite visits, in comparison with the original TimeLog example.

The macro for showing timelog items on an issue is:

<tal:block metal:define-macro="timelog_display">
<form method="POST">
<table class="otherinfo" tal:condition="context/timelog">
 <tr tal:condition="python:request.user.hasPermission('Editfinadmin', context._classname)">
  <th class="header">Time Billable</th>
  <th class="header"
         tal:content="python:utils.totalTimeBillable(context.timelog)" >
         total time billable</th>
  <th class="header"
         tal:content="python:utils.totalOnsiteBillable(context.timelog)" >
         total onsite billable</th>
  <th></th>
  <th></th>
 </tr>
 <tr>
 <th>Date</th><th>Period</th><th>Onsite</th><th>Logged By</th><th>Billable</th>
 </tr>
 <tr tal:repeat="time context/timelog">
  <td tal:content="time/creation"></td>
  <td tal:content="time/period"></td>
  <td tal:content="time/onsite"></td>
  <td tal:content="time/creator"></td>
  <td>
  <tal:block tal:condition="python:request.user.hasPermission('Editfinadmin', context._classname)">
       <input type="radio" value="-1"
         tal:attributes="name string:timelog${time/id}@bill; checked python:time.bill==-1.0" />
                   no
        <input type="radio" value="0"
         tal:attributes="name string:timelog${time/id}@bill; checked python:time.bill!=1.0 and time.bill!=-1.0" />
                     yes
       <input type="radio" value="1"
         tal:attributes="name string:timelog${time/id}@bill; checked python:time.bill==1.0" />
                       billed
  </tal:block>
  </td>
 </tr>
 <tr class="total">
  <td>Time Logged</td>
  <td
         tal:content="python:utils.totalTime(context.timelog)" >
         total time spent</td>
  <td
         tal:content="python:utils.totalOnsite(context.timelog)" >
         total onsite</td>
  <td></td>
  <td>
       <input type="submit" value="change" />
       <input type="hidden" name="@action" value="edit">
       <tal:block replace="structure request/indexargs_form" />
  </td>
 </tr>
</table>
</form>
</tal:block>

Yeah, that's a lot of code. What is does is:

  1. Show a summary of total billable time
  2. Show a detailed listing of all timelog items for this issue
  3. Show a sumtotal of all time logged

In addition, it gives you a radio choice per timelog item where you can switch from the default state 'billable' to either 'billed' or 'non-billable'.

Calling the macros

You'll call the 'timelog_form' macro from within the main editing form in issue.item.html:

<tal:block metal:use-macro="templates/lib/macros/timelog_form" />

This inserts an extra table row. You can put this right above the change note, for example.

The timelog display should be placed outside the editing form. It akin to the files section both conceptually and in style. Put this after the activity block, before the files section:

<tal:block metal:use-macro="templates/lib/macros/timelog_display" />

Python utility and detector logic

To get this to work, you'll need some complex time calculations that are beyond the scope of mere templating. These are implemented in pure python. You need to put those in interfaces.py. However, we need the same python logic in our detectors.

One way to solve this problem, is to put all this logic in $TRACKER_HOME/detectors/lib.py:

#!/usr/bin/python

"""Helper logic for both interfaces and detectors"""

from roundup.date import Interval

class TimelogParser:
    """operate on collections of timelogs"""

    def idlist2times(self, db, idlist):
        """convert a list of timelog ids into a list of timelog instances"""
        return [ db.timelog.getnode(tid) for tid in idlist ]

    def getPeriod(self, time):
        """cover up the differences between HTMLProperty 'Interval' and date.Interval"""
        try:
            if time.period._value == None: return Interval('0d')
            return time.period._value
        except AttributeError:
            return time.period

    def getOnsite(self, time):
        """cover up the difference between HTMLProperty and Integer"""
        try:
            if time.onsite._value == None: return 0
            return time.onsite._value
        except AttributeError:
            return time.onsite

    def totalTime(self, times):
        ''' Call me with a list of timelog items (which have an
            Interval "period" property)
        '''
        total = Interval('0d')
        for time in times:
            total += self.getPeriod(time)
        return total

    def totalTimeBilled(self, times):
        ''' Call me with a list of timelog items (which have an
            Interval "period" property)
        '''
        total = Interval('0d')
        for time in times:
            if time.bill==1.0: total += self.getPeriod(time)
        return total

    def totalTimeBillable(self, times):
        ''' Call me with a list of timelog items (which have an
            Interval "period" property)
        '''
        total = Interval('0d')
        for time in times:
            if time.bill!=-1.0 and time.bill!=1.0:
                total += self.getPeriod(time)
        return total

    def totalTimeBoolean(self, times):
        return self.totalTime(times) != Interval('0d')

    def totalTimeBillableBoolean(self, times):
        return self.totalTimeBillable(times) != Interval('0d')

    def totalOnsite(self, times):
       total = 0
       for time in times:
               total += self.getOnsite(time)
       return total

    def totalOnsiteBilled(self, times):
       total = 0
       for time in times:
           if time.bill==1.0:
               total += self.getOnsite(time)
       return total

    def totalOnsiteBillable(self, times):
       total = 0
       for time in times:
            if time.bill!=-1.0 and time.bill!=1.0:
               total += self.getOnsite(time)
       return total

def init(db):
    """this is just a library, do nothing"""
    pass

We can now import this logic into interfaces.py:

from detectors import lib

class TemplatingUtils(lib.TimelogParser):
    ''' Methods implemented on this class will be available to HTML templates
        through the 'utils' variable.
    '''

By subclassing lib.TimelogParser, all its methods are exposed through TemplatingUtils and callable as eg. utils.totalOnSite(context.timelog) as you can see in the metal macros above.

Finally, as I said we also want to use this logic in a detector. We want to avoid archiving issues that have 'billable' (i.e. not 'billed' and not 'unbillable') timelogs.

In $INSTANCE_HOME/detectors/statusauditor.py:

def keepbillable(db, cl, nodeid, newvalues):
    '''If an issue/project contains billable timelog,
    mark the issue/project itself billable instead of done or archive'''
    if not newvalues.has_key('status'): return
    if newvalues['status'] not in \
       [db.status.lookup('done'),db.status.lookup('archive')]: return
    tp = TimelogParser()
    times = tp.idlist2times(db, cl.get(nodeid, 'timelog'))
    if tp.totalTimeBillableBoolean(times) or tp.totalOnsiteBillable(times):
        newvalues['status'] = db.status.lookup('billable')


def init(db):
    db.issue.audit('set', keepbillable)

Which blocks status changes to 'done' or 'archive' if there's unbilled timelogs. The 'archive' status is not standard in roundup, you can replace this with another status or delete it.

System Message: SEVERE/4 (<string>, line 249)

Incomplete section title.

----
CategoryInterfaceWeb CategoryDetectors