working : Work time tracking

The lino_xl.lib.working adds functionality for managing work time.

This is a tested document. The following instructions are used for initialization:

>>> from lino import startup
>>> startup('lino_book.projects.noi1e.settings.demo')
>>> from lino.api.doctest import *

Note that the demo data is on fictive demo date May 22, 2015:

>>> dd.today()
datetime.date(2015, 5, 22)

The ticket_model defines what a ticket actually is. In Lino Noi this points to tickets.Ticket. It can be any model that implements Workable.

Work sessions

A site manager can see all sessions of the demo project:

>>> rt.show(working.Sessions, limit=15)
... 
==================================================== ========= ============ ============ ============ ========== ============ =============================== ========== ===================
 Ticket                                               Worker    Start date   Start time   End Date     End Time   Break Time   Summary                         Duration   Ticket #
---------------------------------------------------- --------- ------------ ------------ ------------ ---------- ------------ ------------------------------- ---------- -------------------
 #97 ('NoneType' object has no attribute 'isocode')   Jean      22/05/2015   09:00:00                                          fiddle with get_auth()                     `#97 <Detail>`__
 #109 (Cannot delete foo)                             Jean      22/05/2015   09:00:00                             0:10         jitsi meeting claire and paul              `#109 <Detail>`__
 #111 (Irritating message when bar)                   Luc       22/05/2015   09:00:00                             0:10         commit and push                            `#111 <Detail>`__
 #9 (Foo never matches Bar)                           Luc       22/05/2015   09:00:00                                          empty recycle bin                          `#9 <Detail>`__
 #73 (Cannot delete foo)                              Mathieu   22/05/2015   09:00:00                             0:10         meeting with john                          `#73 <Detail>`__
 #85 (How can I see where bar?)                       Mathieu   22/05/2015   09:00:00                                          response to email                          `#85 <Detail>`__
 #97 ('NoneType' object has no attribute 'isocode')   Mathieu   22/05/2015   09:00:00                                          check for comments                         `#97 <Detail>`__
 #109 (Cannot delete foo)                             Mathieu   22/05/2015   09:00:00                             0:10         keep door open                             `#109 <Detail>`__
 #99 (No more foo when bar is gone)                   Luc       21/05/2015   12:58:00     21/05/2015   15:00:00   0:10         drive to brussels               1:52       `#99 <Detail>`__
 #61 ('NoneType' object has no attribute 'isocode')   Mathieu   21/05/2015   12:58:00     21/05/2015   13:10:00                catch the brown fox             0:12       `#61 <Detail>`__
 #49 (How can I see where bar?)                       Mathieu   21/05/2015   12:53:00     21/05/2015   12:58:00                brainstorming lou & paul        0:05       `#49 <Detail>`__
 #87 (Default account in invoices per partner)        Luc       21/05/2015   12:48:00     21/05/2015   12:58:00                keep door open                  0:10       `#87 <Detail>`__
 #85 (How can I see where bar?)                       Jean      21/05/2015   12:29:00     21/05/2015   13:06:00                peer review with mark           0:37       `#85 <Detail>`__
 #75 (Irritating message when bar)                    Luc       21/05/2015   11:18:00     21/05/2015   12:48:00                check for comments              1:30       `#75 <Detail>`__
 #73 (Cannot delete foo)                              Jean      21/05/2015   09:00:00     21/05/2015   12:29:00   0:10         empty recycle bin               3:19       `#73 <Detail>`__
 **Total (2384 rows)**                                                                                                                                         **7:45**
==================================================== ========= ============ ============ ============ ========== ============ =============================== ========== ===================

Some sessions are on private tickets:

>>> from django.db.models import Q
>>> rt.show(working.Sessions, column_names="ticket user duration", filter=Q(ticket__private=True))
... 
============================== ======== ==========
 Ticket                         Worker   Duration
------------------------------ -------- ----------
 #4 (⚒ Foo and bar don't baz)   Luc      2:18
 #12 (⚒ Foo cannot bar)         Luc      1:30
 **Total (2 rows)**                      **3:48**
============================== ======== ==========

Worked hours

Example of WorkedHours table

