Welcome | Get started | Dive | Contribute | Topics | Reference | Changes | More

working : Work time tracking

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

Lines starting with >>> in this document are code snippets that get tested as part of our development workflow.

>>> 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   Duration (decimal)   Ticket #       Subscription
----------------------------------------------------- --------- ------------ ------------ ------------ ---------- ------------ ------------------------------- ---------- -------------------- -------------- ---------------------
 #97 ('NoneType' object has no attribute 'isocode')    Jean      22/05/2015   09:00:00                                          fiddle with get_auth()                                          `#97 <…>`__    SLA 5/2014 (dde)
 #109 ('NoneType' object has no attribute 'isocode')   Jean      22/05/2015   09:00:00                             0:10         jitsi meeting claire and paul                                   `#109 <…>`__   SLA 3/2014 (aab)
 #111 (Bars have no foo)                               Luc       22/05/2015   09:00:00                             0:10         commit and push                                                 `#111 <…>`__
 #9 (Foo never matches Bar)                            Luc       22/05/2015   09:00:00                                          empty recycle bin                                               `#9 <…>`__
 #73 ('NoneType' object has no attribute 'isocode')    Mathieu   22/05/2015   09:00:00                             0:10         meeting with john                                               `#73 <…>`__    SLA 4/2014 (bcc)
 #85 ('NoneType' object has no attribute 'isocode')    Mathieu   22/05/2015   09:00:00                                          response to email                                               `#85 <…>`__    SLA 2/2014 (welsch)
 #97 ('NoneType' object has no attribute 'isocode')    Mathieu   22/05/2015   09:00:00                                          check for comments                                              `#97 <…>`__    SLA 5/2014 (dde)
 #109 ('NoneType' object has no attribute 'isocode')   Mathieu   22/05/2015   09:00:00                             0:10         keep door open                                                  `#109 <…>`__   SLA 3/2014 (aab)
 #99 (Bars have no foo)                                Luc       21/05/2015   12:58:00     21/05/2015   15:00:00   0:10         drive to brussels               1:52       1,87                 `#99 <…>`__
 #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       0,20                 `#61 <…>`__    SLA 1/2014 (welket)
 #49 ('NoneType' object has no attribute 'isocode')    Mathieu   21/05/2015   12:53:00     21/05/2015   12:58:00                brainstorming lou & paul        0:05       0,08                 `#49 <…>`__    SLA 3/2014 (aab)
 #87 (Bars have no foo)                                Luc       21/05/2015   12:48:00     21/05/2015   12:58:00                keep door open                  0:10       0,17                 `#87 <…>`__
 #85 ('NoneType' object has no attribute 'isocode')    Jean      21/05/2015   12:29:00     21/05/2015   13:06:00                peer review with mark           0:37       0,62                 `#85 <…>`__    SLA 2/2014 (welsch)
 #75 (Bars have no foo)                                Luc       21/05/2015   11:18:00     21/05/2015   12:48:00                check for comments              1:30       1,50                 `#75 <…>`__
 #73 ('NoneType' object has no attribute 'isocode')    Jean      21/05/2015   09:00:00     21/05/2015   12:29:00   0:10         empty recycle bin               3:19       3,32                 `#73 <…>`__    SLA 4/2014 (bcc)
 **Total (2384 rows)**                                                                                                                                          **7:45**   **7,75**
===================================================== ========= ============ ============ ============ ========== ============ =============================== ========== ==================== ============== =====================

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 <…>`__      `#97 <…>`__, `#109 <…>`__                            0:02                       0:02
 `Thursday 21 May 2015 <…>`__    `#73 <…>`__, `#85 <…>`__                             3:56                       3:56
 `Wednesday 20 May 2015 <…>`__   `#25 <…>`__, `#61 <…>`__, `#37 <…>`__, `#49 <…>`__   5:40                       5:40
 `Tuesday 19 May 2015 <…>`__     `#1 <…>`__, `#115 <…>`__, `#13 <…>`__                4:00                       4:00
 `Monday 18 May 2015 <…>`__      `#91 <…>`__, `#103 <…>`__                            3:51                       3:51
 `Sunday 17 May 2015 <…>`__                                                                                      0:00
 `Saturday 16 May 2015 <…>`__                                                                                    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      Tariff
------- --------- --------- ---------
 10      regular   Regular   Regular
 20      extra     Extra     Extra
 30      free      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

Virtual field returning the computed_duration.

computed_duration

The duration of this session as a Duration.

This is the mathematical difference between start_time and end_time, minus the break_time and the durations of sub-sessions of this session. (Details see lino_xl.lib.working.models.Session.compute_duration()).

duration_decimal

The computed_duration expressed as a decimal number rather than a Duration. This format can be more convenient when processing the data in a spreadsheet.

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            681:06
 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            1372:42
 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            681:06
 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            1372:42
 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   Week   User      Regular   Extra   Free
