.. doctest docs/specs/avanti/courses.rst
.. _avanti.specs.courses:

=========================
Activities in Lino Avanti
=========================

This document specifies how activities are being used in
:ref:`avanti`.

.. contents::
   :depth: 1
   :local:

.. include:: /../docs/shared/include/tested.rst

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

The methods for managing courses and enrolments in Lino Avanti is
implemented by the :mod:`lino_avanti.lib.courses` plugin which extends
:mod:`lino_xl.lib.courses`.

.. currentmodule:: lino_avanti.lib.courses


Enrolments
==========

.. class:: EnrolmentsByCourse

    Same as :class:`lino_xl.lib.courses.EnrolmentsByCourse` but adds
    the gender of the pupil (a remote field) and the enrolment
    options.

.. class:: Enrolment

    Inherits from :class:`lino_xl.lib.courses.Enrolment` but adds four
    specific "enrolment options":

    .. attribute:: needs_childcare

        Whether this pupil has small children to care about.

    .. attribute:: needs_bus

        Whether this pupil needs public transportation for moving.

    .. attribute:: needs_school

        Whether this pupil has school children to care about.

    .. attribute:: needs_evening

        Whether this pupil is available only for evening courses.

    .. attribute:: missing_rate

        How many times the pupil was missing when a lesson took
        place. In percent.


.. class:: PresencesByEnrolment

    Shows the presences of this pupil for this course.

Filling the guest list of a calendar entry
==========================================

>>> rt.show('cal.EntryStates')
======= ============ ============ ============= ============= ======== ============= =========
 value   name         text         Button text   Fill guests   Stable   Transparent   No auto
------- ------------ ------------ ------------- ------------- -------- ------------- ---------
 10      suggested    Suggested    ?             Yes           No       No            No
 20      draft        Draft        ☐             Yes           No       No            No
 50      took_place   Took place   ☑             No            Yes      No            No
 70      cancelled    Cancelled    ☒             No            Yes      Yes           Yes
======= ============ ============ ============= ============= ======== ============= =========
<BLANKLINE>


>>> cal.EntryStates.suggested.fill_guests
True

Topics
======

>>> rt.show('courses.Topics')
==== ================== ================== ==================
 ID   Designation        Designation (de)   Designation (fr)
---- ------------------ ------------------ ------------------
 1    Citizen course     Citizen course     Citizen course
 2    Language courses   Language courses   Language courses
==== ================== ================== ==================
<BLANKLINE>

>>> language_courses = courses.Topic.objects.get(pk=2)
>>> rt.show('courses.ActivitiesByTopic', language_courses)
================================ =========== ============= ================== =========== ============= =========== ========
 Activity                         When        Times         Available places   Confirmed   Free places   Requested   Trying
-------------------------------- ----------- ------------- ------------------ ----------- ------------- ----------- --------
 *Alphabetisation (16/01/2017)*   Every day   09:00-12:00   5                  3           0             3           2
 *Alphabetisation (16/01/2017)*   Every day   14:00-17:00   15                 2           0             4           13
 *Alphabetisation (16/01/2017)*   Every day   18:00-20:00   15                 12          0             11          3
 **Total (3 rows)**                                         **35**             **17**      **0**         **18**      **18**
================================ =========== ============= ================== =========== ============= =========== ========
<BLANKLINE>


API note: :class:`ActivitiesByTopic <lino_xl.lib.courses.ActivitiesByTopic>`
is a table with a remote master key:

>>> courses.ActivitiesByTopic.master
<class 'lino_xl.lib.courses.models.Topic'>
>>> print(courses.ActivitiesByTopic.master_key)
line__topic


Course lines
============


>>> rt.show('courses.LinesByTopic', language_courses)
==================== ====================== ====================== ====================== ================== ============ ===================== ===================== ============ ==============
 Reference            Designation            Designation (de)       Designation (fr)       Topic              Layout       Calendar entry type   Manage presences as   Recurrency   Repeat every
-------------------- ---------------------- ---------------------- ---------------------- ------------------ ------------ --------------------- --------------------- ------------ --------------
                      Alphabetisation        Alphabetisation        Alphabetisation        Language courses   Activities   Lesson                Pupil                 weekly       1
                      German A1+             German A1+             German A1+             Language courses   Activities   Lesson                Pupil                 weekly       1
                      German A2              German A2              German A2              Language courses   Activities   Lesson                Pupil                 weekly       1
                      German A2 (women)      German A2 (women)      German A2 (women)      Language courses   Activities   Lesson                Pupil                 weekly       1
                      German for beginners   German for beginners   German for beginners   Language courses   Activities   Lesson                Pupil                 weekly       1
 **Total (5 rows)**                                                                                                                                                                 **5**