>>> # rnd = settings.SITE.kernel.text_renderer
>>> rt.login('jean').show(working.WorkedHours)
... 
============================= ======================================================================== =========== ======= ====== ===========
 Description                   Worked tickets                                                           Regular     Extra   Free   Total
----------------------------- ------------------------------------------------------------------------ ----------- ------- ------ -----------
 `Fri 22/05/2015 <Detail>`__   `#97 <Detail>`__, `#109 <Detail>`__                                      0:02                       0:02
 `Thu 21/05/2015 <Detail>`__   `#73 <Detail>`__, `#85 <Detail>`__                                       3:56                       3:56
 `Wed 20/05/2015 <Detail>`__   `#25 <Detail>`__, `#61 <Detail>`__, `#37 <Detail>`__, `#49 <Detail>`__   5:40                       5:40
 `Tue 19/05/2015 <Detail>`__   `#1 <Detail>`__, `#115 <Detail>`__, `#13 <Detail>`__                     4:00                       4:00
 `Mon 18/05/2015 <Detail>`__   `#91 <Detail>`__, `#103 <Detail>`__                                      3:51                       3:51
 `Sun 17/05/2015 <Detail>`__                                                                                                       0:00
 `Sat 16/05/2015 <Detail>`__                                                                                                       0:00
 **Total (7 rows)**                                                                                     **17:29**                  **17:29**
============================= ======================================================================== =========== ======= ====== ===========

In order to reproduce #523, let's ffind the users who worked on more than one site and then render this table to HTML.

