# -*- coding: UTF-8 -*-
# Copyright 2009-2022 Rumma & Ko Ltd
# License: GNU Affero General Public License v3 (see file COPYING for details)
"""Defines the :class:`Model` class.

See :ref:`dev.models`, :doc:`/dev/delete`, :doc:`/dev/disable`,
:doc:`/dev/hide`, :doc:`/dev/format`

"""

from lino import logger
import copy

from django.db import models
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.exceptions import FieldDoesNotExist
from django.utils.translation import gettext_lazy as _
from django.db.models.signals import pre_delete
from django.utils.text import format_lazy

from etgen.html import E, forcetext, tostring, join_elems

from lino.core import fields
from lino.core import signals
from lino.core import actions

from .fields import make_remote_field, RichTextField, displayfield
from .utils import error2str
from .utils import obj2str
from .diff import ChangeWatcher
from .utils import obj2unicode
from .utils import class_dict_items
from .signals import receiver, on_ui_created, pre_ui_delete, pre_ui_save
from .workflows import ChangeStateAction
from .tablerequest import TableRequest, sliced_data_iterator

try:
    import bleach
except ImportError:
    bleach = None


class Model(models.Model, fields.TableRow):

    class Meta(object):
        abstract = True

    allow_cascaded_copy = frozenset()
    allow_cascaded_delete = frozenset()

    grid_post = actions.CreateRow()
    submit_insert = actions.SubmitInsert()

    allow_merge_action = False

    master_data_fields = None

    show_in_site_search = True

    quick_search_fields = None

    quick_search_fields_digit = None

    active_fields = frozenset()
    """Deprecated. If specified, this is the default value for
    :attr:`active_fields <lino.core.tables.AbstractTable.active_fields>`
    of every `Table` on this model.

    """
    hidden_elements = frozenset()

    # simple_parameters = frozenset()
    # """If specified, this is the default value for :attr:`simple_parameters
    # <lino.core.tables.AbstractTable.simple_parameters>` of every `Table`
    # on this model.

    # """

    preferred_foreignkey_width = None

    workflow_state_field = None
    workflow_owner_field = None

    change_watcher_spec = None
    """
    Internally used by :meth:`watch_changes`
    """

    # disable_create_choice = False
    # """Whether to disable any automatic creation by learning choosers.
    # """

    _lino_tables = []
    _bleached_fields = []

    def get_master_data(self, ar, master_instance=None):
        if master_instance is None:
            master_instance = ar.master_instance
        if master_instance is not None and self.master_data_fields is not None:
            rv = {}
            for mdf in self.master_data_fields:
                rv[mdf] = getattr(master_instance, mdf)
            return rv

    @classmethod
    def collect_virtual_fields(model):
        """Declare every virtual field defined on this model to Django.

        We use Django's undocumented :meth:`add_field` method.

        Make a copy if the field is inherited, in order to avoid side effects
        like #2592.

        Raise an exception if the model defines both a database field and a
        virtual field of same name.

        """
        if model._meta.abstract:  # 20181023
            return
        # print("201906274 collect_virtual_fields a ", model, model._meta.fields)
        # fieldnames = {f.name for f in model._meta.get_fields()}
        # inject_field() can call this when Models aren't loaded yet.
        fieldnames = {
            f.name
            for f in model._meta.private_fields + model._meta.local_fields
        }
        # print("201906274 collect_virtual_fields b ", fieldnames)
        for m, k, v in class_dict_items(model):
            if isinstance(v, fields.VirtualField):
                # print("201906274 collect_virtual_fields c", m, k, v)
                if k in fieldnames:
                    # There are different possible reasons for this case.  E.g.
                    # a copy of virtual field in parent has already been
                    # attached.
                    continue
                    # f = model._meta.get_field(k)
                    # if f.__class__ is v.__class__:
                    #     # print("20190627 ignoring", m, k, v, f)
                    #     continue
                    # raise ChangedAPI(
                    #     "Virtual field {}.{} hides {} "
                    #     "of same name.".format(
                    #         full_model_name(model), k, f.__class__.__name__))
                # if k == "workflow_buttons":
                #     print("20210306 attach virtual field from {} to {}".format(m, model))
                # if m is not model or not issubclass(model, Model):
                #     # don't reuse a virtual field injected to a plain Django model
                if m is not model:
                    # if k == "municipality" and model.__name__ == "Client":
                    #     print("20200818", m, model)
                    # settings.SITE.VIRTUAL_FIELDS.pop(v)
                    v = copy.deepcopy(v)
                settings.SITE.register_virtual_field(v)
                v.attach_to_model(model, k)
                model._meta.add_field(v, private=True)
                fieldnames.add(k)

    @classmethod
    def get_param_elem(model, name):
        # This is called by :meth:`Chooser.get_data_elem` when
        # application code defines a chooser with an argument that
        # does not match any field. There is currently no usage
        # example for this on database models.
        return None

    @classmethod
    def get_data_elem(cls, name):

        if not name.startswith('__'):
            rf = make_remote_field(cls, name)
            if rf:
                return rf
        try:
            return cls._meta.get_field(name)
        except FieldDoesNotExist:
            pass

        for vf in cls._meta.private_fields:
            if vf.name == name:
                return vf
        return getattr(cls, name, None)
        # we cannot return super(Model, cls).get_data_elem(name) here because
        # get_data_elem is grafted also to pure Django models which don't
        # inherit from lino.core.Model

    def disable_delete(self, ar=None):
        # In case of MTI, every concrete model has its own ddh.
        # Deleting an Invoice will also delete the Voucher. Ask all
        # MTI parents (other than self) whether they have a veto .

        for b in self.__class__.__bases__:
            if issubclass(b, models.Model) \
               and b is not models.Model and not b._meta.abstract:
                msg = b._lino_ddh.disable_delete_on_object(
                    self, [self.__class__])
                if msg is not None:
                    return msg
        return self.__class__._lino_ddh.disable_delete_on_object(self)

    def disabled_fields(self, ar):
        return set()

    def delete(self, **kw):
        """
        Double-check to avoid "murder bug" (20150623).

        """
        msg = self.disable_delete(None)
        if msg is not None:
            raise Warning(msg)
        super(Model, self).delete(**kw)

    def delete_veto_message(self, m, n):
        msg = _(
            "Cannot delete %(model)s %(self)s "
            "because %(count)d %(refs)s refer to it.") % dict(
                self=self,
                count=n,
                model=self._meta.verbose_name,
                refs=m._meta.verbose_name_plural or m._meta.verbose_name + 's')
        #~ print msg
        return msg

    @classmethod
    def define_action(cls, **kw):
        """Adds one or several actions or other class attributes to this
        model.

        Attributes must be specified using keyword arguments, the
        specified keys must not yet exist on the model.

        Used e.g. in :mod:`lino_xl.lib.cal` to add the `UpdateReminders`
        action to :class: `lino.modlib.users.models.User`.

        Or in :mod:`lino_xl.lib.invoicing.models` for defining a
        custom chooser.

        """
        for k, v in list(kw.items()):
            if k in cls.__dict__:
                raise Exception("Tried to redefine %s.%s" % (cls, k))
            setattr(cls, k, v)

    @classmethod
    def add_active_field(cls, names):
        if isinstance(cls.active_fields, str):
            cls.active_fields = frozenset(
                fields.fields_list(cls, cls.active_fields))
        cls.active_fields = cls.active_fields | fields.fields_list(cls, names)

    @classmethod
    def hide_elements(self, *names):
        for name in names:
            if self.get_data_elem(name) is None:
                raise Exception("%s has no element '%s'" % (self, name))
        self.hidden_elements = self.hidden_elements | set(names)

    @classmethod
    def add_param_filter(cls, qs, lookup_prefix='', **kwargs):
        """Add filters to queryset using table parameter fields.

        This is called for every simple parameter.

        Usage examples: :class:`DeploymentsByTicket
        <lino_xl.lib.deploy.desktop.DeploymentsByTicket>`, :mod:`lino_book.projects.min3.lib.contacts`.

        """
        # print("20200425", kwargs)
        return qs.filter(**kwargs)
        # if len(kwargs):
        #     raise Exception(
        #         "{}.add_param_filter got unknown argument {}".format(
        #             str(cls.__name__), kwargs))
        # return qs

    @classmethod
    def on_analyze(cls, site):

        if isinstance(cls.workflow_owner_field, str):
            cls.workflow_owner_field = cls.get_data_elem(
                cls.workflow_owner_field)
        if isinstance(cls.workflow_state_field, str):
            cls.workflow_state_field = cls.get_data_elem(
                cls.workflow_state_field)
        # for vf in cls._meta.private_fields:
        #     if vf.name == 'detail_link':
        #         if vf.verbose_name is None:
        #
        #             # note that the verbose_name of a virtual field is a copy
        #             # of the verbose_name of its return_type (see
        #             # VirtualField.lino_resolve_type)
        #
        #             # vf.verbose_name = model._meta.verbose_name
        #             vf.return_type.verbose_name = cls._meta.verbose_name
        #             # if model.__name__ == "Course":
        #             #     print("20181212", model)
        #             break
        bleached_fields = []
        for f in cls._meta.get_fields():
            if isinstance(f, RichTextField):
                if f.editable and \
                    (f.bleached is True or
                        f.bleached is None and settings.SITE.textfield_bleached):
                    bleached_fields.append(f)
        cls._bleached_fields = tuple(bleached_fields)
        if hasattr(cls, 'bleached_fields'):
            raise ChangedAPI(
                "Replace bleached_fields by bleached=True on each field")

    @classmethod
    def create_from_choice(cls, text, ar=None, save=True):
        # Use save=False when calling this in a doctest to avoid modifying the
        # database, which might cause failures in other tests
        values = cls.choice_text_to_dict(text, ar)
        if values is None:
            raise ValidationError(
                _("Cannot create {obj} from '{text}'").format(
                    obj=cls._meta.verbose_name, text=text))
        obj = cls(**values)
        obj.full_clean()
        if save:
            obj.save()
        return obj

    @classmethod
    def choice_text_to_dict(cls, text, ar):
        return None

    @classmethod
    def lookup_or_create(model, lookup_field, value, **known_values):
        """
        Look up whether there is a model instance having
        `lookup_field` with value `value`
        (and optionally other `known_values` matching exactly).

        If it doesn't exist, create it and emit an
        :attr:`auto_create <lino.core.signals.auto_create>` signal.

        """

        from lino.utils.mldbc.fields import BabelCharField

        # ~ logger.info("2013011 lookup_or_create")
        fkw = dict()
        fkw.update(known_values)

        if isinstance(lookup_field, str):
            lookup_field = model._meta.get_field(lookup_field)
        if isinstance(lookup_field, BabelCharField):
            flt = settings.SITE.lookup_filter(lookup_field.name, value,
                                              **known_values)
        else:
            if isinstance(lookup_field, models.CharField):
                fkw[lookup_field.name + '__iexact'] = value
            else:
                fkw[lookup_field.name] = value
            flt = models.Q(**fkw)
            # ~ flt = models.Q(**{self.lookup_field.name: value})
        qs = model.objects.filter(flt)
        if qs.count() > 0:  # if there are multiple objects, return the first
            if qs.count() > 1:
                logger.warning(
                    "%d %s instances having %s=%r (I'll return the first).",
                    qs.count(), model.__name__, lookup_field.name, value)
            return qs[0]
        # ~ known_values[lookup_field.name] = value
        obj = model(**known_values)
        setattr(obj, lookup_field.name, value)
        try:
            obj.full_clean()
        except ValidationError as e:
            raise ValidationError("Failed to auto_create %s : %s" %
                                  (obj2str(obj), e))
        obj.save()
        signals.auto_create.send(obj, known_values=known_values)
        return obj

    @classmethod
    def quick_search_filter(model, search_text, prefix=''):
        """Return the filter expression to apply when a quick search text is
        specified.

        """
        # logger.info(
        #     "20160610 quick_search_filter(%s, %r, %r)",
        #     model, search_text, prefix)
        flt = models.Q()
        for w in search_text.split():
            q = models.Q()
            char_search = True
            if w.startswith("#") and w[1:].isdigit():
                w = w[1:]
                char_search = False
            if w.isdigit():
                for fn in model.quick_search_fields_digit:
                    kw = {prefix + fn.name: int(w)}
                    q = q | models.Q(**kw)
            if char_search:
                for fn in model.quick_search_fields:
                    kw = {prefix + fn.name + "__icontains": w}
                    q = q | models.Q(**kw)
            flt &= q
        return flt

    def on_create(self, ar):
        pass

    def before_ui_save(self, ar, cw):
        for f, old, new in self.fields_to_bleach():
            setattr(self, f.name, new)

    def fields_to_bleach(self):

        if bleach:
            for f in self._bleached_fields:
                old = getattr(self, f.name)
                if old is None:
                    continue
                # print("20190626", self, f, old)
                if not old.startswith("<"):
                    continue
                try:
                    new = bleach.clean(
                        old,
                        tags=settings.SITE.bleach_allowed_tags,
                        attributes=settings.SITE.bleach_allowed_attributes,
                        strip=True)
                except TypeError as e:
                    logger.warning("Could not bleach %r : %s (%s)", old, e,
                                   self)
                    continue
                if old != new:
                    logger.debug("Bleaching %s from %r to %r", f.name, old,
                                 new)
                    yield f, old, new

    def after_ui_save(self, ar, cw):
        # Invalidate disabled_fields cache
        self._disabled_fields = None

    def after_ui_create(self, ar):
        # print(19062017, "Ticket 1910")
        pass

    def save_new_instance(elem, ar):
        """Save this instance and fire related behaviour."""
        # elem.full_clean()
        pre_ui_save.send(sender=elem.__class__, instance=elem, ar=ar)
        elem.before_ui_save(ar, None)
        elem.save(force_insert=True)
        # yes, `on_ui_created` comes *after* save()
        on_ui_created.send(elem, request=ar.request)
        ar.selected_rows.append(elem)
        elem.after_ui_create(ar)
        if ar and ar.actor:
            ar.actor.after_create_instance(elem, ar)
        elem.after_ui_save(ar, None)

    def save_watched_instance(elem, ar, watcher):
        if watcher.is_dirty():
            # pre_ui_save.send(sender=elem.__class__, instance=elem, ar=ar)
            # elem.before_ui_save(ar, watcher)
            elem.save(force_update=True)
            watcher.send_update(ar)
            ar.success(_("%s has been updated.") % obj2unicode(elem))
        else:
            ar.success(_("%s : nothing to save.") % obj2unicode(elem))
        elem.after_ui_save(ar, watcher)

    def delete_instance(self, ar):
        pre_ui_delete.send(sender=self, request=ar.request)
        self.delete()

    def get_row_permission(self, ar, state, ba):
        """Returns True or False whether this database object gives permission
        to the ActionRequest `ar` to run the specified action.

        """
        return ba.get_bound_action_permission(ar, self, state)

    def update_owned_instance(self, controllable):
        #~ print '20120627 tools.Model.update_owned_instance'
        pass

    def after_update_owned_instance(self, controllable):
        pass

    def get_mailable_recipients(self):
        """
        Return or yield a list of (type,partner) tuples to be
        used as recipents when creating an outbox.Mail from this object.
        """
        return []

    def get_postable_recipients(self):
        """
        Return or yield a list of Partners to be
        used as recipents when creating a posting.Post from this object.
        """
        return []

    def on_duplicate(self, ar, master):
        pass

    def after_duplicate(self, ar, master):
        pass

    def before_state_change(self, ar, old, new):
        """Called by :meth:`set_workflow_state` before a state change."""
        pass

    def after_state_change(self, ar, old, new):
        """Called by :meth:`set_workflow_state` after a state change."""
        ar.set_response(refresh=True)

    def set_workflow_state(row, ar, state_field, target_state):
        """Called by workflow actions (:class:`ChangeStateAction
        <lino.core.workflows.ChangeStateAction>`) to perform the
        actual state change.

        """
        watcher = ChangeWatcher(row)
        # assert hasattr(row, state_field.attname)
        old = getattr(row, state_field.attname)
        target_state.choicelist.before_state_change(row, ar, old, target_state)
        row.before_state_change(ar, old, target_state)
        setattr(row, state_field.attname, target_state)
        # row.save()
        target_state.choicelist.after_state_change(row, ar, old, target_state)
        row.after_state_change(ar, old, target_state)
        # row.full_clean()
        row.save()
        watcher.send_update(ar)
        row.after_ui_save(ar, watcher)

    def after_send_mail(self, mail, ar, kw):
        """
        Called when an outbox email controlled by self has been sent
        (i.e. when the :class:`lino_xl.lib.outbox.models.SendMail`
        action has successfully completed).
        """
        pass

    def as_search_item(self, ar):

        def _get_attr(attr):
            value = getattr(self, attr)
            if value.startswith('<'):
                return value
            return tostring(E.p(value))

        if hasattr(self, 'body_short_preview'):
            content = _get_attr('body_short_preview')
        elif hasattr(self, 'description'):
            content = _get_attr('description')
        else:
            content = ""
        return tostring(E.h4(ar.obj2html(self))) + ar.parse_memo(content)

    @property
    def edge_ngram_field(self):
        values = [
            str(getattr(self, field.name))
            for field in self.quick_search_fields +
            self.quick_search_fields_digit
        ]
        return ' '.join(values)

    @property
    def haystack_rendered_field(self):
        """

        Used by haystack.indexes.SearchIndex.

        Haystack stores the value from this property in the search engine
        backend along with the document so that there's no need to make a
        database query and show this value as the search result.

        """
        ar = self.__class__.get_default_table().request()
        return self.as_search_item(ar)

    # def summary_row(self, ar, **kw):
    #     yield ar.obj2html(self)

    @displayfield(_("Name"), max_length=15)
    def name_column(self, ar):
        return str(self)

    # @fields.displayfield(_("Description"))
    # def mobile_item(self, ar):
    #     if ar is None:
    #         return ''
    #     return E.div(*forcetext(self.get_mobile_list_item_elems(ar)))
    #
    # def get_mobile_list_item_elems(self, ar):
    #     return [self.obj2href(ar)]

    @fields.htmlbox()
    def navigation_panel(self, ar):
        if not isinstance(ar, TableRequest):
            return None
        items = []
        navinfo = ar.actor.get_navinfo(ar, self)
        if navinfo is None:
            return None
        nav_items = [
            ar.goto_pk(navinfo['first'], "⏮ "),  # 23EE
            ar.goto_pk(navinfo['prev_page'], "⏪ "),  # 23Ea
            ar.goto_pk(navinfo['prev'], "◀ "),  # 25C0
            navinfo['message'],
            ar.goto_pk(navinfo['next'], " ▶"),  # 25B6
            ar.goto_pk(navinfo['next_page'], " ⏩"),  # 23E9
            ar.goto_pk(navinfo['last'], " ⏭")  # 0x23ED
        ]
        items.append(E.p(*nav_items))
        # print("20210325", navinfo)
        # raise Exception("20")

        qs = sliced_data_iterator(ar.data_iterator, navinfo['offset'],
                                  ar.limit)
        for obj in qs:
            if obj.pk == self.pk:
                items.append(E.b(str(obj)))
            else:
                items.append(ar.obj2html(obj))
        # return E.ul(*items, **{'class': 'layout-wrapper layout-sidebar'})
        # return E.div(*items, **{'class': 'l-component'})
        # items = join_elems([E.p(i) for i in items])
        items = join_elems(items, sep=E.br)
        kwargs = {'class': 'list-item', 'style': 'width:15em;'}
        return ar.html_text(E.div(*items, **kwargs))

    @displayfield(_("Workflow"))
    def workflow_buttons(self, ar):
        if ar is None:
            return ''
        return self.get_workflow_buttons(ar)

    def get_workflow_buttons(obj, ar):
        l = []
        actor = ar.actor
        # print(20170102, actor)
        state = actor.get_row_state(obj)
        sep = ''
        show = True  # whether to show the state

        # logger.info('20161219 workflow_buttons %r', state)

        def show_state():
            l.append(sep)
            #~ l.append(E.b(unicode(state),style="vertical-align:middle;"))
            if state.button_text:
                l.append(E.b(u"{} {}".format(state.button_text, state)))
            else:
                l.append(E.b(str(state)))
            # l.append(E.b(str(state)))
            #~ l.append(u" » ")
            #~ l.append(u" \u25b8 ")
            #~ l.append(u" \u2192 ")
            #~ sep = u" \u25b8 "

        df = actor.get_disabled_fields(obj, ar)
        # print(20170909, df)
        for ba in actor.get_actions():
            assert ba.actor == actor  # 20170102
            if ba.action.show_in_workflow:
                # if actor.model.__name__ == 'Vote':
                #     if ba.action.__class__.__name__ == 'MarkVoteAssigned':
                #         print(20170115, actor, ar.get_user())
                if ba.action.action_name not in df:
                    if actor.get_row_permission(obj, ar, state, ba):
                        if show and isinstance(ba.action, ChangeStateAction):
                            show_state()
                            sep = u" \u2192 "  # "→"
                            show = False
                        l.append(sep)
                        l.append(ar.action_button(ba, obj))
                        sep = ' '
        if state and show:
            show_state()
        return E.span(*l)

    def error2str(self, e):
        return error2str(self, e)

    def __repr__(self):
        return obj2str(self)

    def get_related_project(self):
        if settings.SITE.project_model:
            if isinstance(self, settings.SITE.project_model):
                return self

    # def to_html(self, **kw):
    #     import lino.ui.urls  # hack: trigger ui instantiation
    #     actor = self.get_default_table()
    #     kw.update(renderer=settings.SITE.kernel.text_renderer)
    #     ar = actor.request(**kw)
    #     return self.preview(ar)
    #     #~ username = kw.pop('username',None)

    def get_typed_instance(self, model):
        """
        Used when implementing :ref:`polymorphism`.
        """
        return self


