Source code for feincms3.admin
import json
from collections import defaultdict
from functools import update_wrapper
from django import forms
from django.contrib import messages
from django.contrib.admin import ModelAdmin, SimpleListFilter, display, helpers
from django.contrib.admin.options import IncorrectLookupParameters, csrf_protect_m
from django.contrib.admin.utils import unquote
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import router, transaction
from django.db.models import F
from django.http import HttpResponse
from django.shortcuts import redirect
from django.urls import path, re_path, reverse
from django.utils.html import format_html, mark_safe
from django.utils.text import capfirst
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_protect
from js_asset.js import JS
from tree_queries.forms import TreeNodeChoiceField
__all__ = (
"TreeAdmin",
"MoveForm",
"CloneForm",
"AncestorFilter",
"action_form_view_decorator",
)
def action_form_view_decorator(modeladmin):
def wrap(view):
def wrapper(request, object_id):
with transaction.atomic(using=router.db_for_write(modeladmin.model)):
model = modeladmin.model
opts = model._meta
obj = modeladmin.get_object(request, unquote(object_id))
if not modeladmin.has_change_permission(request, obj):
raise PermissionDenied
if obj is None:
return modeladmin._get_obj_does_not_exist_redirect(
request, opts, object_id
)
return modeladmin.admin_site.admin_view(view)(request, obj)
wrapper.model_admin = modeladmin
return csrf_protect(update_wrapper(wrapper, view))
return wrap
[docs]
class TreeAdmin(ModelAdmin):
"""
``ModelAdmin`` subclass for managing models using `django-tree-queries
<https://github.com/matthiask/django-tree-queries>`_ trees.
Shows the tree's hierarchy and adds a view to move nodes around. To use
this class the three columns ``collapse_column``, ``indented_title`` and
``move_column`` should be added to subclasses ``list_display``::
class NodeAdmin(TreeAdmin):
list_display = [*TreeAdmin.list_display, ...]
# This is the default:
# list_display_links = ["indented_title"]
admin.site.register(Node, NodeAdmin)
"""
list_display = ["collapse_column", "indented_title", "move_column"]
list_display_links = ["indented_title"]
[docs]
@csrf_protect_m
def changelist_view(self, request, **kwargs):
response = super().changelist_view(request, **kwargs)
if not hasattr(response, "context_data"):
return response
context = self.tree_admin_context(request)
response.context_data["media"] += forms.Media(
css={"all": ["content_editor/material-icons.css", "feincms3/admin.css"]},
js=[
JS(
"feincms3/admin.js",
{"id": "feincms3-context", "data-context": json.dumps(context)},
),
],
)
return response
def tree_admin_context(self, request):
return {
"initiallyCollapseDepth": 1,
}
@display(description="")
def collapse_column(self, instance):
return format_html(
'<div class="collapse-toggle collapse-hide" data-pk="{}" data-tree-depth="{}"></div>',
instance.pk,
instance.tree_depth,
)
[docs]
def indented_title(self, instance, *, ellipsize=True):
"""
Use Unicode box-drawing characters to visualize the tree hierarchy.
"""
box_drawing = []
for _i in range(instance.tree_depth - 1):
box_drawing.append('<i class="l"></i>')
if instance.tree_depth > 0:
box_drawing.append('<i class="a"></i>')
return format_html(
'<div class="box">'
'<div class="box-drawing">{}</div>'
'<div class="box-text{}" style="text-indent:{}px">{}</div>'
"</div>",
mark_safe("".join(box_drawing)),
" ellipsize" if ellipsize else "",
instance.tree_depth * 30,
instance,
)
indented_title.short_description = _("title")
[docs]
def move_column(self, instance):
"""
Show a ``move`` link which leads to a separate page where the move
destination may be selected.
"""
return format_html(
"""\
<div class="move-controls">
<button class="move-cut" type="button" data-pk="{}" title="{}">
<span class="material-icons">content_cut</span>
</button>
<select class="move-paste" data-pk="{}" title="{}">
<option value="">---</option>
<option value="before">{}</option> -->
<option value="first-child">{}</option>
<option value="last-child">{}</option>
<option value="after">{}</option>
</select>
</div>
""",
instance.pk,
_("Move '{}' to a new location").format(instance),
instance.pk,
_("Choose new location"),
_("before"),
_("as first child"),
_("as last child"),
_("after"),
)
opts = self.model._meta
return format_html(
'<a href="{}">{}</a>',
reverse(
f"admin:{opts.app_label}_{opts.model_name}_move",
args=(instance.pk,),
),
_("move"),
)
move_column.short_description = _("move")
[docs]
def get_urls(self):
"""
Add our own ``move`` view.
"""
info = self.model._meta.app_label, self.model._meta.model_name
return [
path(
"move-node/",
self.admin_site.admin_view(self.move_node_view),
),
re_path(
r"^(.+)/move/$",
action_form_view_decorator(self)(self.move_view),
name="{}_{}_move".format(*info),
),
re_path(
r"^(.+)/clone/$",
action_form_view_decorator(self)(self.clone_view),
name="{}_{}_clone".format(*info),
),
] + super().get_urls()
def move_node_view(self, request):
kw = {"request": request, "modeladmin": self}
form = MoveNodeForm(request.POST, **kw)
return HttpResponse(form.process())
def move_view(self, request, obj):
return self.action_form_view(
request, obj, form_class=MoveForm, title=_("Move %s") % obj
)
def clone_view(self, request, obj):
return self.action_form_view(
request, obj, form_class=CloneForm, title=_("Clone %s") % obj
)
def action_form_view(self, request, obj, *, form_class, title):
kw = {"request": request, "obj": obj, "modeladmin": self}
form = form_class(request.POST if request.method == "POST" else None, **kw)
if form.is_valid():
return form.process()
return self.render_action_form(request, form, title=title, obj=obj)
def render_action_form(self, request, form, *, title, obj):
adminform = helpers.AdminForm(
form,
[
(None, {"fields": form.fields.keys()})
], # list(self.get_fieldsets(request, obj)),
{}, # self.get_prepopulated_fields(request, obj),
(), # self.get_readonly_fields(request, obj),
model_admin=self,
)
media = self.media + adminform.media
context = dict(
self.admin_site.each_context(request),
title=title,
object_id=obj.pk,
original=obj,
adminform=adminform,
errors=helpers.AdminErrorList(form, ()),
preserved_filters=self.get_preserved_filters(request),
media=media,
is_popup=False,
inline_admin_formsets=[],
save_as_new=False,
show_save_and_add_another=False,
show_save_and_continue=False,
show_delete=False,
)
response = self.render_change_form(
request, context, add=False, change=True, obj=obj
)
# Suppress the rendering of the "save and add another" button.
response.context_data["has_add_permission"] = False
return response
class MoveNodeForm(forms.Form):
def __init__(self, *args, **kwargs):
self.modeladmin = kwargs.pop("modeladmin")
self.request = kwargs.pop("request")
super().__init__(*args, **kwargs)
self.fields["move"] = forms.ModelChoiceField(
queryset=self.modeladmin.get_queryset(self.request)
)
self.fields["relative_to"] = forms.ModelChoiceField(
queryset=self.modeladmin.get_queryset(self.request)
)
positions = ("before", "first-child", "last-child", "after")
self.fields["position"] = forms.ChoiceField(choices=zip(positions, positions))
def process(self):
if not self.is_valid():
messages.error(self.request, _("Invalid node move request."))
messages.error(self.request, str(self.errors))
return "error"
move = self.cleaned_data["move"]
relative_to = self.cleaned_data["relative_to"]
position = self.cleaned_data["position"]
print(self.cleaned_data)
if position in {"first-child", "last-child"}:
move._set_parent(relative_to)
siblings_qs = relative_to.children
else:
move._set_parent(relative_to.parent)
siblings_qs = relative_to.__class__._default_manager.filter(
parent=relative_to.parent
)
try:
# All fields of model are not in this form
move.full_clean(exclude=[f.name for f in move._meta.get_fields()])
except ValidationError as exc:
messages.error(
self.request,
_("Error while validating the new position of '{}'.").format(move),
)
messages.error(self.request, str(exc))
return "error"
if position == "before":
siblings_qs.filter(position__gte=relative_to.position).update(
position=F("position") + 10
)
move.position = relative_to.position
move.save()
elif position == "after":
siblings_qs.filter(position__gt=relative_to.position).update(
position=F("position") + 10
)
move.position = relative_to.position + 10
move.save()
elif position == "first-child":
siblings_qs.update(position=F("position") + 10)
move.position = 10
move.save()
elif position == "last-child":
move.position = 0 # Let AbstractPage.save handle the position
move.save()
else: # pragma: no cover
pass
messages.success(
self.request,
_("Node '{}' has been moved to its new position.").format(move),
)
return "ok"
[docs]
class MoveForm(forms.Form):
"""
Allows making the node the left or right sibling or the first or last
child of another node.
Requires the node to be moved as ``obj`` keyword argument.
"""
class Media:
css = {"screen": ["feincms3/move-form.css"]}
def __init__(self, *args, **kwargs):
self.instance = kwargs.pop("obj")
self.modeladmin = kwargs.pop("modeladmin")
self.request = kwargs.pop("request")
self.model = self.instance.__class__
super().__init__(*args, **kwargs)
choices = self._generate_choices(
self.modeladmin.get_queryset(self.request).with_tree_fields()
)
self.fields["new_location"] = forms.ChoiceField(
label=_("New location"),
widget=forms.RadioSelect,
choices=choices,
)
if len(choices) <= 1:
messages.warning(
self.request,
_(
"Moving isn't possible because there are no valid targets."
" Maybe you selected the only root node?"
),
)
def _generate_choices(self, queryset):
children = defaultdict(list)
for node in queryset:
children[node.parent_id].append(node)
def _text_indent(depth):
return mark_safe(f' style="text-indent:{depth * 30}px"')
choices = []
def _iterate(parent_id):
for index, node in enumerate(children[parent_id]):
if node == self.instance:
choice = (
"",
format_html(
'<div class="mv is-self"{}><strong>{}</strong>',
_text_indent(node.tree_depth),
node,
),
)
if index == 0 and parent_id:
# Moving the first child of parent_id; do not remove parent_id
choices[-1] = (
choices[-1][0],
mark_safe(choices[-1][1].replace("mv-mark", "hidden")),
)
choices.append(choice)
else:
choices[-1] = choice
continue
choices.append(
(
f"{node.id}:first",
format_html(
'<div class="mv to-first"{}><strong>{}</strong>'
'<div class="mv-mark"{}>→ {}</div></div>',
_text_indent(node.tree_depth),
node,
_text_indent(node.tree_depth + 1),
_("move here"),
),
)
)
_iterate(node.id)
choices.append(
(
f"{node.id}:right",
format_html(
'<div class="mv to-right mv-mark"{}>→ {}</div>',
_text_indent(node.tree_depth),
_("move here"),
),
)
)
choices.append(
(
"0:first",
format_html(
'<div class="mv to-root mv-mark">→ {}</div>',
_("move here"),
),
)
)
_iterate(None)
return choices
[docs]
def clean(self):
data = super().clean()
if not data.get("new_location"):
return data
pk, _sep, first_or_right = data["new_location"].partition(":")
data["first_or_right"] = first_or_right
if pk == "0":
self.instance.parent = None
data["relative"] = None
else:
data["relative"] = self.instance.__class__._base_manager.get(pk=pk)
if first_or_right == "first":
self.instance.parent = data["relative"]
else:
self.instance.parent = data["relative"].parent
# FIXME feincms3-sites would also require site_id,
# feincms3-language-sites would also require language_code to be set
# for the cleaning step to work correctly in all cases.
# All fields of model are not in this form
self.instance.full_clean(
exclude=[f.name for f in self.model._meta.get_fields()]
)
return data
def process(self):
siblings = list(
self.model._default_manager.filter(parent=self.instance.parent).exclude(
pk=self.instance.pk
)
)
relative = self.cleaned_data["relative"]
first_or_right = self.cleaned_data["first_or_right"]
if relative is None or first_or_right == "first":
siblings.insert(0, self.instance)
else:
siblings.insert(siblings.index(relative) + 1, self.instance)
for index, instance in enumerate(siblings):
if instance == self.instance:
instance.position = (index + 1) * 10
instance.save()
else:
self.model._default_manager.filter(pk=instance.pk).update(
position=(index + 1) * 10
)
self.modeladmin.message_user(
self.request,
_("The node %(node)s has been moved to the new position.")
% {"node": self.instance},
)
opts = self.modeladmin.model._meta
return redirect(f"admin:{opts.app_label}_{opts.model_name}_changelist")
[docs]
class CloneForm(forms.Form):
def __init__(self, *args, **kwargs):
self.instance = kwargs.pop("obj")
self.modeladmin = kwargs.pop("modeladmin")
self.request = kwargs.pop("request")
super().__init__(*args, **kwargs)
self.fields["target"] = self.instance._meta.get_field("parent").formfield(
form_class=TreeNodeChoiceField,
label=capfirst(_("target")),
required=True,
label_from_instance=lambda obj: "{}{}".format(
"".join(["*** " if obj == self.instance else "--- "] * obj.tree_depth),
obj,
),
)
self.fields["target"].widget.attrs.update({"size": 30, "style": "height:auto"})
self.fields["_set_content"] = forms.BooleanField(
label=_("Replace target's content"),
required=False,
help_text=_("Affects the following models: %s.")
% (
", ".join(
"%s" % inline.model._meta.verbose_name_plural
for inline in self.modeladmin.inlines
),
),
)
for field in sorted(
self.instance._meta.get_fields(), key=lambda field: field.name
):
if field.auto_created or not field.editable:
continue
self.fields[f"set_{field.name}"] = forms.BooleanField(
label=(
f"{capfirst(field.verbose_name)} ({field.name})"
if hasattr(field, "verbose_name")
else field.name
),
required=False,
help_text=_('Current: "%s"') % (getattr(self.instance, field.name),),
)
[docs]
def clean(self):
data = super().clean()
target = data.get("target")
if target is None:
return data
if target == self.instance:
raise forms.ValidationError({"target": _("Cannot clone node to itself.")})
for field in self.instance._meta.get_fields():
if self.cleaned_data.get(f"set_{field.name}"):
setattr(target, field.name, getattr(self.instance, field.name))
# All fields of model are not in this form
target.full_clean(exclude=[f.name for f in target._meta.get_fields()])
return data
def process(self):
target = self.cleaned_data["target"]
fields = []
for field in self.instance._meta.get_fields():
if self.cleaned_data.get(f"set_{field.name}"):
setattr(target, field.name, getattr(self.instance, field.name))
fields.append("{}".format(getattr(field, "verbose_name", field.name)))
if fields:
self.modeladmin.message_user(
self.request,
_("Updated fields of %(node)s: %(fields)s")
% {"node": target, "fields": ", ".join(fields)},
)
if self.cleaned_data.get("_set_content"):
from django.forms.models import _get_foreign_key # Since 2009.
for inline in self.modeladmin.inlines:
fk = _get_foreign_key(
self.modeladmin.model, inline.model, inline.fk_name, can_fail=False
)
# Remove all existing instances
inline.model._default_manager.filter(**{fk.name: target}).delete()
for obj in inline.model._default_manager.filter(
**{fk.name: self.instance}
):
obj.pk = None
setattr(obj, fk.name, target)
obj.save(force_insert=True)
self.modeladmin.message_user(
self.request,
_("Replaced the content of %(target)s with the contents of %(source)s.")
% {"target": target, "source": self.instance},
)
target.save()
opts = self.modeladmin.model._meta
return redirect(f"admin:{opts.app_label}_{opts.model_name}_change", target.pk)
[docs]
class AncestorFilter(SimpleListFilter):
"""
Only show the subtree of an ancestor
By default, the first two levels are shown in the ``list_filter`` sidebar.
This can be changed by setting the ``max_depth`` class attribute to a
different value.
Usage::
class NodeAdmin(TreeAdmin):
list_display = ("indented_title", "move_column", ...)
list_filter = ("is_active", AncestorFilter, ...)
admin.site.register(Node, NodeAdmin)
"""
title = _("subtree")
parameter_name = "ancestor"
max_depth = 1
def indent(self, depth):
return mark_safe("├" * depth)
[docs]
def lookups(self, request, model_admin):
return [
(node.id, format_html("{} {}", self.indent(node.tree_depth), node))
for node in model_admin.model._default_manager.with_tree_fields().extra(
where=["tree_depth <= %s" % self.max_depth]
)
]
[docs]
def queryset(self, request, queryset):
if self.value():
try:
node = queryset.model._default_manager.get(pk=self.value())
except (TypeError, ValueError, queryset.model.DoesNotExist) as exc:
raise IncorrectLookupParameters() from exc
return queryset.descendants(node, include_self=True)
return queryset