>>> for u in users.User.objects.all():
...     qs = tickets.Site.objects.filter(tickets_by_site__sessions_by_ticket__user=u).distinct()
...     if qs.count() > 1:
...         print("{} {} {}".format(str(u.username), "worked on", [o for o in qs]))
jean worked on [Site #1 ('pypi'), Site #4 ('security')]
luc worked on [Site #2 ('docs'), Site #5 ('cust')]
mathieu worked on [Site #1 ('pypi'), Site #4 ('security')]
>>> url = "/api/working/WorkedHours?"
>>> #url += "_dc=1583295523012&cw=398&cw=398&cw=76&cw=76&cw=76&cw=76&cw=281&cw=76&ch=&ch=&ch=&ch=&ch=&ch=&ch=true&ch=true&ci=detail_link&ci=worked_tickets&ci=vc0&ci=vc1&ci=vc2&ci=vc3&ci=description&ci=day_number&name=0&pv=188&pv=&pv=&pv=&lv=1583294270.8095002&an=show_as_html&sr="
>>> url += "an=show_as_html"
>>> test_client.force_login(rt.login('jean').user)
>>> res = test_client.get(url, REMOTE_USER="jean")
>>> json.loads(res.content.decode()) == {'open_url': '/bs3/working/WorkedHours?limit=15', 'success': True}
True

The html version of this table table has only 5 rows (4 data rows and the total row) because valueless rows are not included by default:

>>> ar = rt.login('jean')
>>> u = ar.get_user()
>>> ar = working.WorkedHours.request(user=u)
>>> ar = ar.spawn(working.WorkedHours)
>>> lst = list(ar)
>>> len(lst)
7
>>> e = ar.table2xhtml()
>>> len(e.findall('./tbody/tr'))
8

Service reports

A service report is a document used in various discussions with a stakeholder. It reports about the working time invested during a given date range. This report can serve as a base for writing invoices.

It can be addressed to a recipient (a user) and in that case will consider only the tickets for which this user has specified interest.

Database model: ServiceReport.

A service report currently contains three tables:

  • a list of work sessions

  • a list of the tickets mentioned in the work sessions and their invested time

  • a list of sites mentioned in the work sessions and their invested time

Reporting type

The default ReportingTypes implementation offers three choices.

>>> rt.show(working.ReportingTypes)
======= ========= =========
 value   name      text
------- --------- ---------
 10      regular   Regular
 20      extra     Extra
 30      free      Free
======= ========= =========

The local site admin can adapt above list to the site's needs. He also defines a default reporting type:

>>> dd.plugins.working.default_reporting_type
<working.ReportingTypes.regular:10>

Database models

class lino_xl.lib.working.SessionType

Django model representing the type of a work session.

class lino_xl.lib.working.Session

Django model representing a work session.

start_date

The date when you started to work.

start_time

The time (in hh:mm) when you started working on this session.

This is your local time according to the time zone specified in your preferences.

end_date

Leave this field blank if it is the same date as start_date.

end_time

The time (in hh:mm) when the worker stopped to work.

An empty end_time means that the user is still busy with that session, the session is not yet closed.

end_session() sets this to the current time.

break_time

The time (in hh:mm) to remove from the duration resulting from the difference between start_time and end_time.

faculty

The faculty that has been used during this session. On a new session this defaults to the needed faculty currently specified on the ticket.

site_ref
duration

The duration of this session as a Duration.

Lino detects sub-sessions of this sessions and subtracts their durations from the duration of this session. (Details see lino_xl.lib.working.models.Session.compute_duration()).

end_session()

Tell Lino that you stop this session for now. This will simply set the end_time to the current time.

Implemented by EndThisSession.

Tables reference

class lino_xl.lib.working.Sessions
class lino_xl.lib.working.SessionsByTicket

The "Sessions" panel in the detail of a ticket.

slave_summary

This panel shows:

class lino_xl.lib.working.MySessions

Shows all my sessions.

Use the filter button button to filter them. You can export them to Excel.

class lino_xl.lib.working.MySessionsByDate

Shows my sessions of a given day.

Use this to manually edit your work sessions.

class lino_xl.lib.working.SessionsByReport
class lino_xl.lib.working.TicketsReport
class lino_xl.lib.working.SitesByReport

The list of tickets mentioned in a service report.

class lino_xl.lib.working.WorkersByReport

Actions reference

class lino_xl.lib.working.StartTicketSession

The action behind Workable.start_session.

class lino_xl.lib.working.EndSession

Common base for EndThisSession and EndTicketSession.

class lino_xl.lib.working.EndTicketSession

The action behind Workable.end_session.

class lino_xl.lib.working.EndThisSession

The action behind Session.end_session.

The Workable model mixin

class lino_xl.lib.working.Workable

Base class for things that workers can work on.

The model specified in ticket_model must be a subclass of this. For example, in Lino Noi tickets are workable.

is_workable_for()

Return True if the given user can start a work session on this object.

on_worked()

This is automatically called when a work session has been created or modified.

start_session()

Start a work session on this ticket.

See StartTicketSession.

end_session()

Tell Lino that you stop working on this ticket for now. This will simply set the Session.end_time to the current time.

Implemented by EndTicketSession.

Actions reference

class lino_xl.lib.working.ShowMySessionsByDay

Shows your work sessions per day.

class lino_xl.lib.working.TicketHasSessions

Select only tickets for which there has been at least one session during the given period.

This is added as item to lino_xl.lib.tickets.TicketEvents.

class lino_xl.lib.working.ProjectHasSessions

Select only projects for which there has been at least one session during the given period.

This is added as item to lino_xl.lib.tickets.ProjectEvents.

class lino_xl.lib.working.Worker

A user who is candidate for working on a ticket.

Summaries

class lino_xl.lib.working.SiteSummary

An automatically generated record with yearly summary data about a site.

class lino_xl.lib.working.SummariesBySite

Shows the summary records for a given site.

class lino_xl.lib.working.UserSummary

An automatically generated record with monthly summary data about a user.

class lino_xl.lib.working.SummariesByUser

Shows the summary records for a given user.

Examples

>>> rt.show(working.SiteSummaries, exclude=dict(regular_hours=""))
... 
==== ====== ======= ========== ================ ================== ========= ======= ========
 ID   Year   Month   Project    Active tickets   Inactive tickets   Regular   Extra   Free
---- ------ ------- ---------- ---------------- ------------------ --------- ------- --------
 2    2014           pypi       0                0                  824:14
 3    2015           pypi       0                0                  434:30            696:31
 5    2014           docs       0                0                  427:30
 6    2015           docs       0                0                  232:39
 11   2014           security   0                0                  790:53
 12   2015           security   0                0                  447:26            -1:00
 14   2014           cust       0                0                  379:12
 15   2015           cust       0                0                  209:16
==== ====== ======= ========== ================ ================== ========= ======= ========
>>> rt.show(working.SiteSummaries)
... 
==== ====== ======= ========== ================ ================== ========= ======= ========
 ID   Year   Month   Project    Active tickets   Inactive tickets   Regular   Extra   Free
---- ------ ------- ---------- ---------------- ------------------ --------- ------- --------
 1    2013           pypi       0                0
 2    2014           pypi       0                0                  824:14
 3    2015           pypi       0                0                  434:30            696:31
 4    2013           docs       0                0
 5    2014           docs       0                0                  427:30
 6    2015           docs       0                0                  232:39
 7    2013           bugs       0                0
 8    2014           bugs       0                0
 9    2015           bugs       0                0
 10   2013           security   0                0
 11   2014           security   0                0                  790:53
 12   2015           security   0                0                  447:26            -1:00
 13   2013           cust       0                0
 14   2014           cust       0                0                  379:12
 15   2015           cust       0                0                  209:16
 16   2013           admin      0                0
 17   2014           admin      0                0
 18   2015           admin      0                0
==== ====== ======= ========== ================ ================== ========= ======= ========
>>> rt.show(working.UserSummaries, exclude=dict(regular_hours=""))
... 
===== ====== ======= ========= ========= ======= =======
 ID    Year   Month   User      Regular   Extra   Free
----- ------ ------- --------- --------- ------- -------
 16    2014   4       Jean      44:42
 17    2014   5       Jean      95:06
 18    2014   6       Jean      92:55
 19    2014   7       Jean      99:02
 20    2014   8       Jean      92:55
 21    2014   9       Jean      95:02
 22    2014   10      Jean      100:51
 23    2014   11      Jean      87:15
 24    2014   12      Jean      100:46
 25    2015   1       Jean      95:02
 26    2015   2       Jean      88:45             -1:00
 27    2015   3       Jean      96:55
 28    2015   4       Jean      95:02
 29    2015   5       Jean      65:57
 52    2014   4       Luc       44:34
 53    2014   5       Luc       95:02
 54    2014   6       Luc       91:15
 55    2014   7       Luc       100:42
 56    2014   8       Luc       91:15
 57    2014   9       Luc       96:51
 58    2014   10      Luc       100:46
 59    2014   11      Luc       87:15
 60    2014   12      Luc       99:02
 61    2015   1       Luc       96:51
 62    2015   2       Luc       87:15
 63    2015   3       Luc       95:06
 64    2015   4       Luc       96:51
 65    2015   5       Luc       65:52
 124   2014   4       Mathieu   42:45
 125   2014   5       Mathieu   96:51
 126   2014   6       Mathieu   91:06
 127   2014   7       Mathieu   100:51
 128   2014   8       Mathieu   91:06
 129   2014   9       Mathieu   96:55
 130   2014   10      Mathieu   99:02
 131   2014   11      Mathieu   87:15
 132   2014   12      Mathieu   100:42
 133   2015   1       Mathieu   96:55
 134   2015   2       Mathieu   87:15
 135   2015   3       Mathieu   95:02
 136   2015   4       Mathieu   96:55
 137   2015   5       Mathieu   64:08
===== ====== ======= ========= ========= ======= =======

Some sites (projects) have more than 999:59 hours per year of work, which is indicated by a -1:00. In this case the site operator can decide to increase the summaries.duration_max_length.

>>> dd.plugins.summaries.duration_max_length
6
>>> rt.login('jean').show(working.AllSummaries)
... 
==== ====== ======= ========== ================ ================== ========= ======= ========
 ID   Year   Month   Project    Active tickets   Inactive tickets   Regular   Extra   Free
---- ------ ------- ---------- ---------------- ------------------ --------- ------- --------
 1    2013           pypi       0                0
 2    2014           pypi       0                0                  824:14
 3    2015           pypi       0                0                  434:30            696:31
 4    2013           docs       0                0
 5    2014           docs       0                0                  427:30
 6    2015           docs       0                0                  232:39
 7    2013           bugs       0                0
 8    2014           bugs       0                0
 9    2015           bugs       0                0
 10   2013           security   0                0
 11   2014           security   0                0                  790:53
 12   2015           security   0                0                  447:26            -1:00
 13   2013           cust       0                0
 14   2014           cust       0                0                  379:12
 15   2015           cust       0                0                  209:16
 16   2013           admin      0                0
 17   2014           admin      0                0
 18   2015           admin      0                0
==== ====== ======= ========== ================ ================== ========= ======= ========

Sub-sessions

To find the primary key of a session with sub-sessions, there is a print statement in book/lino_book/projects/noi1e/settings/fixtures/demo.py. So if the following snippet fails, activate that print statement and use one of them.

>>> obj = working.Session.objects.get(pk=2373)
>>> print(obj.break_time)
None
>>> st, et = obj.get_datetime('start'), obj.get_datetime('end')
>>> print("from {} to {}".format(st, et))
from 2015-02-13 09:00:00+00:00 to 2015-02-21 17:00:00+00:00

The session starts on 2015-02-13 at 09:00 and ends on 2015-02-21 at 17:00, so it lasts exactly 8 days and 8 hours, in other words 200 hours or 12000 minutes.

>>> delta = et - st
>>> print(delta)
8 days, 8:00:00
>>> 8*24+8
200
>>> print(delta.total_seconds()/60)
12000.0

But the computed_duration is less than 200 hours:

>>> obj.computed_duration
Duration('172:12')

This is because there are sub-sessions. The get_sub_sessions() method iterates over them :

>>> from lino.utils.quantities import Duration
>>> for s in obj.get_sub_sessions():
...    print("- {} {} ({})".format(s.computed_duration, s.break_time, s))
... 
- 2:08 0:10 (13.02.2015 09:00)
- 1:30 None (13.02.2015 11:18)
- 0:10 None (13.02.2015 12:48)
- 1:52 0:10 (13.02.2015 12:58)
- 0:45 None (14.02.2015 09:30)
- 3:19 0:10 (16.02.2015 09:00)
- 0:37 None (16.02.2015 12:29)
- 1:02 None (17.02.2015 09:00)
- 2:49 0:10 (17.02.2015 10:02)
- 3:43 0:10 (18.02.2015 09:00)
- 0:05 None (18.02.2015 12:53)
- 0:12 None (18.02.2015 12:58)
- 2:08 0:10 (19.02.2015 09:00)
- 0:45 None (19.02.2015 09:30)
- 1:30 None (19.02.2015 11:18)
- 0:10 None (19.02.2015 12:48)
- 1:52 0:10 (19.02.2015 12:58)
- 3:19 0:10 (20.02.2015 09:00)
- 0:37 None (20.02.2015 12:29)

Here is the sum of the durations of these subsessions:

>>> sum([s.computed_duration for s in obj.get_sub_sessions()])
Duration('28:33')

TODO: the sum of these two numbers should be 200, not 200:45:

>>> Duration('28:33') + obj.computed_duration
Duration('200:45')

Don't read me

>>> working.WorkedHours
lino_xl.lib.working.ui.WorkedHours
>>> print(working.WorkedHours.column_names)
detail_link worked_tickets  vc0:5 vc1:5 vc2:5 vc3:5 *
>>> working.WorkedHours.get_data_elem('detail_link')
lino.core.actors.Actor.detail_link

Testing for equality of quantities

Remind the pitfall described in A possible pitfall. Here is a list of sessions where the computed_duration field is not exaclty the same as the return value of compute_duration(). They look the same when you print them, but actually they differ.

>>> [obj.pk for obj in working.Session.objects.all()
...     if obj.computed_duration != obj.compute_duration()]
... 
[5, 6, 16, 17, ... 2382, 2383, 2384]

We verify this with the first one.

>>> obj = working.Session.objects.get(pk=5)
>>> obj.computed_duration
Duration('1:52')
>>> obj.compute_duration()
Duration('1:52')

But:

>>> obj.computed_duration == obj.compute_duration()
False

That's why the SessionChecker must call str() in order to get the expected result:

>>> str(obj.computed_duration) == str(obj.compute_duration())
True