A tested example of GFK fields

This tutorial project uses the lino_book.projects.gfktest demo application to illustrate some aspects of GenericForeignKey fields.

The models.py file defines four database models:

from django.db import models
from django.contrib.contenttypes.models import ContentType
from lino.api import dd
from lino.core.gfks import GenericForeignKey


@dd.python_2_unicode_compatible
class Member(dd.Model):
    name = models.CharField(max_length=200)
    email = models.EmailField(max_length=200, blank=True)

    def __str__(self):
        return self.name


@dd.python_2_unicode_compatible
class Comment(dd.Model):
    allow_cascaded_delete = ['owner']
    owner_type = dd.ForeignKey(ContentType)
    owner_id = models.PositiveIntegerField()
    owner = GenericForeignKey('owner_type', 'owner_id')
    text = models.CharField(max_length=200)

    def __str__(self):
        return '%s object' % (self.__class__.__name__)


@dd.python_2_unicode_compatible
class Note(dd.Model):
    owner_type = dd.ForeignKey(ContentType)
    owner_id = models.PositiveIntegerField()
    owner = GenericForeignKey('owner_type', 'owner_id')
    text = models.CharField(max_length=200)

    def __str__(self):
        return '%s object' % (self.__class__.__name__)


@dd.python_2_unicode_compatible
class Memo(dd.Model):
    owner_type = dd.ForeignKey(ContentType, blank=True, null=True)
    owner_id = models.PositiveIntegerField(blank=True, null=True)
    owner = GenericForeignKey('owner_type', 'owner_id')
    text = models.CharField(max_length=200)

    def __str__(self):
        return '%s object' % (self.__class__.__name__)

A Member is the potential owner of the other three things.

A Comment has allow_cascaded_delete and thus will be silently deleted if the owner gets deleted. A Note does not allow cascaded delete, and thus will cause a veto when we try to delete a member which is owner of some note. A Memo has a nullable owner field and thus will be cleared when we delete the owner.

This project also uses lino.modlib.contenttypes. We define this in our settings.py module:

from lino.projects.std.settings import *


class Site(Site):

    # demo_fixtures = ['demo']

    catch_layout_exceptions = False

    def get_installed_apps(self):
        yield super(Site, self).get_installed_apps()
        yield 'lino.modlib.gfks'
        yield 'lino_book.projects.gfktest.lib.gfktest'

A utility function:

>>> def status():
...     return [m.objects.all().count() for m in [Member, Comment, Note, Memo]]
...

We create a member and three GFK-related objects whose owner fields point to that member. And then we try to delete that member.

>>> mbr = Member(name="John")
>>> mbr.save()
>>> Comment(owner=mbr, text="Just a comment").save()
>>> Note(owner=mbr, text="John owes us 100€").save()
>>> Memo(owner=mbr, text="About John and his friends").save()
>>> print(status())
[1, 1, 1, 1]

The disable_delete method also sees these objects:

>>> print(mbr.disable_delete())
Cannot delete member John because 1 notes refer to it.

This means that Lino would prevent users from deleting this member through the web interface.

Lino also protects normal application code from deleting a member:

>>> mbr.delete()
Traceback (most recent call last):
  ...
Warning: Cannot delete member John because 1 notes refer to it.

All objects are still there:

>>> print(status())
[1, 1, 1, 1]

The above behaviour is thanks to a pre_delete_handler which Lino adds automatically.

We can disable this pre_delete_handler and use Django's raw delete method in order produce broken GFKs:

>>> from django.db.models.signals import pre_delete
>>> from lino.core.model import pre_delete_handler
>>> pre_delete.disconnect(pre_delete_handler) in (None, True)
True

(Above syntax is because Django 1.6 returns None while 1.7+ returns True)

Now deleting the member will not fail:

>>> from django.db import models
>>> models.Model.delete(mbr) in (None, (1, {u'gfktest.Member': 1}))
True

Note: With Django 1.8 , the method models.Model.delete() doesn't return anything, while since 1.8 it returns a dict describing the number of objects deleted.

And it will leave the GFK-related objects in the database.

>>> print(status())
[0, 1, 1, 1]

The users of a Lino application can see these broken GFKs by opening the BrokenGFKs table:

>>> rt.show(gfks.BrokenGFKs)
... 
====================== ================== ======================================================== ========
 Database model         Database object    Message                                                  Action
---------------------- ------------------ -------------------------------------------------------- --------
 `comment <Detail>`__   *Comment object*   Invalid primary key 1 for gfktest.Member in `owner_id`   delete
 `note <Detail>`__      *Note object*      Invalid primary key 1 for gfktest.Member in `owner_id`   manual
 `memo <Detail>`__      *Memo object*      Invalid primary key 1 for gfktest.Member in `owner_id`   clear