----- ------ ------ --------- --------- ------- ---------
 53    2014   1      Jean      13:31
 68    2014   16     Jean      9:11
 69    2014   17     Jean      21:55
 70    2014   18     Jean      21:27
 71    2014   19     Jean      23:07
 72    2014   20     Jean      21:23
 73    2014   21     Jean      21:18
 74    2014   22     Jean      21:27
 75    2014   23     Jean      23:07
 76    2014   24     Jean      21:23
 77    2014   25     Jean      21:18
 78    2014   26     Jean      21:27
 79    2014   27     Jean      23:07
 80    2014   28     Jean      21:23
 81    2014   29     Jean      21:18
 82    2014   30     Jean      21:27
 83    2014   31     Jean      23:07
 84    2014   32     Jean      21:23
 85    2014   33     Jean      21:18
 86    2014   34     Jean      21:27
 87    2014   35     Jean      23:07
 88    2014   36     Jean      21:23
 89    2014   37     Jean      21:18
 90    2014   38     Jean      21:27
 91    2014   39     Jean      23:07
 92    2014   40     Jean      21:23
 93    2014   41     Jean      21:18
 94    2014   42     Jean      21:27
 95    2014   43     Jean      23:07
 96    2014   44     Jean      21:23
 97    2014   45     Jean      21:18
 98    2014   46     Jean      21:27
 99    2014   47     Jean      23:07
 100   2014   48     Jean      21:23
 101   2014   49     Jean      21:18
 102   2014   50     Jean      21:27
 103   2014   51     Jean      23:07
 104   2014   52     Jean      21:23
 105   2015   1      Jean      7:47
 106   2015   2      Jean      21:27
 107   2015   3      Jean      23:52             1202:41
 108   2015   4      Jean      22:08             851:07
 109   2015   5      Jean      21:18
 110   2015   6      Jean      21:27
 111   2015   7      Jean      23:07
 112   2015   8      Jean      21:23
 113   2015   9      Jean      21:18
 114   2015   10     Jean      21:27
 115   2015   11     Jean      23:07
 116   2015   12     Jean      21:23
 117   2015   13     Jean      21:18
 118   2015   14     Jean      21:27
 119   2015   15     Jean      23:07
 120   2015   16     Jean      21:23
 121   2015   17     Jean      21:18
 122   2015   18     Jean      21:27
 123   2015   19     Jean      23:07
 124   2015   20     Jean      21:23
 125   2015   21     Jean      17:27
 209   2014   1      Luc       11:47
 224   2014   16     Luc       9:40
 225   2014   17     Luc       21:23
 226   2014   18     Luc       21:18
 227   2014   19     Luc       21:27
 228   2014   20     Luc       23:07
 229   2014   21     Luc       21:23
 230   2014   22     Luc       21:18
 231   2014   23     Luc       21:27
 232   2014   24     Luc       23:07
 233   2014   25     Luc       21:23
 234   2014   26     Luc       21:18
 235   2014   27     Luc       21:27
 236   2014   28     Luc       23:07
 237   2014   29     Luc       21:23
 238   2014   30     Luc       21:18
 239   2014   31     Luc       21:27
 240   2014   32     Luc       23:07
 241   2014   33     Luc       21:23
 242   2014   34     Luc       21:18
 243   2014   35     Luc       21:27
 244   2014   36     Luc       23:07
 245   2014   37     Luc       21:23
 246   2014   38     Luc       21:18
 247   2014   39     Luc       21:27
 248   2014   40     Luc       23:07
 249   2014   41     Luc       21:23
 250   2014   42     Luc       21:18
 251   2014   43     Luc       21:27
 252   2014   44     Luc       23:07
 253   2014   45     Luc       21:23
 254   2014   46     Luc       21:18
 255   2014   47     Luc       21:27
 256   2014   48     Luc       23:07
 257   2014   49     Luc       21:23
 258   2014   50     Luc       21:18
 259   2014   51     Luc       21:27
 260   2014   52     Luc       23:07
 261   2015   1      Luc       9:36
 262   2015   2      Luc       21:18
 263   2015   3      Luc       21:27
 264   2015   4      Luc       23:07
 265   2015   5      Luc       21:23
 266   2015   6      Luc       21:18
 267   2015   7      Luc       21:27
 268   2015   8      Luc       23:07
 269   2015   9      Luc       21:23
 270   2015   10     Luc       21:18
 271   2015   11     Luc       21:27
 272   2015   12     Luc       23:07
 273   2015   13     Luc       21:23
 274   2015   14     Luc       21:18
 275   2015   15     Luc       21:27
 276   2015   16     Luc       23:07
 277   2015   17     Luc       21:23
 278   2015   18     Luc       21:18
 279   2015   19     Luc       21:27
 280   2015   20     Luc       23:07
 281   2015   21     Luc       17:27
 521   2014   1      Mathieu   13:27
 536   2014   16     Mathieu   7:51
 537   2014   17     Mathieu   23:07
 538   2014   18     Mathieu   21:23
 539   2014   19     Mathieu   21:18
 540   2014   20     Mathieu   21:27
 541   2014   21     Mathieu   23:07
 542   2014   22     Mathieu   21:23
 543   2014   23     Mathieu   21:18
 544   2014   24     Mathieu   21:27
 545   2014   25     Mathieu   23:07
 546   2014   26     Mathieu   21:23
 547   2014   27     Mathieu   21:18
 548   2014   28     Mathieu   21:27
 549   2014   29     Mathieu   23:07
 550   2014   30     Mathieu   21:23
 551   2014   31     Mathieu   21:18
 552   2014   32     Mathieu   21:27
 553   2014   33     Mathieu   23:07
 554   2014   34     Mathieu   21:23
 555   2014   35     Mathieu   21:18
 556   2014   36     Mathieu   21:27
 557   2014   37     Mathieu   23:07
 558   2014   38     Mathieu   21:23
 559   2014   39     Mathieu   21:18
 560   2014   40     Mathieu   21:27
 561   2014   41     Mathieu   23:07
 562   2014   42     Mathieu   21:23
 563   2014   43     Mathieu   21:18
 564   2014   44     Mathieu   21:27
 565   2014   45     Mathieu   23:07
 566   2014   46     Mathieu   21:23
 567   2014   47     Mathieu   21:18
 568   2014   48     Mathieu   21:27
 569   2014   49     Mathieu   23:07
 570   2014   50     Mathieu   21:23
 571   2014   51     Mathieu   21:18
 572   2014   52     Mathieu   21:27
 573   2015   1      Mathieu   9:40
 574   2015   2      Mathieu   21:23
 575   2015   3      Mathieu   21:18
 576   2015   4      Mathieu   21:27
 577   2015   5      Mathieu   23:07
 578   2015   6      Mathieu   21:23
 579   2015   7      Mathieu   21:18
 580   2015   8      Mathieu   21:27
 581   2015   9      Mathieu   23:07
 582   2015   10     Mathieu   21:23
 583   2015   11     Mathieu   21:18
 584   2015   12     Mathieu   21:27
 585   2015   13     Mathieu   23:07
 586   2015   14     Mathieu   21:23
 587   2015   15     Mathieu   21:18
 588   2015   16     Mathieu   21:27
 589   2015   17     Mathieu   23:07
 590   2015   18     Mathieu   21:23
 591   2015   19     Mathieu   21:18
 592   2015   20     Mathieu   21:27
 593   2015   21     Mathieu   17:27