#     def is_attestable(self):
#         """Override this to disable the :class:`lino_xl.lib.excerpts.CreateExcerpt`
# action on individual instances.

#         """
#         return True

    @classmethod
    def get_chooser_for_field(cls, fieldname):
        d = getattr(cls, '_choosers_dict', {})
        # if fieldname.endswith("__municipality"):
        #     print("20200425 Model.get_chooser_for_field", cls, fieldname, d)
        return d.get(fieldname, None)

    def filename_root(self):
        return self._meta.app_label + '.' + self.__class__.__name__ \
            + '-' + str(self.pk)

    @classmethod
    def get_request_queryset(cls, ar, **filter):
        """Return the base queryset for tables on this object.

        The optional `filter` keyword arguments, if present, are
        applied as additional filter. This is used only in UNION
        tables on abstract model mixins where filtering cannot be done
        after the join.

        """
        qs = cls.get_user_queryset(ar.get_user(), **filter)
        if ar.actor.only_fields is not None:
            qs = qs.only(ar.actor.only_fields)
        return qs

    @classmethod
    def get_user_queryset(cls, user, **filter):
        """
        Get the base queryset, used for user level row filtering in :class:`lino_xl.lib.tickets.Ticket`
        """
        if filter:
            return cls.objects.filter(**filter)
        return cls.objects.all()

    # @classmethod
    # def get_title_tags(self, ar):
    #     return []

    @classmethod
    def resolve_states(cls, states):
        """Convert the given string `states` into a set of state objects.

        The states specifier must be either a set containing state
        objects or a string containing a space-separated list of valid
        state names. If it is a string, convert it to the set.

        """
        if states is None:
            return None
        elif isinstance(states, str):
            fld = cls.workflow_state_field
            return set([fld.choicelist.get_by_name(x) for x in states.split()])
        elif isinstance(states, set):
            return states
        raise Exception(
            "Cannot resolve stateset specifier {!r}".format(states))

    @classmethod
    def add_picker(model, fldname):
        """Add a picker for the named choicelist field.

        A picker is a virtual field that shows all the available choices in a
        way that you can click on them to change the underlying choicelist
        field's value.  Functionally similar to a radio button, but causes
        immediate submit instead of waiting until the form gets submitted.

        """
        fld = model._meta.get_field(fldname)
        cls = fld.choicelist
        action_name = "do_pick_" + fldname
        vfield_name = "pick_" + fldname

        class QuickSetChoice(actions.Action):

            label = cls.verbose_name
            icon_name = None
            show_in_toolbar = False
            no_params_window = True
            parameters = dict(choice=cls.field())

            def run_from_ui(self, ar, **kw):
                obj = ar.selected_rows[0]
                pv = ar.action_param_values
                if isinstance(fld, fields.VirtualField):
                    fld.set_value_in_object(ar, obj, pv.choice)
                else:
                    setattr(obj, fld.attname, pv.choice)
                obj.full_clean()
                obj.save()
                ar.success(refresh=True)

        ai = QuickSetChoice()
        setattr(model, action_name, ai)

        lbl = format_lazy(_("Pick {}"), cls.verbose_name)

        @displayfield(lbl, editable=True)
        def pick_choice(self, ar):
            if ar is None:
                return fld.value_from_object(self)
            # ▶: U+25B6, ◀: U+25C0
            selected_tpl = "▶{}◀"
            elems = []
            ba = ar.actor.get_action_by_name(action_name)
            for v in cls.get_list_items():
                kw = dict(action_param_values=dict(choice=v))
                label = str(v.button_text or v.text)
                if fld.value_from_object(self, ar) == v:
                    elems.append(E.b(selected_tpl.format(label)))
                else:
                    if fldname in ar.actor.get_disabled_fields(self, ar):
                        pass  # elems.append(label)
                    else:
                        elems.append(
                            ar.action_button(ba,
                                             self,
                                             label=label,
                                             request_kwargs=kw,
                                             title=v.text))
            return E.p(*join_elems(elems, sep=" | "))

        setattr(model, vfield_name, pick_choice)

    @classmethod
    def django2lino(cls, model):
        """
        Inject Lino model methods into a pure Django model that does
        not inherit from :class:`lino.core.model.Model`.

        """
        wo = {}
        for b in model.__mro__:
            if issubclass(b, cls):
                wo.update(b._widget_options)
        model._widget_options = wo

        if issubclass(model, cls):
            return

        # print("20210306 djago2lino", cls, model)

        for k in LINO_MODEL_ATTRIBS:
            if not hasattr(model, k):
                #~ setattr(model,k,getattr(dd.Model,k))
                # if k in cls.__dict__:
                #     setattr(model, k, cls.__dict__[k])
                # else:
                #     setattr(model, k, getattr(cls, k))
                for b in cls.__mro__:
                    if k in b.__dict__:
                        setattr(model, k, b.__dict__[k])
                        break
                # setattr(model, k, getattr(cls, k))
                #~ model.__dict__[k] = getattr(dd.Model,k)
                #~ logger.info("20121127 Install default %s for %s",k,model)

    @classmethod
    def get_subclasses_graph(self):
        """
        Returns an internationalized `graphviz` directive representing
        the polymorphic forms of this model.

        Usage example::

          .. django2rst::

              with dd.translation.override('de'):
                  contacts.Partner.print_subclasses_graph()

        """
        from lino.api import rt
        pairs = []
        collected = set()

        def collect(p):
            for c in rt.models_by_base(p):
                # if c is not p and (p in c.__bases__):
                # if c is not m and p in c.__bases__:
                if c is not p:
                    # ok = True
                    # for cb in c.__bases__:
                    #     if cb in p.mro():
                    #         ok = False
                    # if ok:
                    if c not in collected:
                        pairs.append((p, c))
                        collected.add(c)
                    collect(c)

        collect(self)
        s = '\n'.join([
            '    "%s" -> "%s"' % (p._meta.verbose_name, c._meta.verbose_name)
            for p, c in pairs
        ])
        return """

.. graphviz::

   digraph foo {
%s
  }

""" % s

    @classmethod
    def print_subclasses_graph(self):
        print(self.get_subclasses_graph())