==================== ====================== ====================== ====================== ================== ============ ===================== ===================== ============ ==============
<BLANKLINE>



Instructor versus author
========================

Note the difference between the *instructor* of a course and the *author*.

The author can *modify* dates and enrol new participants.  The teacher
can just enter presences and absences for existing participants in
existing events.

>>> rt.show('courses.AllActivities')
================= ============ =============== ========== =========== =============
 Activity line     Start date   Instructor      Author     When        Times
----------------- ------------ --------------- ---------- ----------- -------------
 Alphabetisation   16/01/2017   Laura Lieblig   nelly      Every day   18:00-20:00
 Alphabetisation   16/01/2017   Laura Lieblig   nathalie   Every day   14:00-17:00
 Alphabetisation   16/01/2017   Laura Lieblig   martina    Every day   09:00-12:00
================= ============ =============== ========== =========== =============
<BLANKLINE>



>>> rt.login('laura').show('courses.MyCoursesGiven')
... #doctest: +NORMALIZE_WHITESPACE -REPORT_UDIFF
============ =========================================== =========== ============= ====== =============
 Start date   Activity                                    When        Times         Room   Workflow
------------ ------------------------------------------- ----------- ------------- ------ -------------
 16/01/2017   `Alphabetisation (16/01/2017) <Detail>`__   Every day   09:00-12:00          **Started**
 16/01/2017   `Alphabetisation (16/01/2017) <Detail>`__   Every day   14:00-17:00          **Started**
 16/01/2017   `Alphabetisation (16/01/2017) <Detail>`__   Every day   18:00-20:00          **Started**
============ =========================================== =========== ============= ====== =============
<BLANKLINE>


.. _avanti.specs.get_request_detail_action:

Who can see the detail of an activity?
======================================

Here is why we introduced the :meth:`get_request_detail_action
<lino.core.actors.Actor.get_request_detail_action>`.

The :attr:`detail_link` in :class:`MyCourses` and :class:`MyCoursesGiven` didn't
work for :attr:`UserTypes.teacher` because the custom
:meth:`Course.get_detail_action` method, returned the :attr:`detail_action` of
:class:`CoursesByLayout`, for which a teacher doesn't have view permission.

The :meth:`Course.get_detail_action` method may indeed return different detail
actions based on the :term:`activity layout`.

But at least in :ref:`avanti`, the :class:`MyCoursesGiven` table (for which the
teacher does have permission) uses the same detail layout as
:class:`CoursesByLayout`. The custom :meth:`Course.get_detail_action` method had
to become more intelligent and loop over all tables that use the same detail
layout as :class:`CoursesByLayout`

>>> #ses = rt.login('laura', renderer=settings.SITE.kernel.default_ui.renderer)
>>> ses = rt.login('laura')
>>> ut = ses.get_user().user_type
>>> ut
<users.UserTypes.teacher:100>

>>> courses.Activities.required_roles
{<class 'lino_xl.lib.courses.roles.CoursesUser'>}
>>> courses.Activities.default_action.allow_view(ut)
False

>>> obj  = courses.Course.objects.filter(teacher=ses.get_user().partner).first()
>>> obj
Course #1 ('Alphabetisation (16/01/2017)')

For example, the :attr:`owner` field in :class:`lino_xl.lib.cal.MyEntries` now
uses :class:`MyActivities`, which happens to be the first activity table
having the same detail layout as :class:`Activities` and for which the user has
view permission:

>>> ar = cal.MyEntries.request(parent=ses)
>>> ba = obj.get_detail_action(ar)
>>> print(ba.actor)
courses.MyActivities

The detail_link in :class:`MyCoursesGiven` now remains on the same actor because
it has the same detail layout:

>>> ar = courses.MyCoursesGiven.request(parent=ses)
>>> ba = obj.get_detail_action(ar)
>>> print(ba.actor)
courses.MyCoursesGiven



Calendar entries generated by a course
======================================

Teachers can of course also see the list of calendar entries for a
course.

>>> obj = courses.Course.objects.get(pk=1)
>>> rt.login('laura').show('cal.EntriesByController', obj)
... #doctest: +NORMALIZE_WHITESPACE -REPORT_UDIFF
February 2017: `Fri 24. <Detail>`__? `Thu 23. <Detail>`__? `Tue 21. <Detail>`__? `Mon 20. <Detail>`__? `Fri 17. <Detail>`__? `Thu 16. <Detail>`__? `Tue 14. <Detail>`__? `Mon 13. <Detail>`__? `Fri 10. <Detail>`__? `Thu 09. <Detail>`__? `Tue 07. <Detail>`__☑ `Mon 06. <Detail>`__☑ `Fri 03. <Detail>`__☒ `Thu 02. <Detail>`__☑
January 2017: `Tue 31. <Detail>`__☑ `Mon 30. <Detail>`__☑ `Fri 27. <Detail>`__☑ `Thu 26. <Detail>`__☑ `Tue 24. <Detail>`__☑ `Mon 23. <Detail>`__☑ `Fri 20. <Detail>`__☑ `Thu 19. <Detail>`__☒ `Tue 17. <Detail>`__☑ `Mon 16. <Detail>`__☑
Suggested : 10 ,  Draft : 0 ,  Took place : 12 ,  Cancelled : 2 **New**



