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

watch – Watching database changes

The lino_book.projects.watch demo project illustrates some local customizations.

This tutorial explains how to use the lino.modlib.changes plugin for logging changes to individual rows of database tables and implementing a kind of audit trail.

This tutorial is a tested document and uses the lino_book.projects.watch sample application:

>>> from lino import startup
>>> startup('lino_book.projects.watch.settings')

To enable database change watching, you add lino.modlib.changes to your get_installed_plugins and then register “change watchers” for every type of change you want to watch.

The example in this tutorial uses the lino_xl.lib.contacts module. It also adds a model Entry as an example of a watched slave model.

The “master” of a change watcher is the object to which every change should be attributed. In this example the master is Partner: every tracked change to Entry, Partner or Company will be assigned to their Partner record.

In the settings.py file we define our own subclass of Site, with a setup_actions method to call watch_changes.

from lino.projects.std.settings import *


class Site(Site):

    is_demo_site = True
    demo_fixtures = "std demo demo2"
    languages = 'en'
    # default_user = "robin"
    user_types_module = 'lino_xl.lib.xl.user_types'
    # default_ui = "lino_react.react"

    def get_installed_plugins(self):

        yield super().get_installed_plugins()
        yield 'lino_xl.lib.contacts'
        yield 'lino.modlib.changes'
        yield 'lino.modlib.users'
        yield 'lino_book.projects.watch.entries'
        # yield 'lino_xl.lib.notes'

    def setup_actions(self):
        # watch changes to Partner, Company and Entry
        # objects, grouped to their respective Partner.

        super().setup_actions()

        from lino.modlib.changes.utils import watch_changes as wc
        wc(self.models.contacts.Partner)
        wc(self.models.contacts.Company, master_key='partner_ptr')
        wc(self.models.entries.Entry, master_key='company__partner_ptr')
        # wc(self.models.notes.Note, master_key='company__partner_ptr')


SITE = Site(globals())

DEBUG = True

The entries/models.py file defines the Entry model and its data tables:

from django.db import models
from lino.api import dd, _

from lino.modlib.users.mixins import My, UserAuthored


class Entry(UserAuthored):

    class Meta:
        verbose_name = _("Entry")
        verbose_name_plural = _("Entries")

    subject = models.CharField(_("Subject"), blank=True, max_length=200)
    body = dd.RichTextField(_("Body"), blank=True)
    company = dd.ForeignKey('contacts.Company')


class Entries(dd.Table):
    model = Entry

    detail_layout = """
    id user
    company
    subject
    body
    """

    insert_layout = """
    company
    subject
    """


class EntriesByCompany(Entries):
    master_key = 'company'


class MyEntries(My, Entries):
    pass


# @dd.receiver(dd.post_startup)
# def my_change_watchers(sender, **kw):

The tests/test_basics.py file contains a unit test:

# -*- coding: utf-8 -*-
# Copyright 2017-2025 Rumma & Ko Ltd
# License: GNU Affero General Public License v3 (see file COPYING for details)
#  $ pm test tests.test_basics
"""Basic tests.

"""

from django.conf import settings

from lino.utils.djangotest import RemoteAuthTestCase
from lino.core.callbacks import popCallBack, applyCallbackChoice


