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:
- distinguish between remote and on-site hours spent
- distinguish between billable, non-billable and already-billed hours
- prevent billable hours from being archived until they're either marked 'billed' or 'non-billable'
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:
- Show a summary of total billable time
- Show a detailed listing of all timelog items for this issue
- 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"""
passWe 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.