Source code for lotus.forms.category
from django import forms
from django.conf import settings
from django.db import models
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from treebeard.forms import MoveNodeForm, movenodeform_factory
from ..models import Category
from ..formfields import TranslatedModelChoiceField
# Use the right field depending 'ckeditor_uploader' app is enabled or not
if "ckeditor_uploader" in settings.INSTALLED_APPS:
from ckeditor_uploader.widgets import CKEditorUploadingWidget as CKEditorWidget
else:
from ckeditor.widgets import CKEditorWidget
# Use the specific CKEditor config if defined
CONFIG_NAME = "lotus"
CKEDITOR_CONFIG = getattr(settings, "CKEDITOR_CONFIGS", {})
if CONFIG_NAME not in CKEDITOR_CONFIG:
CONFIG_NAME = "default"
class CategoryNodeAbstractForm(MoveNodeForm):
"""
Abstract node form for Category model.
This abstract only cares about the form stuff related to treebeard, everything else
will live in the concrete form class.
According to treebeard documentation it is recommended to not directly inherit from
MoveNodeForm and prefer to use ``movenodeform_factory`` to build the proper form
class to extend.
"""
PARENT_EMPTY_LABEL = _("-- Root --")
@staticmethod
def mk_indent(level):
if level == 1:
return ""
if level == 2:
return "└── "
return (" " * (level - 1)) + "└── "
@classmethod
def add_subtree(cls, for_node, node, options, excluded=None):
"""
Build options tree with categories that are not excluded.
TODO: Right tree order should be applied on queryset
"""
excluded = excluded or []
if cls.is_loop_safe(for_node, node):
for item, data in node.get_annotated_list(node):
# Ignore excluded items
if item.pk in excluded:
continue
name = "{indent}{name} [{lang}]".format(
indent=cls.mk_indent(item.get_depth()),
name=escape(item),
lang=item.get_language_display(),
)
options.append(
(
item.pk,
mark_safe(name),
)
)
@classmethod
def mk_dropdown_tree(cls, model, for_node=None):
"""
Build the choice list for available Category nodes.
The currently edited Category, if any, will be excluded along its descendants.
TODO: Right tree order should be applied on queryset
"""
descendants = []
if for_node:
descendants = list(
for_node.get_descendants().values_list("id", flat=True)
) + [for_node.pk]
options = [(None, cls.PARENT_EMPTY_LABEL)]
for node in model.get_root_nodes():
cls.add_subtree(for_node, node, options, excluded=descendants)
return options
CategoryNodeForm = movenodeform_factory(Category, form=CategoryNodeAbstractForm)
"""
Build the Form class with proper Metas and stuff calibrated by treebeard, this the one
to inherit and extend.
"""
[docs]
class CategoryAdminForm(CategoryNodeForm):
"""
Category form for admin.
.. Note::
Form is customized to manage treebeard fields for our constraint requirements.
"""
def __init__(self, *args, **kwargs):
if "initial" not in kwargs:
kwargs["initial"] = {}
# We only support the 'sorted child' appending. So here we force its initial
# field value and finally the field won't be displayed so it will always use
# this value
kwargs["initial"].update({"_position": "sorted-child"})
super().__init__(*args, **kwargs)
# Rename treebeard "node id" attribute label for something more comprehensive
self.fields["_ref_node_id"].label = _("Parent")
# Make position hidden since we only support the sorted child appending
self.fields["_position"].widget = forms.HiddenInput()
# Apply choice limit on 'original' field queryset to avoid selecting
# itself or object with the same language
self.fields["original"] = TranslatedModelChoiceField(
queryset=self.get_original_relation_queryset(),
required=False,
blank=True,
)
[docs]
def get_original_relation_queryset(self):
"""
Get available categories queryset for original field selection.
Returns:
Queryset: Queryset of available category. Basically only the original
categories are available. And in addition when form is in edition mode,
queryset is filtered with some constraints to avoid selecting the category
itself, a translation or object with the same language.
"""
base_queryset = Category.objects.filter(
original__isnull=True
).order_by(*Category.COMMON_ORDER_BY)
# Model choice querysets for creation form get all objects since there is no
# data yet to constraint
if not self.instance.pk:
return base_queryset
return base_queryset.exclude(
models.Q(id=self.instance.id) |
models.Q(language=self.instance.language)
)
[docs]
def clean(self):
"""
Add custom global input cleaner validations.
WARNING: It seems it is possible to define an "original" category even if the
current category itself has translations which should make it as the original
for these translations, so the current category could not be a translation.
This is the same behavior with articles. Issue #79.
"""
cleaned_data = super().clean()
language = cleaned_data.get("language")
original = cleaned_data.get("original")
ref_node_id = cleaned_data.get("_ref_node_id") or None
# Cast possible selected parend node choice as a Category object
parent = None
if ref_node_id:
parent = Category.objects.get(pk=int(ref_node_id))
if parent:
# Parent must have the same language than current category
if parent.language != language:
self.add_error(
"_ref_node_id",
forms.ValidationError(
_(
"You can't have a parent category with a different "
"language."
),
code="invalid",
),
)
self.add_error(
"language",
forms.ValidationError(
_(
"You can't have a language different from the parent "
"category one."
),
code="invalid",
),
)
# Original must not be in the same language than current category
if original and original.language == language:
self.add_error(
"language",
forms.ValidationError(
_(
"You can't have a language identical to the original "
"relation."
),
code="invalid",
),
)
self.add_error(
"original",
forms.ValidationError(
_(
"You can't have an original relation in the same language."
),
code="invalid",
),
)
# For edition mode
if self.instance.pk:
# Check if an article has a translation, in this case it can not
# select an original object since it is already an original.
if original and Category.objects.filter(original_id=self.instance.pk):
self.add_error(
"original",
forms.ValidationError(
_(
"This category already have a translation so it can not be "
"a translation itself."
),
code="invalid-original",
),
)
# Block save if language has been changed to another but the category still
# have articles in previous language
if self.instance.articles.exclude(language=language).count() > 0:
self.add_error(
"language",
forms.ValidationError(
_(
"Some articles in different language relate to this "
"category, you can't change language until those articles "
"are not related anymore."
),
code="invalid-language",
),
)
# Current category can not change language if it have descendants
# since they are in different language.
if self.instance.get_descendants().exclude(language=language).count() > 0:
self.add_error(
"language",
forms.ValidationError(
_(
"Some categories in different language are children of "
"this category, you can't change language until those "
"categories are not related anymore or adopted the same "
"language."
),
code="invalid-language",
),
)
class Meta:
model = Category
widgets = {
"description": CKEditorWidget(config_name=CONFIG_NAME),
}
fields = "__all__"