Note that even though Nathalie is author of the morning course, it is
Laura (the teacher) who is responsible for the individual events.

>>> rt.login('laura').show('cal.MyEntries')
... #doctest: +NORMALIZE_WHITESPACE -REPORT_UDIFF +ELLIPSIS
=========================================== ======== ===================================
 Calendar entry                              Client   Workflow
------------------------------------------- -------- -----------------------------------
 `Lesson 19 (16.02.2017 09:00) <Detail>`__            [▽] **? Suggested** → [☐] [☑] [☒]
 `Lesson 19 (16.02.2017 14:00) <Detail>`__            [▽] **? Suggested** → [☐] [☑] [☒]
 `Lesson 19 (16.02.2017 18:00) <Detail>`__            [▽] **? Suggested** → [☐] [☑] [☒]
 `Lesson 20 (17.02.2017 09:00) <Detail>`__            [▽] **? Suggested** → [☐] [☑] [☒]
 `Lesson 20 (17.02.2017 14:00) <Detail>`__            [▽] **? Suggested** → [☐] [☑] [☒]
 `Lesson 20 (17.02.2017 18:00) <Detail>`__            [▽] **? Suggested** → [☐] [☑] [☒]
 ...
 `Lesson 23 (23.02.2017 14:00) <Detail>`__            [▽] **? Suggested** → [☐] [☑] [☒]
 `Lesson 23 (23.02.2017 18:00) <Detail>`__            [▽] **? Suggested** → [☐] [☑] [☒]
 `Lesson 24 (24.02.2017 09:00) <Detail>`__            [▽] **? Suggested** → [☐] [☑] [☒]
 `Lesson 24 (24.02.2017 14:00) <Detail>`__            [▽] **? Suggested** → [☐] [☑] [☒]
 `Lesson 24 (24.02.2017 18:00) <Detail>`__            [▽] **? Suggested** → [☐] [☑] [☒]
=========================================== ======== ===================================
<BLANKLINE>




Reminders
=========

.. class:: Reminder

    A **reminder** is when a coaching worker sends a written letter to
    a client reminding him or her that they have a problme with their
    presences.

.. class:: Reminders

    The table of all reminders.

.. class:: RemindersByPupil

    Shows all reminders that have been issued for this pupil.

    This is an example of :ref:`remote_master`.


.. class:: ReminderStates

    The list of possible states of a reminder.

    >>> rt.show(courses.ReminderStates)
    ======= =========== =========== =============
     value   name        text        Button text
    ------- ----------- ----------- -------------
     10      draft       Draft
     20      sent        Sent
     30      ok          OK
     40      final       Final
     90      cancelled   Cancelled
    ======= =========== =========== =============
    <BLANKLINE>


.. class:: EnrolmentChecker

    Checks for the following data problems:

    - :message:`More than 2 times absent.`

    - :message:`Missed more than 10% of meetings.`


Templates
---------

.. xfile:: courses/Enrolment/Default.odt

   Prints an "Integration Course Agreement".

.. xfile:: courses/Reminder/Default.odt

   Prints a reminder to be sent to the client.


Help texts
==========

Test whether the help texts have been loaded and translated correctly:

>>> fld = courses.EnrolmentsByCourse.model._meta.get_field('needs_childcare')
>>> print(fld.help_text)
Whether this pupil has small children to care about.

Test whether translations of help texts are working correctly:

>>> from django.utils import translation
>>> with translation.override('de'):
...     print(fld.help_text)
Ob dieser Teilnehmer Kleinkinder zu betreuen hat.



Presence sheet
==============


>>> from unipath import Path
>>> url = '/api/courses/Activities/2?'
>>> url += 'fv=01.02.2017&fv=28.02.2017&fv=false&fv=true&'
>>> url += 'an=print_presence_sheet_html&sr=2'
>>> test_client.force_login(rt.login('robin').user)

>>> res = test_client.get(url)  #doctest: +ELLIPSIS
weasy2html render ['courses/Course/presence_sheet.weasy.html'] -> .../cache/weasy2html/courses.Course-2.html ('en', {})

