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""" 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.