LINO_MODEL_ATTRIBS = (
    'collect_virtual_fields',
    'define_action',
    'get_workflow_buttons',
    'workflow_buttons',
    'delete_instance',
    'setup_parameters',
    'add_param_filter',
    'save_new_instance',
    'save_watched_instance',
    'save_existing_instance',
    '_widget_options',
    'set_widget_options',
    'get_widget_options',
    'get_chooser_for_field',
    'get_detail_action',
    # 'get_print_language',
    'get_row_permission',
    # 'get_excerpt_options',
    # 'is_attestable',
    'as_summary_row',
    'as_paragraph',
    'as_page',
    'get_data_elem',
    'get_param_elem',
    'after_ui_save',
    'preferred_foreignkey_width',
    'before_ui_save',
    'allow_cascaded_delete',
    'allow_cascaded_copy',
    'workflow_state_field',
    'workflow_owner_field',
    'disabled_fields',
    'get_choices_text',
    # 'summary_row',
    'submit_insert',
    'active_fields',
    'hidden_columns',
    'hidden_elements',
    'get_simple_parameters',
    'get_request_queryset',
    'get_user_queryset',
    'get_title_tags',
    'get_default_table',
    'get_default_table',
    'get_layout_aliases',
    'edge_ngram_field',
    'as_search_item',
    'as_story_item',
    'haystack_rendered_field',
    'get_related_project',
    'obj2href',
    'quick_search_fields',
    'quick_search_fields_digit',
    'change_watcher_spec',
    'on_analyze',
    'disable_delete',
    'lookup_or_create',
    'quick_search_filter',
    'on_duplicate',
    'on_create',
    'error2str',
    'get_typed_instance',
    'print_subclasses_graph',
    'grid_post',
    'submit_insert',
    'delete_veto_message',
    '_lino_tables',
    'show_in_site_search',
    'allow_merge_action')


