class URLPath(MPTTModel): """ Strategy: Very few fields go here, as most has to be managed through an article's revision. As a side-effect, the URL resolution remains slim and swift. """ # Tells django-wiki that permissions from a this object's article # should be inherited to children's articles. In this case, it's a static # property.. but you can also use a BooleanField. INHERIT_PERMISSIONS = True objects = managers.URLPathManager() _default_manager = objects articles = generic.GenericRelation( ArticleForObject, content_type_field='content_type', object_id_field='object_id', ) # Do NOT modify this field - it is updated with signals whenever # ArticleForObject is changed. article = models.ForeignKey( Article, on_delete=models.CASCADE, editable=False, verbose_name=_('Cache lookup value for articles'), ) SLUG_MAX_LENGTH = 50 slug = models.SlugField(verbose_name=_('slug'), null=True, blank=True, max_length=SLUG_MAX_LENGTH) site = models.ForeignKey(Site) parent = TreeForeignKey( 'self', null=True, blank=True, related_name='children') def __init__(self, *args, **kwargs): pass # Fixed in django-mptt 0.5.3 #self._tree_manager = URLPath.objects return super(URLPath, self).__init__(*args, **kwargs) def __cached_ancestors(self): """ This returns the ancestors of this urlpath. These ancestors are hopefully cached from the article path lookup. Accessing a foreign key included in add_selecte_related on one of these ancestors will not occur an additional sql query, as they were retrieved with a select_related. If the cached ancestors were not set explicitly, they will be retrieved from the database. """ if not self.get_ancestors().exists(): self._cached_ancestors = [] if not hasattr(self, "_cached_ancestors"): self._cached_ancestors = list( self.get_ancestors().select_related_common()) return self._cached_ancestors def __cached_ancestors_setter(self, ancestors): self._cached_ancestors = ancestors # Python 2.5 compatible property constructor cached_ancestors = property(__cached_ancestors, __cached_ancestors_setter) def set_cached_ancestors_from_parent(self, parent): self.cached_ancestors = parent.cached_ancestors + [parent] @property def path(self): if not self.parent: return "" ancestors = list( filter( lambda ancestor: ancestor.parent is not None, self.cached_ancestors)) slugs = [obj.slug if obj.slug else "" for obj in ancestors + [self]] return "/".join(slugs) + "/" def is_deleted(self): """ Returns True if this article or any of its ancestors have been deleted """ return self.first_deleted_ancestor() is not None def first_deleted_ancestor(self): for ancestor in self.cached_ancestors + [self]: if ancestor.article.current_revision.deleted: return ancestor return None @atomic @transaction_commit_on_success def delete_subtree(self): """ NB! This deletes this urlpath, its children, and ALL of the related articles. This is a purged delete and CANNOT be undone. """ try: for descendant in self.get_descendants( include_self=True).order_by("-level"): descendant.article.delete() transaction.commit() except: transaction.rollback() log.exception("Exception deleting article subtree.") @classmethod def root(cls): site = Site.objects.get_current() root_nodes = list( cls.objects.root_nodes().filter(site=site).select_related_common() ) # We fetch the nodes as a list and use len(), not count() because we need # to get the result out anyway. This only takes one sql query no_paths = len(root_nodes) if no_paths == 0: raise NoRootURL( "You need to create a root article on site '%s'" % site) if no_paths > 1: raise MultipleRootURLs( "Somehow you have multiple roots on %s" % site) return root_nodes[0] class MPTTMeta: pass def __str__(self): path = self.path return path if path else ugettext("(root)") def save(self, *args, **kwargs): super(URLPath, self).save(*args, **kwargs) def delete(self, *args, **kwargs): assert not (self.parent and self.get_children() ), "You cannot delete a root article with children." super(URLPath, self).delete(*args, **kwargs) class Meta: verbose_name = _('URL path') verbose_name_plural = _('URL paths') unique_together = ('site', 'parent', 'slug') app_label = settings.APP_LABEL def clean(self, *args, **kwargs): if self.slug and not self.parent: raise ValidationError( _('Sorry but you cannot have a root article with a slug.')) if not self.slug and self.parent: raise ValidationError( _('A non-root note must always have a slug.')) if not self.parent: if URLPath.objects.root_nodes().filter( site=self.site).exclude( id=self.id): raise ValidationError( _('There is already a root node on %s') % self.site) super(URLPath, self).clean(*args, **kwargs) @classmethod def get_by_path(cls, path, select_related=False): """ Strategy: Don't handle all kinds of weird cases. Be strict. Accepts paths both starting with and without '/' """ # TODO: Save paths directly in the model for constant time lookups? # Or: Save the parents in a lazy property because the parents are # always fetched anyways so it's fine to fetch them here. path = path.lstrip("/") path = path.rstrip("/") # Root page requested if not path: return cls.root() slugs = path.split('/') level = 1 parent = cls.root() for slug in slugs: if settings.URL_CASE_SENSITIVE: child = parent.get_children().select_related_common().get( slug=slug) child.cached_ancestors = parent.cached_ancestors + [parent] parent = child else: child = parent.get_children().select_related_common().get( slug__iexact=slug) child.cached_ancestors = parent.cached_ancestors + [parent] parent = child level += 1 return parent def get_absolute_url(self): return reverse('wiki:get', kwargs={'path': self.path}) @classmethod def create_root(cls, site=None, title="Root", request=None, **kwargs): if not site: site = Site.objects.get_current() root_nodes = cls.objects.root_nodes().filter(site=site) if not root_nodes: # (get_or_create does not work for MPTT models??) article = Article() revision = ArticleRevision(title=title, **kwargs) if request: revision.set_from_request(request) article.add_revision(revision, save=True) article.save() root = cls.objects.create(site=site, article=article) article.add_object_relation(root) else: root = root_nodes[0] return root @classmethod @atomic @transaction_commit_on_success def create_article( cls, parent, slug, site=None, title="Root", article_kwargs={}, **kwargs): """Utility function: Create a new urlpath with an article and a new revision for the article""" if not site: site = Site.objects.get_current() article = Article(**article_kwargs) article.add_revision(ArticleRevision(title=title, **kwargs), save=True) article.save() newpath = cls.objects.create( site=site, parent=parent, slug=slug, article=article) article.add_object_relation(newpath) return newpath
class URLPath(MPTTModel): """ Strategy: Very few fields go here, as most has to be managed through an article's revision. As a side-effect, the URL resolution remains slim and swift. """ # Tells django-wiki that permissions from a this object's article # should be inherited to children's articles. In this case, it's a static # property.. but you can also use a BooleanField. INHERIT_PERMISSIONS = True objects = managers.URLPathManager() # Do not use this because of # https://github.com/django-mptt/django-mptt/issues/369 # _default_manager = objects articles = GenericRelation( ArticleForObject, content_type_field='content_type', object_id_field='object_id', ) # Do NOT modify this field - it is updated with signals whenever # ArticleForObject is changed. article = models.ForeignKey( Article, on_delete=models.CASCADE, verbose_name=_('article'), help_text=_( "This field is automatically updated, but you need to populate " "it when creating a new URL path." ) ) SLUG_MAX_LENGTH = 50 slug = models.SlugField(verbose_name=_('slug'), null=True, blank=True, max_length=SLUG_MAX_LENGTH) site = models.ForeignKey(Site) parent = TreeForeignKey( 'self', null=True, blank=True, related_name='children', help_text=_("Position of URL path in the tree.") ) moved_to = TreeForeignKey( 'self', verbose_name=_("Moved to"), help_text=_("Article path was moved to this location"), null=True, blank=True, on_delete=models.SET_NULL, related_name='moved_from' ) def __init__(self, *args, **kwargs): pass # Fixed in django-mptt 0.5.3 # self._tree_manager = URLPath.objects return super(URLPath, self).__init__(*args, **kwargs) def __cached_ancestors(self): """ This returns the ancestors of this urlpath. These ancestors are hopefully cached from the article path lookup. Accessing a foreign key included in add_selecte_related on one of these ancestors will not occur an additional sql query, as they were retrieved with a select_related. If the cached ancestors were not set explicitly, they will be retrieved from the database. """ # "not self.pk": HACK needed till PR#591 is included in all supported django-mptt # versions. Prevent accessing a deleted URLPath when deleting it from the admin # interface. if not self.pk or not self.get_ancestors().exists(): self._cached_ancestors = [] if not hasattr(self, "_cached_ancestors"): self._cached_ancestors = list( self.get_ancestors().select_related_common()) return self._cached_ancestors def __cached_ancestors_setter(self, ancestors): self._cached_ancestors = ancestors # Python 2.5 compatible property constructor cached_ancestors = property(__cached_ancestors, __cached_ancestors_setter) def set_cached_ancestors_from_parent(self, parent): self.cached_ancestors = parent.cached_ancestors + [parent] @property def path(self): if not self.parent: return "" # All ancestors except roots ancestors = list( filter( lambda ancestor: ancestor.parent is not None, self.cached_ancestors ) ) slugs = [obj.slug if obj.slug else "" for obj in ancestors + [self]] return "/".join(slugs) + "/" def is_deleted(self): """ Returns True if this article or any of its ancestors have been deleted """ return self.first_deleted_ancestor() is not None def first_deleted_ancestor(self): for ancestor in self.cached_ancestors + [self]: if ancestor.article.current_revision.deleted: return ancestor return None @atomic @transaction_commit_on_success def _delete_subtree(self): for descendant in self.get_descendants( include_self=True).order_by("-level"): descendant.article.delete() def delete_subtree(self): """ NB! This deletes this urlpath, its children, and ALL of the related articles. This is a purged delete and CANNOT be undone. """ try: self._delete_subtree() except: # Not sure why any exception is getting caught here? Have we had # unresolved database integrity errors? log.exception("Exception deleting article subtree.") @classmethod def root(cls): site = Site.objects.get_current() root_nodes = list( cls.objects.root_nodes().filter(site=site).select_related_common() ) # We fetch the nodes as a list and use len(), not count() because we need # to get the result out anyway. This only takes one sql query no_paths = len(root_nodes) if no_paths == 0: raise NoRootURL( "You need to create a root article on site '%s'" % site) if no_paths > 1: raise MultipleRootURLs( "Somehow you have multiple roots on %s" % site) return root_nodes[0] class MPTTMeta: pass def __str__(self): path = self.path return path if path else ugettext("(root)") def delete(self, *args, **kwargs): assert not (self.parent and self.get_children() ), "You cannot delete a root article with children." super(URLPath, self).delete(*args, **kwargs) class Meta: verbose_name = _('URL path') verbose_name_plural = _('URL paths') unique_together = ('site', 'parent', 'slug') def clean(self, *args, **kwargs): if self.slug and not self.parent: raise ValidationError( _('Sorry but you cannot have a root article with a slug.')) if not self.slug and self.parent: raise ValidationError( _('A non-root note must always have a slug.')) if not self.parent: if URLPath.objects.root_nodes().filter( site=self.site).exclude( id=self.id): raise ValidationError( _('There is already a root node on %s') % self.site) super(URLPath, self).clean(*args, **kwargs) @classmethod def get_by_path(cls, path, select_related=False): """ Strategy: Don't handle all kinds of weird cases. Be strict. Accepts paths both starting with and without '/' """ # TODO: Save paths directly in the model for constant time lookups? # Or: Save the parents in a lazy property because the parents are # always fetched anyways so it's fine to fetch them here. path = path.lstrip("/") path = path.rstrip("/") # Root page requested if not path: return cls.root() slugs = path.split('/') level = 1 parent = cls.root() for slug in slugs: if settings.URL_CASE_SENSITIVE: child = parent.get_children().select_related_common().get( slug=slug) child.cached_ancestors = parent.cached_ancestors + [parent] parent = child else: child = parent.get_children().select_related_common().get( slug__iexact=slug) child.cached_ancestors = parent.cached_ancestors + [parent] parent = child level += 1 return parent def get_absolute_url(self): return reverse('wiki:get', kwargs={'path': self.path}) @classmethod def create_root(cls, site=None, title="Root", request=None, **kwargs): if not site: site = Site.objects.get_current() root_nodes = cls.objects.root_nodes().filter(site=site) if not root_nodes: # (get_or_create does not work for MPTT models??) article = Article() revision = ArticleRevision(title=title, **kwargs) if request: revision.set_from_request(request) article.add_revision(revision, save=True) article.save() root = cls.objects.create(site=site, article=article) article.add_object_relation(root) else: root = root_nodes[0] return root @classmethod @atomic @transaction_commit_on_success def create_urlpath( cls, parent, slug, site=None, title="Root", article_kwargs={}, request=None, article_w_permissions=None, **revision_kwargs): """ Utility function: Creates a new urlpath with an article and a new revision for the article :returns: A new URLPath instance """ if not site: site = Site.objects.get_current() article = Article(**article_kwargs) article.add_revision(ArticleRevision(title=title, **revision_kwargs), save=True) article.save() newpath = cls.objects.create( site=site, parent=parent, slug=slug, article=article) article.add_object_relation(newpath) return newpath @classmethod def _create_urlpath_from_request( cls, request, perm_article, parent_urlpath, slug, title, content, summary): """ Creates a new URLPath, using meta data from ``request`` and copies in the permissions from ``perm_article``. This interface is internal because it's rather sloppy """ user = None ip_address = None if not request.user.is_anonymous(): user = request.user if settings.LOG_IPS_USERS: ip_address = request.META.get('REMOTE_ADDR', None) elif settings.LOG_IPS_ANONYMOUS: ip_address = request.META.get('REMOTE_ADDR', None) return cls.create_urlpath( parent_urlpath, slug, title=title, content=content, user_message=summary, user=user, ip_address=ip_address, article_kwargs={'owner': user, 'group': perm_article.group, 'group_read': perm_article.group_read, 'group_write': perm_article.group_write, 'other_read': perm_article.other_read, 'other_write': perm_article.other_write} ) @classmethod def create_article(cls, *args, **kwargs): warnings.warn("Pending removal: URLPath.create_article renamed to create_urlpath", DeprecationWarning) return cls.create_urlpath(*args, **kwargs) def get_ordered_children(self): """Return an ordered list of all chilren""" return self.children.order_by('slug')