Source code for widgy.models.base

"""
Classes in this module supply the abstract models used to create new widgy
objects.
"""
from collections import defaultdict
from functools import partial
import logging
import itertools
import copy

from django.db import models, transaction
from django import forms
from django.forms.models import modelform_factory, ModelForm
from django.contrib.contenttypes.models import ContentType
from django.template import RequestContext
from django.contrib.admin import widgets
from django.template.defaultfilters import capfirst
from django.utils.encoding import force_text, python_2_unicode_compatible

from treebeard.mp_tree import MP_Node

from widgy.exceptions import (
    InvalidOperation,
    InvalidTreeMovement,
    ParentChildRejection,
    RootDisplacementError
)
from widgy.signals import pre_delete_widget
from widgy.generic import WidgyGenericForeignKey, ProxyGenericRelation
from widgy.utils import (
    exception_to_bool, update_context, render_to_string, unset_pks,
)
from widgy.widgets import DateTimeWidget, DateWidget, TimeWidget

logger = logging.getLogger(__name__)

# TODO: Don't use the Admin widgets.
FORMFIELD_FOR_DBFIELD_DEFAULTS = {
    models.DateTimeField: {
        'form_class': forms.SplitDateTimeField,
        'widget': DateTimeWidget
    },
    models.DateField: {'widget': DateWidget},
    models.TimeField: {'widget': TimeWidget},
}