class QuickTest(RemoteAuthTestCase):

    # fixtures = ['demo', 'demo2']
    maxDiff = None

    def test_this(self):

        from lino.api import rt
        from lino.core.renderer import TestRenderer

        UserTypes = rt.models.users.UserTypes
        rt.models.users.User(username="robin",
                             user_type=UserTypes.admin).save()

        ses = rt.login('robin',
                       renderer=TestRenderer(
                           settings.SITE.kernel.web_front_ends[0]))

        s = ses.show('changes.Changes')
        self.assertEqual(s, "No data to display")

        rr = rt.models.contacts.Companies.required_roles
        self.assertTrue(ses.user.user_type.role.satisfies_requirement(rr))

        # We create a new organization:

        url = '/api/contacts/Companies'
        data = dict(an='submit_insert', name='My pub')
        res = self.post_json_dict('robin', url, data)
        self.assertEqual(res.message,
                         'Organization "My pub" has been created.')

        s = ses.show('changes.Changes',
                     column_names="id type master object diff")
        # print(s)
        expected = """\
==== ============= ================ ================ ===========================================
 ID   Change Type   Master           Object           Changes
---- ------------- ---------------- ---------------- -------------------------------------------
 1    Create        `My pub <…>`__   `My pub <…>`__   Company(id=1,name='My pub',partner_ptr=1)
==== ============= ================ ================ ===========================================
"""

        self.assertEqual(s, expected)

        url = '/api/contacts/Companies/1'
        data = "an=submit_detail&name=Our%20pub"
        res = self.put_json_dict('robin', url, data)
        self.assertEqual(res.message,
                         'Organization "Our pub" has been updated.')

        output = ses.show('changes.Changes',
                          column_names="id type master object diff")
        # print(output)
        expected = """\
==== ============= ================= ================= ===========================================
 ID   Change Type   Master            Object            Changes
---- ------------- ----------------- ----------------- -------------------------------------------
 2    Update        `Our pub <…>`__   `Our pub <…>`__   name : 'My pub' --> 'Our pub'
 1    Create        `Our pub <…>`__   `Our pub <…>`__   Company(id=1,name='My pub',partner_ptr=1)
==== ============= ================= ================= ===========================================
"""
        self.assertEqual(output, expected)

        # We add an entry:

        url = '/api/entries/Entries'
        data = dict(an='submit_insert', subject='test', companyHidden=1)
        res = self.post_json_dict('robin', url, data)
        self.assertEqual(res.message,
                         'Entry "Entry object (1)" has been created.')

        output = ses.show('changes.Changes',
                          column_names="id type master object diff")
        # print(output)
        expected = """\
==== ============= ================= ========================== =============================================
 ID   Change Type   Master            Object                     Changes
---- ------------- ----------------- -------------------------- ---------------------------------------------
 3    Create        `Our pub <…>`__   `Entry object (1) <…>`__   Entry(id=1,user=1,subject='test',company=1)
 2    Update        `Our pub <…>`__   `Our pub <…>`__            name : 'My pub' --> 'Our pub'
 1    Create        `Our pub <…>`__   `Our pub <…>`__            Company(id=1,name='My pub',partner_ptr=1)
==== ============= ================= ========================== =============================================
"""

        self.assertEqual(output, expected)

        # Now we delete the entry:

        url = '/api/entries/Entries/1'
        data = dict(an='delete_selected', sr=1)
        res = self.get_json_dict('robin', url, data)
        self.assertEqual(
            res.message, """\
You are about to delete 1 Entry
(Entry object (1)). Are you sure?""")

        # We answer "yes":
        applyCallbackChoice(res, data, "yes")
        # url = "/callbacks/{0}/yes".format(res['xcallback']['id'])
        # print(data)
        res = self.get_json_dict('robin', url, data)
        # print(res)
        # r = test_client.get(url)
        self.assertEqual(res.success, True)
        self.assertEqual(res.record_deleted, True)

        output = ses.show('changes.Changes',
                          column_names="id type master object diff")
        # print(output)
        expected = """\
==== ============= ================= ================= =============================================
 ID   Change Type   Master            Object            Changes
---- ------------- ----------------- ----------------- ---------------------------------------------
 4    Delete        `Our pub <…>`__                     Entry(id=1,user=1,subject='test',company=1)
 3    Create        `Our pub <…>`__                     Entry(id=1,user=1,subject='test',company=1)
 2    Update        `Our pub <…>`__   `Our pub <…>`__   name : 'My pub' --> 'Our pub'
 1    Create        `Our pub <…>`__   `Our pub <…>`__   Company(id=1,name='My pub',partner_ptr=1)
==== ============= ================= ================= =============================================
"""
        self.assertEqual(output, expected)

        # Note how the `object` column of the first two rows in above
        # table is empty. That's because the entry object has been
        # deleted, so it does no longer exist in the database and Lino
        # cannot point to it. But note also that `object` is a
        # "nullable Generic ForeignKey", the underlying fields
        # `object_id` and `object_type` still contain their
        # values. Here is the same table with "Object" split into its
        # components:

        output = ses.show(
            'changes.Changes',
            column_names="id type master object_type object_id diff")
        # print(output)
        expected = """\
==== ============= ================= ============== =========== =============================================
 ID   Change Type   Master            Object type    object id   Changes
---- ------------- ----------------- -------------- ----------- ---------------------------------------------
 4    Delete        `Our pub <…>`__   Entry          1           Entry(id=1,user=1,subject='test',company=1)
 3    Create        `Our pub <…>`__   Entry          1           Entry(id=1,user=1,subject='test',company=1)
 2    Update        `Our pub <…>`__   Organization   1           name : 'My pub' --> 'Our pub'
 1    Create        `Our pub <…>`__   Organization   1           Company(id=1,name='My pub',partner_ptr=1)
==== ============= ================= ============== =========== =============================================
"""
        self.assertEqual(output, expected)

        # Until 20150626 only the
        # :attr:`object<lino.modlib.changes.models.Change.object>` was nullable,
        # not the :attr:`master<lino.modlib.changes.models.Change.master>`.  But
        # now you can also delete the master, and all change records will still
        # remain:

        url = '/api/contacts/Companies/1'
        data = dict(an='delete_selected', sr=1)
        res = self.get_json_dict('robin', url, data)
        applyCallbackChoice(res, data, "yes")
        # url = "/callbacks/{0}/yes".format(res.xcallback['id'])
        self.get_json_dict('robin', url, data)

        output = ses.show('changes.Changes',
                          column_names="id type master object diff")
        # print(output)
        expected = """\
==== ============= ======== ======== =============================================
 ID   Change Type   Master   Object   Changes
---- ------------- -------- -------- ---------------------------------------------
 5    Delete                          Company(id=1,name='Our pub',partner_ptr=1)
 4    Delete                          Entry(id=1,user=1,subject='test',company=1)
 3    Create                          Entry(id=1,user=1,subject='test',company=1)
 2    Update                          name : 'My pub' --> 'Our pub'
 1    Create                          Company(id=1,name='My pub',partner_ptr=1)
==== ============= ======== ======== =============================================
"""
        self.assertEqual(output, expected)

        # Of course these change records are now considered broken GFKs:

        output = ses.show('gfks.BrokenGFKs')
        # print(output)
        expected = """\
================ ================= =========================================================== ========
 Database model   Database object   Message                                                     Action
---------------- ----------------- ----------------------------------------------------------- --------
 `Change <…>`__   `#1 <…>`__        Invalid primary key 1 for contacts.Partner in `master_id`   clear
 `Change <…>`__   `#2 <…>`__        Invalid primary key 1 for contacts.Partner in `master_id`   clear
 `Change <…>`__   `#3 <…>`__        Invalid primary key 1 for contacts.Partner in `master_id`   clear
 `Change <…>`__   `#4 <…>`__        Invalid primary key 1 for contacts.Partner in `master_id`   clear
 `Change <…>`__   `#5 <…>`__        Invalid primary key 1 for contacts.Partner in `master_id`   clear
 `Change <…>`__   `#1 <…>`__        Invalid primary key 1 for contacts.Company in `object_id`   clear
 `Change <…>`__   `#2 <…>`__        Invalid primary key 1 for contacts.Company in `object_id`   clear
 `Change <…>`__   `#3 <…>`__        Invalid primary key 1 for entries.Entry in `object_id`      clear
 `Change <…>`__   `#4 <…>`__        Invalid primary key 1 for entries.Entry in `object_id`      clear
 `Change <…>`__   `#5 <…>`__        Invalid primary key 1 for contacts.Company in `object_id`   clear
================ ================= =========================================================== ========
"""
        self.assertEqual(output, expected)

        # There open questions regarding these change records:

        # - Do we really never want to remove them? Do we really want a nullable
        #   master field? Should this option be configurable?
        # - How to tell :class:`lino.modlib.gfks.models.BrokenGFKs` to
        #   differentiate them from ?
        # - Should :meth:`get_broken_generic_related
        #   <lino.core.kernel.Kernel.get_broken_generic_related>` suggest to
        #   "clear" nullable GFK fields?

