forms.py 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. import re
  2. from django import forms
  3. from django.core.validators import RegexValidator
  4. from django.db import models
  5. from django.utils.html import conditional_escape, mark_safe
  6. from django.utils.translation import gettext_lazy as _
  7. from mptt.forms import TreeNodeChoiceField, TreeNodeMultipleChoiceField
  8. from ...admin.forms import YesNoSwitch
  9. from ...core.validators import validate_sluggable
  10. from ...threads.threadtypes import trees_map
  11. from .. import THREADS_ROOT_NAME
  12. from ..models import Category, CategoryRole
  13. class AdminCategoryFieldMixin:
  14. def __init__(self, *args, **kwargs):
  15. self.base_level = kwargs.pop("base_level", 1)
  16. kwargs["level_indicator"] = kwargs.get("level_indicator", "- - ")
  17. threads_tree_id = trees_map.get_tree_id_for_root(THREADS_ROOT_NAME)
  18. queryset = Category.objects.filter(tree_id=threads_tree_id)
  19. if not kwargs.pop("include_root", False):
  20. queryset = queryset.exclude(special_role="root_category")
  21. kwargs.setdefault("queryset", queryset)
  22. super().__init__(*args, **kwargs)
  23. def _get_level_indicator(self, obj):
  24. level = getattr(obj, obj._mptt_meta.level_attr) - self.base_level
  25. if level > 0:
  26. return mark_safe(conditional_escape(self.level_indicator) * level)
  27. return ""
  28. class AdminCategoryChoiceField(AdminCategoryFieldMixin, TreeNodeChoiceField):
  29. pass
  30. class AdminCategoryMultipleChoiceField(
  31. AdminCategoryFieldMixin, TreeNodeMultipleChoiceField
  32. ):
  33. pass
  34. class CategoryFormBase(forms.ModelForm):
  35. name = forms.CharField(label=_("Name"), validators=[validate_sluggable()])
  36. short_name = forms.CharField(
  37. label=_("Short name"),
  38. required=False,
  39. help_text=_(
  40. "Optional, alternative or abbreviated name (eg. abbreviation) used on threads list."
  41. ),
  42. validators=[validate_sluggable()],
  43. )
  44. color = forms.CharField(
  45. label=_("Color"),
  46. required=False,
  47. help_text=_("Optional but recommended, should be in hex format, eg. #F5A9B8."),
  48. validators=[
  49. RegexValidator(
  50. r"^#[0-9a-f][0-9a-f][0-9a-f]([0-9a-f][0-9a-f][0-9a-f]?)$",
  51. flags=re.MULTILINE | re.IGNORECASE,
  52. message=_("Entered value is not a valid color."),
  53. ),
  54. ],
  55. )
  56. description = forms.CharField(
  57. label=_("Description"),
  58. max_length=2048,
  59. required=False,
  60. widget=forms.Textarea(attrs={"rows": 3}),
  61. help_text=_("Optional description explaining category intented purpose."),
  62. )
  63. css_class = forms.CharField(
  64. label=_("CSS class"),
  65. required=False,
  66. help_text=_(
  67. "Optional CSS class used to customize this "
  68. "category's appearance from templates."
  69. ),
  70. )
  71. is_closed = YesNoSwitch(
  72. label=_("Closed category"),
  73. required=False,
  74. help_text=_(
  75. "Only members with valid permissions can post in closed categories."
  76. ),
  77. )
  78. require_threads_approval = YesNoSwitch(
  79. label=_("Threads"),
  80. required=False,
  81. help_text=_(
  82. "All threads started in this category will require moderator approval."
  83. ),
  84. )
  85. require_replies_approval = YesNoSwitch(
  86. label=_("Replies"),
  87. required=False,
  88. help_text=_(
  89. "All replies posted in this category will require moderator approval."
  90. ),
  91. )
  92. require_edits_approval = YesNoSwitch(
  93. label=_("Edits"),
  94. required=False,
  95. help_text=_(
  96. "Will make all edited replies return to unapproved state "
  97. "for moderator to review."
  98. ),
  99. )
  100. prune_started_after = forms.IntegerField(
  101. label=_("Thread age"),
  102. min_value=0,
  103. help_text=_(
  104. "Prune thread if number of days since its creation is greater than "
  105. "specified. Enter 0 to disable this pruning criteria."
  106. ),
  107. )
  108. prune_replied_after = forms.IntegerField(
  109. label=_("Last reply"),
  110. min_value=0,
  111. help_text=_(
  112. "Prune thread if number of days since last reply is greater than "
  113. "specified. Enter 0 to disable this pruning criteria."
  114. ),
  115. )
  116. class Meta:
  117. model = Category
  118. fields = [
  119. "name",
  120. "short_name",
  121. "color",
  122. "description",
  123. "css_class",
  124. "is_closed",
  125. "require_threads_approval",
  126. "require_replies_approval",
  127. "require_edits_approval",
  128. "prune_started_after",
  129. "prune_replied_after",
  130. "archive_pruned_in",
  131. ]
  132. def clean_copy_permissions(self):
  133. data = self.cleaned_data["copy_permissions"]
  134. if data and data.pk == self.instance.pk:
  135. message = _("Permissions cannot be copied from category into itself.")
  136. raise forms.ValidationError(message)
  137. return data
  138. def clean_archive_pruned_in(self):
  139. data = self.cleaned_data["archive_pruned_in"]
  140. if data and data.pk == self.instance.pk:
  141. message = _("Category cannot act as archive for itself.")
  142. raise forms.ValidationError(message)
  143. return data
  144. def clean(self):
  145. data = super().clean()
  146. self.instance.set_name(data.get("name"))
  147. return data
  148. def CategoryFormFactory(instance):
  149. parent_queryset = Category.objects.all_categories(True).order_by("lft")
  150. if instance.pk:
  151. not_siblings = models.Q(lft__lt=instance.lft)
  152. not_siblings = not_siblings | models.Q(rght__gt=instance.rght)
  153. parent_queryset = parent_queryset.filter(not_siblings)
  154. return type(
  155. "CategoryFormFinal",
  156. (CategoryFormBase,),
  157. {
  158. "new_parent": AdminCategoryChoiceField(
  159. label=_("Parent category"),
  160. queryset=parent_queryset,
  161. initial=instance.parent,
  162. empty_label=None,
  163. ),
  164. "copy_permissions": AdminCategoryChoiceField(
  165. label=_("Copy permissions"),
  166. help_text=_(
  167. "You can replace this category permissions with "
  168. "permissions copied from category selected here."
  169. ),
  170. queryset=Category.objects.all_categories(),
  171. empty_label=_("Don't copy permissions"),
  172. required=False,
  173. ),
  174. "archive_pruned_in": AdminCategoryChoiceField(
  175. label=_("Archive"),
  176. help_text=_(
  177. "Instead of being deleted, pruned threads can be "
  178. "moved to designated category."
  179. ),
  180. queryset=Category.objects.all_categories(),
  181. empty_label=_("Don't archive pruned threads"),
  182. required=False,
  183. ),
  184. },
  185. )
  186. class DeleteCategoryFormBase(forms.ModelForm):
  187. class Meta:
  188. model = Category
  189. fields = []
  190. def clean(self):
  191. data = super().clean()
  192. if data.get("move_threads_to"):
  193. if data["move_threads_to"].pk == self.instance.pk:
  194. message = _("You are trying to move this category threads to itself.")
  195. raise forms.ValidationError(message)
  196. moving_to_child = self.instance.has_child(data["move_threads_to"])
  197. if moving_to_child and not data.get("move_children_to"):
  198. message = _(
  199. "You are trying to move this category threads to a "
  200. "child category that will be deleted together with "
  201. "this category."
  202. )
  203. raise forms.ValidationError(message)
  204. return data
  205. def DeleteFormFactory(instance):
  206. content_queryset = Category.objects.all_categories().order_by("lft")
  207. fields = {
  208. "move_threads_to": AdminCategoryChoiceField(
  209. label=_("Move category threads to"),
  210. queryset=content_queryset,
  211. initial=instance.parent,
  212. empty_label=_("Delete with category"),
  213. required=False,
  214. )
  215. }
  216. not_siblings = models.Q(lft__lt=instance.lft)
  217. not_siblings = not_siblings | models.Q(rght__gt=instance.rght)
  218. children_queryset = Category.objects.all_categories(True)
  219. children_queryset = children_queryset.filter(not_siblings).order_by("lft")
  220. if children_queryset.exists():
  221. fields["move_children_to"] = AdminCategoryChoiceField(
  222. label=_("Move child categories to"),
  223. queryset=children_queryset,
  224. empty_label=_("Delete with category"),
  225. required=False,
  226. )
  227. return type("DeleteCategoryFormFinal", (DeleteCategoryFormBase,), fields)
  228. class CategoryRoleForm(forms.ModelForm):
  229. name = forms.CharField(label=_("Role name"))
  230. class Meta:
  231. model = CategoryRole
  232. fields = ["name"]
  233. def RoleCategoryACLFormFactory(category, category_roles, selected_role):
  234. attrs = {
  235. "category": category,
  236. "role": forms.ModelChoiceField(
  237. label=_("Role"),
  238. required=False,
  239. queryset=category_roles,
  240. initial=selected_role,
  241. empty_label=_("No access"),
  242. ),
  243. }
  244. return type("RoleCategoryACLForm", (forms.Form,), attrs)
  245. def CategoryRolesACLFormFactory(role, category_roles, selected_role):
  246. attrs = {
  247. "role": role,
  248. "category_role": forms.ModelChoiceField(
  249. label=_("Role"),
  250. required=False,
  251. queryset=category_roles,
  252. initial=selected_role,
  253. empty_label=_("No access"),
  254. ),
  255. }
  256. return type("CategoryRolesACLForm", (forms.Form,), attrs)