>>> res.status_code
200
>>> rv = AttrDict(json.loads(res.content.decode()))
>>> url = rv.open_url
>>> print(url)
/media/cache/weasy2html/courses.Course-2.html
>>> url = url[1:]
>>> # print(url)
>>> fn = Path(settings.SITE.cache_dir, Path(url))
>>> html = open(fn).read()
>>> soup = BeautifulSoup(html, "lxml")
>>> links = soup.find_all('a')
>>> len(links)
0

Number of rows:

>>> len(soup.find_all('tr'))
29

Number of columns:

>>> len(soup.find('tr').find_all('td'))
17

Total number of cells is 13*17:

>>> cells = soup.find_all('td')
>>> len(cells)
493

>>> cells[0]
<td>No.</td>
>>> cells[1]
<td>Participant</td>
>>> print(cells[3].decode())  #doctest: +NORMALIZE_WHITESPACE -REPORT_UDIFF
<td>02.02.
<BLANKLINE>
<br/><font size="1">11 (☑)</font>
</td>

>>> cells[17]
<td>1</td>

>>> print(cells[18].decode())
<td><p>Mr Armán Berndt</p></td>

>>> print(cells[20].decode())  #doctest: +NORMALIZE_WHITESPACE -REPORT_UDIFF
<td align="center" valign="middle">⚕
  </td>



Course layouts
==============

The :class:`ActivityLayouts` choicelist in :ref:`avanti` defines only one
layout.

>>> rt.show(courses.ActivityLayouts)
======= ========= ============ ============================
 value   name      text         Table
------- --------- ------------ ----------------------------
 C       default   Activities   courses.ActivitiesByLayout
======= ========= ============ ============================
<BLANKLINE>


Missing rates
=============

.. class:: Course

     Adds an action to update the missing rates of all enrolments.
     Otherwise same as :class:`lino_xl.lib.courses.Course`.

     .. method:: update_missing_rates(self)

        Calculate the missing rates for all enrolments of this course.

        This action is automatically performed for all courses once
        per day in the evening.  Users can run it manually by clicking
        the ☉ button on a course.

        >>> print(courses.Course.update_missing_rates.button_text)
         ☉ 

        >>> print(courses.Course.update_missing_rates.label)
        Update missing rates

        >>> print(courses.Course.update_missing_rates.help_text)
        Calculate the missing rates for all enrolments of this course.



.. class:: DitchingEnrolments

     List of enrolments with high absence rate for review by their
     coach.

>>> with translation.override("de"):
... #doctest: +NORMALIZE_WHITESPACE -REPORT_UDIFF
...    print(str(courses.DitchingEnrolments.label))
...    print(str(courses.DitchingEnrolments.help_text))
Abwesenheitskontrolle
Liste der Einschreibungen mit hoher Abwesenheitsrate zwecks Kontrolle durch den Begleiter.

>>> rt.login("romain").show(courses.DitchingEnrolments)
============== ================================= ============================== =================
 Missing rate   Participant                       Activity                       Primary coach
-------------- --------------------------------- ------------------------------ -----------------
 29,17          ABID Abdul Báásid (162/romain)    Alphabetisation (16/01/2017)   Romain Raffault
 29,17          ARSHUN Aloyoshenká (135/romain)   Alphabetisation (16/01/2017)   Romain Raffault
 25,00          ABBASI Aáishá (118/romain)        Alphabetisation (16/01/2017)   Romain Raffault
 25,00          BEK-MURZIN Agápiiá (160/romain)   Alphabetisation (16/01/2017)   Romain Raffault
 25,00          CISSE Chátá (150/romain)          Alphabetisation (16/01/2017)   Romain Raffault
 25,00          FOFANA Denzel (147/romain)        Alphabetisation (16/01/2017)   Romain Raffault
============== ================================= ============================== =================
<BLANKLINE>



Clients with more than one enrolment
====================================

>>> from django.db.models import Count
>>> qs = rt.models.avanti.Client.objects.order_by('name').all()
>>> qs = qs.annotate(
...     ecount=Count('enrolments_by_pupil'))
>>> qs = qs.filter(ecount__gt=1)
>>> obj = qs[0]
>>> rt.show(courses.EnrolmentsByPupil, obj, header_level=4)
Enrolments in Activities of ABAD Aábdeen (114/nathalie) (Also Cancelled)
========================================================================
================= ============================== ======== ======== ===============
 Date of request   Activity                       Author   Remark   Workflow
----------------- ------------------------------ -------- -------- ---------------
 13/02/2017        Alphabetisation (16/01/2017)   nelly             **Requested**
 13/02/2017        Alphabetisation (16/01/2017)   sandra            **Trying**
================= ============================== ======== ======== ===============
<BLANKLINE>

Note that missing rates are also computed for non-confirmed
enrolments, and that there are even non-zero rates for such cases.