Here is our demo fixture, which partly reproduces what we are doing in the temporary database during djangotests:

from lino.api import rt
from lino.core.diff import ChangeWatcher
from lino.core.utils import PseudoRequest


def objects():

    Company = rt.models.contacts.Company

    ar = rt.login(request=PseudoRequest("robin"))

    obj = Company(name="My pub")
    obj.full_clean()
    obj.save_new_instance(ar)

    cw = ChangeWatcher(obj)
    obj.name = "Our pub"
    obj.save_watched_instance(ar, cw)

    # obj.delete_instance(ar)

    # this is a special fixture : it creates objects as a side effect
    # but does not yield them.
    return []
>>> from lino.api.doctest import *
>>> rt.show('changes.Changes')
...
==== ============ ============================ ============= ============== =========== ============= =========== ============================================= ======== ================= =================
 ID   Author       time                         Change Type   Object type    object id   Master type   master id   Changes                                       Fields   Object            Master
---- ------------ ---------------------------- ------------- -------------- ----------- ------------- ----------- --------------------------------------------- -------- ----------------- -----------------
 2    Robin Rood   ...                          Update        Organization   82          Partner       82          name : 'My pub' --> 'Our pub'                 name     `Our pub <…>`__   `Our pub <…>`__
 1    Robin Rood   ...                          Create        Organization   82          Partner       82          Company(id=82,name='My pub',partner_ptr=82)            `Our pub <…>`__   `Our pub <…>`__
==== ============ ============================ ============= ============== =========== ============= =========== ============================================= ======== ================= =================