===== ====== ====== ========= ========= ======= =========

Some 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            681:06
 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            1372:42
 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, run go noi1e followed by pm prep and watch the output for two lines of text similar to the following ones:

20230117 Session 2373 has a subsession (compare docs/specs/working.rst)
20230117 Session 2379 has a subsession (compare docs/specs/working.rst)

And then use one of these numbers as the pk in the following snippet.

>>> 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-01-14 09:00:00+00:00 to 2015-01-22 17:00:00+00:00

The session starts on 2015-02-14 at 09:00 and ends on 2015-01-22 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
>>> print(delta.total_seconds()/3600)
200.0
>>> 8*24+8
200

But the computed_duration is less than 200 hours:

>>> obj.computed_duration
Duration('168:17')

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))
... 
- 1:02 None (14.01.2015 09:00-10:02 Jean #67)
- 2:49 0:10 (14.01.2015 10:02-13:01 Jean #79)
- 2:58 0:10 (15.01.2015 09:00-12:53 Jean #91)
- 0:45 None (15.01.2015 09:30-10:15 Jean #43)
- 0:05 None (15.01.2015 12:53-12:58 Jean #103)
- 0:12 None (15.01.2015 12:58-13:10 Jean #115)
- 2:08 0:10 (16.01.2015 09:00-11:18 Jean #1)
- 1:30 None (16.01.2015 11:18-12:48 Jean #13)
- 0:10 None (16.01.2015 12:48-12:58 Jean #25)
- 1:52 0:10 (16.01.2015 12:58-15:00 Jean #37)
- 3:19 0:10 (19.01.2015 09:00-12:29 Jean #49)
- 0:37 None (19.01.2015 12:29-13:06 Jean #61)
- 1:02 None (20.01.2015 09:00-10:02 Jean #73)
- 0:45 None (20.01.2015 09:30-10:15 Jean #115)
- 2:49 0:10 (20.01.2015 10:02-13:01 Jean #85)
- 3:43 0:10 (21.01.2015 09:00-12:53 Jean #97)
- 0:05 None (21.01.2015 12:53-12:58 Jean #109)
- 0:12 None (21.01.2015 12:58-13:10 Jean #7)
- 2:08 0:10 (22.01.2015 09:00-11:18 Jean #19)
- 1:30 None (22.01.2015 11:18-12:48 Jean #31)
- 0:10 None (22.01.2015 12:48-12:58 Jean #43)
- 1:52 0:10 (22.01.2015 12:58-15:00 Jean #55)

Here is the sum of the durations of these sub-sessions:

>>> duration_of_sub_sessions = sum([s.computed_duration for s in obj.get_sub_sessions()])
>>> duration_of_sub_sessions
Duration('31:43')

And the sum of these two sums is indeed 200:00:

>>> duration_of_sub_sessions + obj.computed_duration
Duration('200:00')

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