@receiver(pre_delete)
def pre_delete_handler(sender, instance=None, **kw):
    """Before actually deleting an object, we override Django's behaviour
    concerning related objects via a GFK field.

    In Lino you can configure the cascading behaviour using
    :attr:`allow_cascaded_delete`.

    See also :doc:`/dev/gfks`.

    It seems that Django deletes *generic related objects* only if
    the object being deleted has a `GenericRelation
    <https://docs.djangoproject.com/en/5.0/ref/contrib/contenttypes/#django.contrib.contenttypes.fields.GenericRelation>`_
    field (according to `Why won't my GenericForeignKey cascade
    when deleting?
    <https://stackoverflow.com/questions/6803018/why-wont-my-genericforeignkey-cascade-when-deleting>`_).
    OTOH this statement seems to be wrong: it happens also in my
    projects which do *not* use any `GenericRelation`.  As
    :mod:`test_broken_gfk
    <lino_welfare.projects.eupen.tests.test_broken_gfk>` shows.

    TODO: instead of calling :meth:`disable_delete
    <lino.core.model.Model.disable_delete>` here again (it has
    been called earlier by the delete action before asking for user
    confirmation), Lino might change the `on_delete` attribute of all
    `ForeignKey` fields which are not in
    :attr:`allow_cascaded_delete` from ``CASCADE`` to
    ``PROTECTED`` at startup.

    """

    kernel = settings.SITE.kernel
    # print "20141208 generic related objects for %s:" % obj
    must_cascade = []
    for gfk, fk_field, qs in kernel.get_generic_related(instance):
        if gfk.name in qs.model.allow_cascaded_delete:
            must_cascade.append(qs)
        else:
            if fk_field.null:  # clear nullable GFKs
                for obj in qs:
                    setattr(obj, gfk.name, None)
            elif qs.count():
                raise Warning(
                    instance.delete_veto_message(qs.model, qs.count()))
    for qs in must_cascade:
        if qs.count():
            logger.info("Deleting %d %s before deleting %s", qs.count(),
                        qs.model._meta.verbose_name_plural, obj2str(instance))
            for obj in qs:
                obj.delete()