@python_2_unicode_compatible
[docs]class Node(MP_Node): """ Instances of this class maintain the Materialized Path tree structure that the generic content is attached to. For more information, consult the Treebeard_ documentation. **Model Fields**: :content_type: ``models.ForeignKey(ContentType)`` :content_id: ``models.PositiveIntegerField()`` :content: ``generic.GenericForeignKey('content_type', 'content_id')`` .. _Treebeard: https://tabo.pe/projects/django-treebeard/docs/1.61/index.html """ content_type = models.ForeignKey(ContentType) content_id = models.PositiveIntegerField() content = WidgyGenericForeignKey('content_type', 'content_id') is_frozen = models.BooleanField(default=False) class Meta: app_label = 'widgy' unique_together = [('content_type', 'content_id')] def __str__(self): return force_text(self.content) def to_json(self, site): children = [c.to_json(site) for c in self.get_children()] json = { 'url': self.get_api_url(site), 'content': self.content.to_json(site), 'children': children, 'available_children_url': self.get_available_children_url(site), 'possible_parents_url': self.get_possible_parents_url(site), } parent = self.get_parent() if parent: json['parent_id'] = parent.get_api_url(site) right = self.get_next_sibling() json['right_id'] = right and right.get_api_url(site) return json
[docs] def render(self, *args, **kwargs): """ Delegates the render call to the content instance. """ return self.content.render(*args, **kwargs)
def get_children(self): if hasattr(self, '_children'): return self._children return super(Node, self).get_children() def get_parent(self, *args, **kwargs): if hasattr(self, '_parent'): return self._parent return super(Node, self).get_parent(*args, **kwargs) def get_next_sibling(self): if hasattr(self, '_parent'): if self._parent: siblings = list(self._parent.get_children()) else: siblings = [self] try: return siblings[siblings.index(self) + 1] except IndexError: return None return super(Node, self).get_next_sibling() def get_ancestors(self): if hasattr(self, '_parent'): if self._parent: return list(self._parent.get_ancestors()) + [self._parent] else: return [] return super(Node, self).get_ancestors() def get_root(self): if hasattr(self, '_parent'): if self._parent: return self._parent.get_root() else: return self return super(Node, self).get_root()
[docs] def maybe_prefetch_tree(self): """ Prefetch the tree unless it has already been prefetched """ if not hasattr(self, '_children'): self.prefetch_tree()
[docs] def depth_first_order(self): """ All of the nodes in my tree (including myself) in depth-first order. """ if hasattr(self, '_children'): ret = [self] for child in self.get_children(): ret.extend(child.depth_first_order()) return ret else: return [self] + list(self.get_descendants().order_by('path'))
@staticmethod def fetch_content_instances(nodes): """ Given a list of nodes, efficiently get all of their content instances. The structure returned looks like this:: { content_type_id: { content_id: content_instance, content_id: content_instance, }, content_type_id: { content_id: content_instance, }, } """ # Build a mapping of content_types -> ids contents = defaultdict(set) for node in nodes: contents[node.content_type_id].add(node.content_id) # Convert that mapping to content_types -> Content instances for content_type_id, content_ids in contents.items(): try: ct = ContentType.objects.get_for_id(content_type_id) model_class = ct.model_class() except AttributeError: # get_for_id raises AttributeError when there's no model_class in django < 1.6. model_class = None if model_class: contents[content_type_id] = ct.model_class().objects.in_bulk(content_ids) else: ct = ContentType.objects.get(id=content_type_id) contents[content_type_id] = dict((id, UnknownWidget(ct, id)) for id in content_ids) # Warn about using an UnknownWidget. It doesn't matter which instance we use. next(iter(contents[content_type_id].values()), UnknownWidget(ct, None)).warn() return contents @classmethod def attach_content_instances(cls, nodes): """ Given a list of nodes, attach each one's Content. Efficiently. """ needed_nodes = [i for i in nodes if '_content_cache' not in i.__dict__] contents = cls.fetch_content_instances(needed_nodes) for node in needed_nodes: node.content = contents[node.content_type_id][node.content_id] for node in nodes: node.content.node = node return nodes @classmethod
[docs] def prefetch_trees(cls, *root_nodes): trees = [i.depth_first_order() for i in root_nodes] cls.attach_content_instances(list(itertools.chain(*trees))) for tree in trees: root_node = tree.pop(0) # This should get_depth() or is_root(), but both of those do # another query if root_node.depth == 1: root_node._parent = None root_node.consume_children(tree) assert not tree, "all of the nodes should be consumed"
[docs] def prefetch_tree(self): """ Builds the entire tree using python. Each node has its Content instance filled in, and the reverse node relation on the content filled in as well. """ self.prefetch_trees(self)
def consume_children(self, descendants): """ Helper method to assign the proper children in the proper order to each node """ self._children = [] while descendants: child = descendants[0] if child.depth == self.depth + 1: self._children.append(descendants.pop(0)) child._parent = self child.consume_children(descendants) else: break def get_api_url(self, site): return site.reverse(site.node_view, kwargs={'node_pk': self.pk}) def get_available_children_url(self, site): return site.reverse(site.shelf_view, kwargs={'node_pk': self.pk}) def get_possible_parents_url(self, site): return site.reverse(site.node_parents_view, kwargs={'node_pk': self.pk}) def filter_child_classes(self, site, classes): """ What Content classes from `classes` would I let be my children? """ validator = partial(site.validate_relationship, self.content) return list(filter( exception_to_bool(validator, ParentChildRejection), classes)) def filter_child_classes_recursive(self, site, classes): """ A dictionary of node objects to lists of content classes they would allow:: { node_obj: [content_class, content_class, ...], ... } """ allowed_classes = {self: self.filter_child_classes(site, classes)} for child in self.get_children(): allowed_classes.update(child.filter_child_classes_recursive(site, classes)) return allowed_classes def possible_parents(self, site, root_node): """ Where in root_node's tree can I be moved? """ validator = exception_to_bool( partial(site.validate_relationship, child=self.content), ParentChildRejection) all_nodes = root_node.depth_first_order() my_family = set(self.depth_first_order()) return [i for i in all_nodes if validator(i.content) and i not in my_family] def clone_tree(self, freeze=True): """ 1. new_root <- root_node 2. new_root.content <- Clone(root_node.content) 3. Insert new_root. 4. children <- All descendents of root node. 5. Iterate over children as child: i. unset PK ii. replace child.path[0:steplen] with new_root.path iii. cloned_content <- child.content iv. content_id <- cloned_content.pk 6. Issue a bulk_create for children. """ # This method only supports cloning an entire tree. We don't need it # for versioning, and I'm not sure what the semantics would be. cls = self.__class__ assert self.depth == 1 self.maybe_prefetch_tree() new_root = cls.add_root( content=self.content.clone(), numchild=self.numchild, is_frozen=freeze, ) children_to_create = [] for child in self.depth_first_order()[1:]: children_to_create.append(Node( content=child.content.clone(), path=new_root.path + child.path[cls.steplen:], is_frozen=freeze, depth=child.depth, numchild=child.numchild, )) cls.objects.bulk_create(children_to_create) return new_root def check_frozen(self): if self.is_frozen: raise InvalidOperation({'message': "This widget is uneditable."}) def delete(self, *args, **kwargs): self.check_frozen() return super(Node, self).delete(*args, **kwargs) def add_child(self, *args, **kwargs): self.check_frozen() return super(Node, self).add_child(*args, **kwargs) def add_sibling(self, *args, **kwargs): self.check_frozen() return super(Node, self).add_sibling(*args, **kwargs) def move(self, *args, **kwargs): self.check_frozen() return super(Node, self).move(*args, **kwargs) def trees_equal(self, other): if self.content_type_id != other.content_type_id: return False if not self.get_depth() == other.get_depth(): return False if not self.get_children_count() == other.get_children_count(): return False if not self.content.equal(other.content): return False for child, other_child in zip(self.get_children(), other.get_children()): if not child.trees_equal(other_child): return False return True @classmethod
[docs] def find_widgy_problems(cls, site=None): """ Searches all the nodes for inconsistencies. - Nodes whose content doesn't exist - Nodes whose content types don't exist (UnknownWidgets) TODO: Maybe check for Content instances that don't have any nodes pointing to them. """ dangling, unknown = [], [] for node in cls.objects.all(): content = node.content if not content: dangling.append(node.id) elif isinstance(content, UnknownWidget): unknown.append(node.id) return dangling, unknown
def check_frozen(sender, instance, **kwargs): instance.check_frozen() models.signals.pre_delete.connect(check_frozen, sender=Node)
[docs]class Content(models.Model): """ Abstract base class for all models that are intended to a part of a Widgy tree structure. **Model Fields**: :_nodes: ``generic.GenericRelation(Node, content_type_field='content_type', object_id_field='content_id')`` """ _nodes = ProxyGenericRelation(Node, content_type_field='content_type', object_id_field='content_id') # these preferences only affect the frontend interface and editing through # the API draggable = True deletable = True editable = False accepting_children = False shelf = False tooltip = None component_name = 'widget' CANNOT_POP_OUT = 0 CAN_POP_OUT = 1 MUST_POP_OUT = 2 pop_out = CANNOT_POP_OUT form = ModelForm formfield_overrides = {} class Meta: abstract = True app_label = 'widgy' def __init__(self, *args, **kwargs): super(Content, self).__init__(*args, **kwargs) overrides = FORMFIELD_FOR_DBFIELD_DEFAULTS.copy() overrides.update(self.formfield_overrides) self.formfield_overrides = overrides def get_api_url(self, site): return site.reverse(site.content_view, kwargs={ 'object_name': self._meta.model_name, 'app_label': self._meta.app_label, 'object_pk': self.pk}) def to_json(self, site): node_pk_kwargs = {'node_pk': self.node.pk} data = { 'url': self.get_api_url(site), '__class__': self.class_name, 'css_classes': self.get_css_classes(), 'component': self.component_name, 'model': self._meta.model_name, 'object_name': self._meta.object_name, 'draggable': self.draggable, 'deletable': self.deletable, 'accepting_children': self.accepting_children, 'template_url': site.reverse(site.node_templates_view, kwargs=node_pk_kwargs), 'preview_template': self.get_preview_template(site), 'pop_out': self.pop_out, 'shelf': self.shelf, 'attributes': self.get_attributes(), 'form_prefix': self.get_form_prefix(), 'display_name': self.display_name, } if self.editable: data['edit_url'] = site.reverse(site.node_edit_view, kwargs=node_pk_kwargs) return data def get_attributes(self): model_data = {} for field in self._meta.concrete_fields: if field.serialize: model_data[field.attname] = field.value_from_object(self) for field in self._meta.many_to_many: # this is copied from django.forms.models.model_to_dict if self.pk is None: model_data[field.name] = [] else: qs = field.value_from_object(self) if qs._result_cache is not None: model_data[field.name] = [item.pk for item in qs] else: model_data[field.name] = list(qs.values_list('pk', flat=True)) return model_data @classmethod def class_to_json(cls, site): """ :Returns: a json-able python object that represents the class type. """ return { '__class__': "%s.%s" % (cls._meta.app_label, cls._meta.model_name), 'title': capfirst(cls._meta.verbose_name), 'css_classes': cls.get_class_css_classes(), 'tooltip': cls.tooltip and force_text(cls.tooltip), } @property def display_name(self): return force_text(self._meta.verbose_name) @property def class_name(self): """ :Returns: a fully qualified classname including app_label and model_name """ return "%s.%s" % (self._meta.app_label, self._meta.model_name) @classmethod def get_class_css_classes(cls): if hasattr(cls, 'css_classes'): return cls.css_classes return (cls._meta.app_label, cls._meta.model_name) def get_css_classes(self): if hasattr(self, 'css_classes'): return self.css_classes return self.get_class_css_classes() @property def node(self): """ Settable property used by Node.prefetch_tree to optimize tree rendering """ if hasattr(self, '_node'): return self._node try: return self._nodes.all()[0] except IndexError: raise Node.DoesNotExist @node.setter def node(self, value): self._node = value
[docs] def get_root(self): return self.node.get_root().content
[docs] def get_ancestors(self): ancestors = Node.attach_content_instances(self.node.get_ancestors()) return [node.content for node in ancestors]
[docs] def depth_first_order(self): nodes = Node.attach_content_instances(self.node.depth_first_order()) return [node.content for node in nodes]
[docs] def get_children(self): node_children = Node.attach_content_instances(self.node.get_children()) return [node.content for node in node_children]
[docs] def get_next_sibling(self): sib = self.node.get_next_sibling() return sib and sib.content
[docs] def get_parent(self): parent = self.node.get_parent() return parent and parent.content
[docs] def get_form_class(self, request): defaults = { "form": self.form, "formfield_callback": partial(self.formfield_for_dbfield, request=request), "fields": "__all__", } form_class = modelform_factory(self.__class__, **defaults) # Rather than make everybody subclass a special form, add the # required_css_class and error_css_class manually here, because we know # we need it in the widgy editor. If need be, we can make the actual # classes configurable somehow later. if not hasattr(form_class, 'required_css_class'): form_class.required_css_class = 'required' if not hasattr(form_class, 'error_css_class'): form_class.error_css_class = 'error' return form_class
def get_form_prefix(self): return self.node.pk
[docs] def get_form(self, request, **form_kwargs): form_class = self.get_form_class(request) form_kwargs.setdefault('instance', self) return form_class(**form_kwargs)
[docs] def valid_parent_of(self, cls, obj=None): """ Given a content class, can it be _added_ as our child? Note: this does not apply to _existing_ children (adoption) """ return self.accepting_children
@classmethod
[docs] def valid_child_of(cls, parent, obj=None): """ Given a `Content` instance, does our class consent to be adopted by them? """ return True
@classmethod
[docs] def add_root(cls, site, **kwargs): """ Creates a new Content instance, stores it in the database, and calls ``Node.add_root`` """ obj = cls.objects.create(**kwargs) Node.add_root(content=obj) obj.post_create(site) return obj
[docs] def add_child(self, site, cls, **kwargs): self.check_frozen() obj = cls.objects.create(**kwargs) self.node.add_child(content=obj) try: site.validate_relationship(self, obj) except: obj.delete(raw=True) raise obj.post_create(site) return obj
[docs] def add_sibling(self, site, cls, **kwargs): self.check_frozen() if self.node.is_root(): raise RootDisplacementError({'message': 'You can\'t put things next to me'}) obj = cls.objects.create(**kwargs) self.node.add_sibling(content=obj, pos='left') parent = self.node.get_parent().content try: site.validate_relationship(parent, obj) except ParentChildRejection: obj.delete(raw=True) raise obj.post_create(site) return obj
[docs] def post_create(self, site): """ Hook for custom code which needs to be run after creation. Since the `Node` must be created after the content, any tree based actions have to happen after the save event has finished. """ pass
@classmethod
[docs] def get_templates_hierarchy(cls, **kwargs): templates = kwargs.get('hierarchy', ( 'widgy/{app_label}/{model_name}/{template_name}{extension}', 'widgy/{app_label}/{template_name}{extension}', 'widgy/{template_name}{extension}', )) kwargs.setdefault('extension', '.html') ret = [] for template in templates: for parent_cls in cls.__mro__: try: ret.extend( template.format(**i) for i in parent_cls.get_template_kwargs(**kwargs) ) except AttributeError: pass # This must return a list or tuple because # django.template.render_to_string does a typecheck. return ret
@classmethod def get_template_kwargs(cls, **kwargs): defaults = { 'app_label': cls._meta.app_label, 'model_name': cls._meta.model_name, } defaults.update(**kwargs) return [defaults] @property def preview_templates(self): """ List of templates to search for the content template that is displayed in the editor to show a preview. """ return self.get_templates_hierarchy(template_name='preview') @property def edit_templates(self): """ List of templates to search for the edit form. """ return self.get_templates_hierarchy(template_name='edit')
[docs] def get_render_templates(self, context): """ List of templates to search for the rendered template on the frontend. """ return self.get_templates_hierarchy(template_name='render')
def get_form_template(self, request, template=None, context=None): """ :Returns: Rendered form template with the given context, if any. """ if not context: context = RequestContext(request) with update_context(context, {'form': self.get_form(request, prefix=self.get_form_prefix())}): return render_to_string(template or self.edit_templates, context) def get_preview_template(self, site): """ :Returns: Rendered preview template. """ return render_to_string(self.preview_templates, { 'self': self, 'edit_url': site.reverse(site.node_edit_view, kwargs={ 'node_pk': self.node.pk, }), 'site': site, })
[docs] def render(self, context, template=None): """ A ``template`` kwarg can be passed to use an explictly defined template instead of the default template list. """ with update_context(context, {'self': self}): return render_to_string( template or self.get_render_templates(context), context )
def formfield_for_dbfield(self, db_field, **kwargs): """ Hook for specifying the form Field instance for a given database Field instance. If kwargs are given, they're passed to the form Field's constructor. """ request = kwargs.pop("request") formfield = db_field.formfield(**kwargs) if formfield and isinstance(db_field, (models.ForeignKey, models.ManyToManyField)): from django.contrib.admin import site as admin_site related_modeladmin = admin_site._registry.get(db_field.rel.to) can_add_related = bool(related_modeladmin and related_modeladmin.has_add_permission(request)) formfield.widget = widgets.RelatedFieldWidgetWrapper( formfield.widget, db_field.rel, admin_site, can_add_related=can_add_related) else: for klass in db_field.__class__.mro(): if klass in self.formfield_overrides: kwargs = dict(copy.deepcopy(self.formfield_overrides[klass]), **kwargs) return db_field.formfield(**kwargs) return formfield def get_templates(self, request): return { 'edit_template': self.get_form_template(request), }
[docs] def reposition(self, site, right=None, parent=None): self.check_frozen() old_parent_node = self.node.get_parent() old_right_node = self.node.get_next_sibling() if right: if right.node.is_root(): raise InvalidTreeMovement({'message': 'You can\'t move the root'}) site.validate_relationship(right.get_parent(), self) self.node.move(right.node, pos='left') elif parent: site.validate_relationship(parent, self) self.node.move(parent.node, pos='last-child') else: assert right or parent try: # When moving, it's necessary to recheck compatibility for all of # our children. For example, this detects deep nesting of # un-nestable widgets. node = self._nodes.get() # use a new, uncached node # use a prefetched tree node.prefetch_tree() node.content._recheck_children(site) except ParentChildRejection: # Backout. It'd be nice to rely on a transaction here and have this # happen automatically. if old_right_node: self.node.move(old_right_node, pos='left') else: self.node.move(old_parent_node, pos='last-child') raise
def _recheck_children(self, site): for c in self.get_children(): site.validate_relationship(self, c) c._recheck_children(site)
[docs] def delete(self, raw=False): self.check_frozen() pre_delete_widget.send(self.__class__, instance=self, raw=raw) for child in self.get_children(): child.delete(raw) self.node.delete() super(Content, self).delete()
@transaction.atomic
[docs] def clone(self): """ **Note:** In order for clone to work, you need to have an auto-incrementing primary key. Also see https://docs.djangoproject.com/en/dev/topics/db/queries/#copying-model-instances """ # TODO: Maybe provide support for many-to-many relationships too, or # document that you should provide your own clone() # See https://code.djangoproject.com/ticket/4027 new = copy.copy(self) unset_pks(new) new.save() for f in self._meta.many_to_many: # This will only handle simple many-to-manies, those that don't # have a custom through table. f.save_form_data(new, f.value_from_object(self)) return new
def save(self, *args, **kwargs): self.check_frozen() return super(Content, self).save(*args, **kwargs) def check_frozen(self): if not self.pk: # if we don't have a pk, we can't have a node return try: self.node.check_frozen() except Node.DoesNotExist: pass
[docs] def equal(self, other): return self.get_attributes() == other.get_attributes() # Otherwise, mixins will inherit Content.Meta and get wierd app_labels # Delete this line and the app_label line when we drop support for Django < 1.7
del Content.Meta class UnknownWidget(Content): """ A placeholder Content class used when the correct one can't be found. For example, when the database refers to widgets from an app that is no longer installed. """ deletable = False draggable = False editable = False class Meta: app_label = 'widgy' # we don't actually need a db table managed = False def __init__(self, content_type, id, *args, **kwargs): super(UnknownWidget, self).__init__(*args, **kwargs) self.content_type = content_type self.id = id def render(self, *args, **kwargs): return '' def delete(*args, **kwargs): pass def warn(self): logger.warning('UnknownWidget being rendered. Content type: %s.%s', self.content_type.app_label, self.content_type.model)