====================== ================== ======================================================== ========

TODO: a management command to cleanup broken GFK fields. This would execute the suggested actions (delete and clear) without any further user interaction. Attention:

Note that in plain Django you can achieve some of the above things by using GenericRelation fields. That is, if we define a GenericRelation from Member to every model which potentially points to it. In our case three GenericRelation objects.

A detailed comparison is yet to be written, but it seems that Django's approach is uncomplete compared to what Lino can do.

Tested twice

This tutorial project is tested twice. Most things which we tested in the present document are also being tested in a plain unittest module:

# -*- coding: UTF-8 -*-
# Copyright 2015-2018 Rumma & Ko Ltd
# License: BSD (see file COPYING for details)

# go gfktest
# python manage.py test

from __future__ import unicode_literals
from builtins import str
# from lino.utils.test import DocTest
from django.utils import six

from lino.utils.djangotest import WebIndexTestCase

from django.db import models
from django.conf import settings

from lino.api import rt
from lino.utils.djangotest import TestCase


class TestCase(TestCase):

    maxDiff = None

    def test01(self):
        """We create a member, and three GFK-related objects whose `owner`
        fields point to that member. And then we try to delete that
        member.

        """
        Member = rt.models.gfktest.Member
        Note = rt.models.gfktest.Note
        Memo = rt.models.gfktest.Memo
        Comment = rt.models.gfktest.Comment
        BrokenGFKs = rt.models.gfks.BrokenGFKs

        def check_status(*args):
            for i, m in enumerate((Member, Comment, Note, Memo)):
                n = m.objects.all().count()
                if n != args[i]:
                    msg = "Expected %d objects in %s but found %d"
                    msg %= (args[i], m.__name__, n)
                    self.fail(msg)
        
        gfklist = [
            (f.model, f.fk_field, f.ct_field)
            for f in settings.SITE.kernel.GFK_LIST]
        self.assertEqual(gfklist, [
            (Comment, 'owner_id', 'owner_type'),
            (Memo, 'owner_id', 'owner_type'),
            (Note, 'owner_id', 'owner_type')])

        def create_objects():
            mbr = Member(name="John",id=1)
            mbr.save()

            self.assertEqual(mbr.name, "John")
            Comment(owner=mbr, text="Just a comment...").save()
            Note(owner=mbr, text="John owes us 100€").save()
            Memo(owner=mbr, text="More about John and his friends").save()
            return mbr

        mbr = create_objects()
        check_status(1, 1, 1, 1)
        try:
            mbr.delete()
        except Warning as e:
            self.assertEqual(
                str(e), "Cannot delete member John because 1 notes refer to it.")
        else:
            self.fail("Expected an exception")

        # they are all still there:
        check_status(1, 1, 1, 1)
        
        # delete the note manually
        Note.objects.all().delete()
        check_status(1, 1, 0, 1)
        mbr.delete()
        # the memo remains:
        check_status(0, 0, 0, 1)
        Memo.objects.all().delete()

        # The above behaviour is thanks to a `pre_delete_handler`
        # which Lino adds automatically. Theoretically it is no longer
        # possible to produce broken GFKs.  But now we disable this
        # `pre_delete_handler` and use Django's raw `delete` method in
        # order to produce some broken GFKs:

        from django.db.models.signals import pre_delete
        from lino.core.model import pre_delete_handler
        pre_delete.disconnect(pre_delete_handler)

        check_status(0, 0, 0, 0)
        mbr = create_objects()
        check_status(1, 1, 1, 1)
        models.Model.delete(mbr)

        pre_delete.connect(pre_delete_handler)

        # The member has been deleted, but all generic related objects
        # are still there:
        check_status(0, 1, 1, 1)

        # That's what the BrokenGFKs table is supposed to show:
        # rst = BrokenGFKs.request().table2rst()
        rst = BrokenGFKs.request().to_rst()
        self.assertEqual(rst, """\
====================== ================== ======================================================== ========
 Database model         Database object    Message                                                  Action
---------------------- ------------------ -------------------------------------------------------- --------
 `comment <Detail>`__   *Comment object*   Invalid primary key 1 for gfktest.Member in `owner_id`   delete
 `note <Detail>`__      *Note object*      Invalid primary key 1 for gfktest.Member in `owner_id`   manual
 `memo <Detail>`__      *Memo object*      Invalid primary key 1 for gfktest.Member in `owner_id`   clear
====================== ================== ======================================================== ========

""")