Welcome | Get started | Dive into Lino | Contribute | Reference
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
------------------------------------- ------------------------------------------------------------------------ ----------- ------- ------ -----------
`Friday, 22 May 2015 <Detail>`__ `#97 <Detail>`__, `#109 <Detail>`__ 0:02 0:02
`Thursday, 21 May 2015 <Detail>`__ `#73 <Detail>`__, `#85 <Detail>`__ 3:56 3:56
`Wednesday, 20 May 2015 <Detail>`__ `#25 <Detail>`__, `#61 <Detail>`__, `#37 <Detail>`__, `#49 <Detail>`__ 5:40 5:40
`Tuesday, 19 May 2015 <Detail>`__ `#1 <Detail>`__, `#115 <Detail>`__, `#13 <Detail>`__ 4:00 4:00
`Monday, 18 May 2015 <Detail>`__ `#91 <Detail>`__, `#103 <Detail>`__ 3:51 3:51
`Sunday, 17 May 2015 <Detail>`__ 0:00
`Saturday, 16 May 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
andend_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
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
andEndTicketSession
.
- 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 1370:49
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 1370:49
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 2067:20
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 would
be indicated by a -1:00
if summaries.duration_max_length
was at
its default value of 6. But in lino_noi.lib.noi.settings
the default
value is modified to 10.
>>> dd.plugins.summaries.duration_max_length
10
>>> 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 1370:49
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