django-unfold 0.25.0__py3-none-any.whl → 0.27.0__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
Files changed (45) hide show
  1. {django_unfold-0.25.0.dist-info → django_unfold-0.27.0.dist-info}/METADATA +61 -7
  2. {django_unfold-0.25.0.dist-info → django_unfold-0.27.0.dist-info}/RECORD +44 -31
  3. unfold/admin.py +13 -157
  4. unfold/contrib/forms/templates/unfold/forms/array.html +31 -0
  5. unfold/contrib/forms/widgets.py +58 -3
  6. unfold/contrib/import_export/forms.py +17 -0
  7. unfold/contrib/import_export/templates/admin/import_export/change_form.html +10 -0
  8. unfold/contrib/import_export/templates/admin/import_export/export.html +38 -3
  9. unfold/contrib/import_export/templates/admin/import_export/import_form.html +8 -12
  10. unfold/contrib/import_export/templates/admin/import_export/resource_fields_list.html +1 -1
  11. unfold/contrib/inlines/__init__.py +0 -0
  12. unfold/contrib/inlines/admin.py +141 -0
  13. unfold/contrib/inlines/apps.py +6 -0
  14. unfold/contrib/inlines/checks.py +18 -0
  15. unfold/contrib/inlines/forms.py +43 -0
  16. unfold/decorators.py +3 -0
  17. unfold/fields.py +200 -0
  18. unfold/forms.py +6 -0
  19. unfold/static/unfold/css/simplebar.css +230 -0
  20. unfold/static/unfold/css/styles.css +1 -1
  21. unfold/static/unfold/js/simplebar.js +10 -0
  22. unfold/styles.css +9 -1
  23. unfold/templates/admin/app_list.html +1 -1
  24. unfold/templates/admin/change_form.html +11 -11
  25. unfold/templates/admin/change_list_results.html +2 -2
  26. unfold/templates/admin/edit_inline/stacked.html +6 -6
  27. unfold/templates/admin/edit_inline/tabular.html +7 -9
  28. unfold/templates/admin/includes/fieldset.html +2 -32
  29. unfold/templates/unfold/helpers/app_list.html +1 -1
  30. unfold/templates/unfold/helpers/display_header.html +11 -8
  31. unfold/templates/unfold/helpers/field.html +20 -6
  32. unfold/templates/unfold/helpers/field_readonly.html +1 -3
  33. unfold/templates/unfold/helpers/field_readonly_value.html +1 -0
  34. unfold/templates/unfold/helpers/fieldset_row.html +53 -0
  35. unfold/templates/unfold/helpers/fieldsets_tabs.html +4 -4
  36. unfold/templates/unfold/helpers/form_label.html +1 -1
  37. unfold/templates/unfold/layouts/skeleton.html +2 -0
  38. unfold/templates/unfold/widgets/clearable_file_input.html +1 -1
  39. unfold/templates/unfold/widgets/foreign_key_raw_id.html +15 -0
  40. unfold/templates/unfold/widgets/textarea.html +1 -7
  41. unfold/templates/unfold/widgets/textarea_expandable.html +7 -0
  42. unfold/widgets.py +36 -3
  43. unfold/contrib/import_export/admin.py +0 -37
  44. {django_unfold-0.25.0.dist-info → django_unfold-0.27.0.dist-info}/LICENSE.md +0 -0
  45. {django_unfold-0.25.0.dist-info → django_unfold-0.27.0.dist-info}/WHEEL +0 -0
@@ -1,7 +1,10 @@
1
- from typing import Any, Dict, Optional
1
+ from typing import Any, Dict, List, Optional, Union
2
2
 
3
- from django.forms import Widget
4
- from unfold.widgets import PROSE_CLASSES
3
+ from django.core.validators import EMPTY_VALUES
4
+ from django.forms import MultiWidget, Widget
5
+ from django.http import QueryDict
6
+ from django.utils.datastructures import MultiValueDict
7
+ from unfold.widgets import PROSE_CLASSES, UnfoldAdminTextInputWidget
5
8
 
6
9
  WYSIWYG_CLASSES = [
7
10
  *PROSE_CLASSES,
@@ -22,6 +25,58 @@ WYSIWYG_CLASSES = [
22
25
  ]
23
26
 
24
27
 
28
+ class ArrayWidget(MultiWidget):
29
+ template_name = "unfold/forms/array.html"
30
+ widget_class = UnfoldAdminTextInputWidget
31
+
32
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
33
+ widgets = [self.widget_class]
34
+ super().__init__(widgets)
35
+
36
+ def get_context(self, name: str, value: str, attrs: Dict) -> Dict:
37
+ self._resolve_widgets(value)
38
+ context = super().get_context(name, value, attrs)
39
+ template_widget = UnfoldAdminTextInputWidget()
40
+ template_widget.name = name
41
+
42
+ context.update({"template": template_widget})
43
+ return context
44
+
45
+ def value_from_datadict(
46
+ self, data: QueryDict, files: MultiValueDict, name: str
47
+ ) -> List:
48
+ values = []
49
+
50
+ for item in data.getlist(name):
51
+ if item not in EMPTY_VALUES:
52
+ values.append(item)
53
+
54
+ return values
55
+
56
+ def value_omitted_from_data(
57
+ self, data: QueryDict, files: MultiValueDict, name: str
58
+ ) -> List:
59
+ return data.getlist(name) not in [[""], *EMPTY_VALUES]
60
+
61
+ def decompress(self, value: Union[str, List]) -> List:
62
+ if isinstance(value, List):
63
+ return value.split(",")
64
+
65
+ return []
66
+
67
+ def _resolve_widgets(self, value: Optional[Union[List, str]]) -> None:
68
+ if value is None:
69
+ value = []
70
+
71
+ elif isinstance(value, List):
72
+ self.widgets = [self.widget_class for item in value]
73
+ else:
74
+ self.widgets = [self.widget_class for item in value.split(",")]
75
+
76
+ self.widgets_names = ["" for i in range(len(self.widgets))]
77
+ self.widgets = [w() if isinstance(w, type) else w for w in self.widgets]
78
+
79
+
25
80
  class WysiwygWidget(Widget):
26
81
  template_name = "unfold/forms/wysiwyg.html"
27
82
 
@@ -1,8 +1,13 @@
1
+ from django.forms.fields import BooleanField
1
2
  from import_export.forms import ExportForm as BaseExportForm
2
3
  from import_export.forms import ImportForm as BaseImportForm
4
+ from import_export.forms import (
5
+ SelectableFieldsExportForm as BaseSelectableFieldsExportForm,
6
+ )
3
7
  from unfold.widgets import (
4
8
  SELECT_CLASSES,
5
9
  UnfoldAdminFileFieldWidget,
10
+ UnfoldBooleanWidget,
6
11
  )
7
12
 
8
13
 
@@ -18,4 +23,16 @@ class ImportForm(BaseImportForm):
18
23
  class ExportForm(BaseExportForm):
19
24
  def __init__(self, *args, **kwargs):
20
25
  super().__init__(*args, **kwargs)
26
+ self.fields["resource"].widget.attrs["class"] = " ".join(SELECT_CLASSES)
21
27
  self.fields["format"].widget.attrs["class"] = " ".join(SELECT_CLASSES)
28
+
29
+
30
+ class SelectableFieldsExportForm(BaseSelectableFieldsExportForm):
31
+ def __init__(self, formats, resources, **kwargs):
32
+ super().__init__(formats, resources, **kwargs)
33
+ self.fields["resource"].widget.attrs["class"] = " ".join(SELECT_CLASSES)
34
+ self.fields["format"].widget.attrs["class"] = " ".join(SELECT_CLASSES)
35
+
36
+ for _key, field in self.fields.items():
37
+ if isinstance(field, BooleanField):
38
+ field.widget = UnfoldBooleanWidget()
@@ -0,0 +1,10 @@
1
+ {% extends 'admin/change_form.html' %}
2
+ {% load i18n %}
3
+
4
+ {% block actions %}
5
+ {{ block.super }}
6
+
7
+ {% if show_change_form_export %}
8
+ <input type="submit" value="{% translate 'Export' %}" name="_export-item" class="bg-white text-gray-500 border cursor-pointer flex font-medium items-center px-3 py-2 mr-3 rounded-md shadow-sm text-sm dark:bg-gray-900 dark:border dark:border-gray-700 dark:text-gray-400">
9
+ {% endif %}
10
+ {% endblock %}
@@ -33,11 +33,46 @@
33
33
  {% endblock %}
34
34
 
35
35
  {% block content %}
36
- <form action="" method="POST">
36
+ <form action="{{ export_url }}" method="POST">
37
37
  {% csrf_token %}
38
38
 
39
- <fieldset class="border border-gray-200 mb-8 rounded-md pt-3 px-3 shadow-sm dark:border-gray-800">
40
- {% include "unfold/helpers/field.html" with field=form.format %}
39
+ {% if form.initial.export_items %}
40
+ <p class="bg-blue-50 mb-4 text-blue-500 px-3 py-3 rounded-md text-sm dark:bg-blue-500/20 dark:border-blue-500/10">
41
+ {% blocktranslate count len=form.initial.export_items|length %}
42
+ Export {{ len }} selected item.
43
+ {% plural %}
44
+ Export {{ len }} selected items.
45
+ {% endblocktranslate %}
46
+ </p>
47
+ {% endif %}
48
+
49
+ {% if not form.is_selectable_fields_form %}
50
+ {% include "admin/import_export/resource_fields_list.html" with import_or_export="export" %}
51
+ {% endif %}
52
+
53
+ {{ form.non_field_errors }}
54
+
55
+ <fieldset class="border border-gray-200 mb-4 rounded-md pt-3 px-3 shadow-sm dark:border-gray-800">
56
+ {% for field in form.visible_fields %}
57
+ <div {% if field.field.is_selectable_field %}class="selectable-field-export-row" resource-index="{{ field.field.resource_index }}"{% else %}class="form-row aligned"{% endif %}>
58
+ {% if field.field.initial_field %}
59
+ <p class="block font-medium mb-2 text-gray-900 text-sm dark:text-gray-200">
60
+ {% trans "This exporter will export the following fields" %}
61
+ </p>
62
+ {% endif %}
63
+
64
+ {% if field.field.widget.attrs.readonly %}
65
+ {% include "unfold/helpers/field_readonly.html" with title=field.label value=field.field.value %}
66
+ {{ field.as_hidden }}
67
+ {% else %}
68
+ {% include "unfold/helpers/field.html" with field=field %}
69
+ {% endif %}
70
+ </div>
71
+ {% endfor %}
72
+
73
+ {% for field in form.hidden_fields %}
74
+ {{ field }}
75
+ {% endfor %}
41
76
  </fieldset>
42
77
 
43
78
  <button type="submit" class="bg-primary-600 border border-transparent font-medium px-3 py-2 rounded-md text-sm text-white">
@@ -7,20 +7,16 @@
7
7
  {% include "admin/import_export/resource_fields_list.html" with import_or_export="import" %}
8
8
 
9
9
  <fieldset class="border border-gray-200 mb-8 rounded-md pt-3 px-3 shadow-sm dark:border-gray-800">
10
- {% if form.resource.field.widget.attrs.readonly %}
11
- {% include "unfold/helpers/field_readonly.html" with title=form.resource.field.label value=form.resource.field.value %}
12
- {{ form.resource.as_hidden }}
13
- {% else %}
14
- {% include "unfold/helpers/field.html" with field=form.resource %}
15
- {% endif %}
16
-
17
-
18
- {% include "unfold/helpers/field.html" with field=form.import_file %}
19
-
20
- {% include "unfold/helpers/field.html" with field=form.format %}
10
+ {% for field in form %}
11
+ {% if field.field.widget.attrs.readonly %}
12
+ {% include "unfold/helpers/field_readonly.html" with title=field.label value=field.field.value %}
13
+ {{ field.as_hidden }}
14
+ {% else %}
15
+ {% include "unfold/helpers/field.html" with field=field %}
16
+ {% endif %}
17
+ {% endfor %}
21
18
  </fieldset>
22
19
 
23
-
24
20
  <button type="submit" class="bg-primary-600 border border-transparent font-medium px-3 py-2 rounded-md text-sm text-white">
25
21
  {% translate 'Submit' %}
26
22
  </button>
@@ -1,7 +1,7 @@
1
1
  {% load i18n %}
2
2
 
3
3
  {% block fields_help %}
4
- <div class="bg-blue-50 mb-8 text-blue-500 px-3 py-3 rounded-md text-sm dark:bg-blue-500/20 dark:border-blue-500/10">
4
+ <div class="bg-blue-50 mb-4 text-blue-500 px-3 py-3 rounded-md text-sm dark:bg-blue-500/20 dark:border-blue-500/10">
5
5
  {% if import_or_export == "export" %}
6
6
  {% trans "This exporter will export the following fields: " %}
7
7
  {% elif import_or_export == "import" %}
File without changes
@@ -0,0 +1,141 @@
1
+ from functools import partial
2
+ from typing import Any, Optional
3
+
4
+ from django import forms
5
+ from django.contrib.admin.utils import NestedObjects, flatten_fieldsets
6
+ from django.core.exceptions import ValidationError
7
+ from django.db import router
8
+ from django.db.models import Model
9
+ from django.forms.formsets import DELETION_FIELD_NAME
10
+ from django.forms.models import modelform_defines_fields
11
+ from django.http import HttpRequest
12
+ from django.utils.text import get_text_list
13
+ from django.utils.translation import gettext_lazy as _
14
+ from unfold.admin import StackedInline, TabularInline
15
+
16
+ from .checks import NonrelatedModelAdminChecks
17
+ from .forms import NonrelatedInlineModelFormSet, nonrelated_inline_formset_factory
18
+
19
+
20
+ class NonrelatedInlineMixin:
21
+ checks_class = NonrelatedModelAdminChecks
22
+ formset = NonrelatedInlineModelFormSet
23
+
24
+ def get_formset(
25
+ self, request: HttpRequest, obj: Optional[Model] = None, **kwargs: Any
26
+ ):
27
+ defaults = self._get_formset_defaults(request, obj, **kwargs)
28
+
29
+ defaults["queryset"] = (
30
+ self.get_form_queryset(obj) if obj else self.model.objects.none()
31
+ )
32
+
33
+ return nonrelated_inline_formset_factory(
34
+ self.model, save_new_instance=self.save_new_instance, **defaults
35
+ )
36
+
37
+ def _get_formset_defaults(
38
+ self, request: HttpRequest, obj: Optional[Model] = None, **kwargs: Any
39
+ ):
40
+ """Return a BaseInlineFormSet class for use in admin add/change views."""
41
+ if "fields" in kwargs:
42
+ fields = kwargs.pop("fields")
43
+ else:
44
+ fields = flatten_fieldsets(self.get_fieldsets(request, obj))
45
+ excluded = self.get_exclude(request, obj)
46
+ exclude = [] if excluded is None else list(excluded)
47
+ exclude.extend(self.get_readonly_fields(request, obj))
48
+ if excluded is None and hasattr(self.form, "_meta") and self.form._meta.exclude:
49
+ # Take the custom ModelForm's Meta.exclude into account only if the
50
+ # InlineModelAdmin doesn't define its own.
51
+ exclude.extend(self.form._meta.exclude)
52
+ # If exclude is an empty list we use None, since that's the actual
53
+ # default.
54
+ exclude = exclude or None
55
+ can_delete = self.can_delete and self.has_delete_permission(request, obj)
56
+ defaults = {
57
+ "form": self.form,
58
+ "formset": self.formset,
59
+ # "fk_name": self.fk_name,
60
+ "fields": fields,
61
+ "exclude": exclude,
62
+ "formfield_callback": partial(self.formfield_for_dbfield, request=request),
63
+ "extra": self.get_extra(request, obj, **kwargs),
64
+ "min_num": self.get_min_num(request, obj, **kwargs),
65
+ "max_num": self.get_max_num(request, obj, **kwargs),
66
+ "can_delete": can_delete,
67
+ **kwargs,
68
+ }
69
+
70
+ base_model_form = defaults["form"]
71
+ can_change = self.has_change_permission(request, obj) if request else True
72
+ can_add = self.has_add_permission(request, obj) if request else True
73
+
74
+ class DeleteProtectedModelForm(base_model_form):
75
+ def hand_clean_DELETE(self):
76
+ """
77
+ We don't validate the 'DELETE' field itself because on
78
+ templates it's not rendered using the field information, but
79
+ just using a generic "deletion_field" of the InlineModelAdmin.
80
+ """
81
+ if self.cleaned_data.get(DELETION_FIELD_NAME, False):
82
+ using = router.db_for_write(self._meta.model)
83
+ collector = NestedObjects(using=using)
84
+ if self.instance._state.adding:
85
+ return
86
+ collector.collect([self.instance])
87
+ if collector.protected:
88
+ objs = []
89
+ for p in collector.protected:
90
+ objs.append(
91
+ # Translators: Model verbose name and instance representation,
92
+ # suitable to be an item in a list.
93
+ _("%(class_name)s %(instance)s")
94
+ % {
95
+ "class_name": p._meta.verbose_name,
96
+ "instance": p,
97
+ }
98
+ )
99
+ params = {
100
+ "class_name": self._meta.model._meta.verbose_name,
101
+ "instance": self.instance,
102
+ "related_objects": get_text_list(objs, _("and")),
103
+ }
104
+ msg = _(
105
+ "Deleting %(class_name)s %(instance)s would require "
106
+ "deleting the following protected related objects: "
107
+ "%(related_objects)s"
108
+ )
109
+ raise ValidationError(
110
+ msg, code="deleting_protected", params=params
111
+ )
112
+
113
+ def is_valid(self):
114
+ result = super().is_valid()
115
+ self.hand_clean_DELETE()
116
+ return result
117
+
118
+ def has_changed(self):
119
+ # Protect against unauthorized edits.
120
+ if not can_change and not self.instance._state.adding:
121
+ return False
122
+ if not can_add and self.instance._state.adding:
123
+ return False
124
+ return super().has_changed()
125
+
126
+ defaults["form"] = DeleteProtectedModelForm
127
+
128
+ if defaults["fields"] is None and not modelform_defines_fields(
129
+ defaults["form"]
130
+ ):
131
+ defaults["fields"] = forms.ALL_FIELDS
132
+
133
+ return defaults
134
+
135
+
136
+ class NonrelatedStackedInline(NonrelatedInlineMixin, StackedInline):
137
+ pass
138
+
139
+
140
+ class NonrelatedTabularInline(NonrelatedInlineMixin, TabularInline):
141
+ pass
@@ -0,0 +1,6 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class DefaultAppConfig(AppConfig):
5
+ name = "unfold.contrib.inlines"
6
+ label = "unfold_inlines"
@@ -0,0 +1,18 @@
1
+ from typing import List
2
+
3
+ from django.contrib.admin.checks import InlineModelAdminChecks
4
+ from django.contrib.admin.options import InlineModelAdmin
5
+ from django.core.checks import CheckMessage
6
+ from django.db.models import Model
7
+
8
+
9
+ class NonrelatedModelAdminChecks(InlineModelAdminChecks):
10
+ def _check_exclude_of_parent_model(
11
+ self, obj: InlineModelAdmin, parent_model: Model
12
+ ) -> List[CheckMessage]:
13
+ return []
14
+
15
+ def _check_relation(
16
+ self, obj: InlineModelAdmin, parent_model: Model
17
+ ) -> List[CheckMessage]:
18
+ return []
@@ -0,0 +1,43 @@
1
+ from typing import Any, Callable, Optional
2
+
3
+ from django.db.models import Model, QuerySet
4
+ from django.forms import BaseModelFormSet, ModelForm, modelformset_factory
5
+
6
+
7
+ class NonrelatedInlineModelFormSet(BaseModelFormSet):
8
+ def __init__(
9
+ self,
10
+ instance: Optional[Model] = None,
11
+ save_as_new: bool = False,
12
+ **kwargs: Any,
13
+ ) -> None:
14
+ self.instance = instance
15
+ self.queryset = self.provided_queryset
16
+
17
+ super().__init__(**kwargs)
18
+
19
+ @classmethod
20
+ def get_default_prefix(cls: BaseModelFormSet) -> str:
21
+ return f"{cls.model._meta.app_label}-{cls.model._meta.model_name}"
22
+
23
+ def save_new(self, form: ModelForm, commit: bool = True):
24
+ obj = super().save_new(form, commit=False)
25
+ self.save_new_instance(self.instance, obj)
26
+
27
+ if commit:
28
+ obj.save()
29
+
30
+ return obj
31
+
32
+
33
+ def nonrelated_inline_formset_factory(
34
+ model: Model,
35
+ queryset: Optional[QuerySet] = None,
36
+ formset: BaseModelFormSet = NonrelatedInlineModelFormSet,
37
+ save_new_instance: Optional[Callable] = None,
38
+ **kwargs: Any,
39
+ ) -> BaseModelFormSet:
40
+ inline_formset = modelformset_factory(model, formset=formset, **kwargs)
41
+ inline_formset.provided_queryset = queryset
42
+ inline_formset.save_new_instance = save_new_instance
43
+ return inline_formset
unfold/decorators.py CHANGED
@@ -58,6 +58,7 @@ def display(
58
58
  function: Optional[Callable[[Model], Any]] = None,
59
59
  *,
60
60
  boolean: Optional[bool] = None,
61
+ image: Optional[bool] = None,
61
62
  ordering: Optional[Union[str, Combinable, BaseExpression]] = None,
62
63
  description: Optional[str] = None,
63
64
  empty_value: Optional[str] = None,
@@ -72,6 +73,8 @@ def display(
72
73
  )
73
74
  if boolean is not None:
74
75
  func.boolean = boolean
76
+ if image is not None:
77
+ func.image = image
75
78
  if ordering is not None:
76
79
  func.admin_order_field = ordering
77
80
  if description is not None:
unfold/fields.py ADDED
@@ -0,0 +1,200 @@
1
+ from django.contrib.admin import helpers
2
+ from django.contrib.admin.utils import lookup_field, quote
3
+ from django.core.exceptions import ObjectDoesNotExist
4
+ from django.db import models
5
+ from django.db.models import (
6
+ ForeignObjectRel,
7
+ ImageField,
8
+ JSONField,
9
+ ManyToManyRel,
10
+ OneToOneField,
11
+ )
12
+ from django.forms.utils import flatatt
13
+ from django.template.defaultfilters import linebreaksbr
14
+ from django.urls import NoReverseMatch, reverse
15
+ from django.utils.html import conditional_escape, format_html
16
+ from django.utils.module_loading import import_string
17
+ from django.utils.safestring import SafeText, mark_safe
18
+ from django.utils.text import capfirst
19
+
20
+ from .settings import get_config
21
+ from .utils import display_for_field
22
+ from .widgets import CHECKBOX_LABEL_CLASSES, LABEL_CLASSES
23
+
24
+
25
+ class UnfoldAdminReadonlyField(helpers.AdminReadonlyField):
26
+ def label_tag(self) -> SafeText:
27
+ from .admin import ModelAdmin, ModelAdminMixin
28
+
29
+ if not isinstance(self.model_admin, ModelAdmin) and not isinstance(
30
+ self.model_admin, ModelAdminMixin
31
+ ):
32
+ return super().label_tag()
33
+
34
+ attrs = {
35
+ "class": " ".join(LABEL_CLASSES + ["mb-2"]),
36
+ }
37
+
38
+ label = self.field["label"]
39
+
40
+ return format_html(
41
+ "<label{}>{}{}</label>",
42
+ flatatt(attrs),
43
+ capfirst(label),
44
+ self.form.label_suffix,
45
+ )
46
+
47
+ def is_json(self) -> bool:
48
+ field, obj, model_admin = (
49
+ self.field["field"],
50
+ self.form.instance,
51
+ self.model_admin,
52
+ )
53
+
54
+ try:
55
+ f, attr, value = lookup_field(field, obj, model_admin)
56
+ except (AttributeError, ValueError, ObjectDoesNotExist):
57
+ return False
58
+
59
+ return isinstance(f, JSONField)
60
+
61
+ def is_image(self) -> bool:
62
+ field, obj, model_admin = (
63
+ self.field["field"],
64
+ self.form.instance,
65
+ self.model_admin,
66
+ )
67
+
68
+ try:
69
+ f, attr, value = lookup_field(field, obj, model_admin)
70
+ except (AttributeError, ValueError, ObjectDoesNotExist):
71
+ return False
72
+
73
+ if hasattr(attr, "image"):
74
+ return attr.image
75
+ elif (
76
+ isinstance(attr, property)
77
+ and hasattr(attr, "fget")
78
+ and hasattr(attr.fget, "image")
79
+ ):
80
+ return attr.fget.image
81
+
82
+ return isinstance(f, ImageField)
83
+
84
+ def contents(self) -> str:
85
+ contents = self._get_contents()
86
+ contents = self._preprocess_field(contents)
87
+ return contents
88
+
89
+ def get_admin_url(self, remote_field, remote_obj):
90
+ url_name = f"admin:{remote_field.model._meta.app_label}_{remote_field.model._meta.model_name}_change"
91
+ try:
92
+ url = reverse(
93
+ url_name,
94
+ args=[quote(remote_obj.pk)],
95
+ current_app=self.model_admin.admin_site.name,
96
+ )
97
+ return format_html(
98
+ '<a href="{}" class="text-primary-600 underline whitespace-nowrap">{}</a>',
99
+ url,
100
+ remote_obj,
101
+ )
102
+ except NoReverseMatch:
103
+ return str(remote_obj)
104
+
105
+ def _get_contents(self) -> str:
106
+ from django.contrib.admin.templatetags.admin_list import _boolean_icon
107
+
108
+ field, obj, model_admin = (
109
+ self.field["field"],
110
+ self.form.instance,
111
+ self.model_admin,
112
+ )
113
+ try:
114
+ f, attr, value = lookup_field(field, obj, model_admin)
115
+ except (AttributeError, ValueError, ObjectDoesNotExist):
116
+ result_repr = self.empty_value_display
117
+ else:
118
+ if field in self.form.fields:
119
+ widget = self.form[field].field.widget
120
+ # This isn't elegant but suffices for contrib.auth's
121
+ # ReadOnlyPasswordHashWidget.
122
+ if getattr(widget, "read_only", False):
123
+ return widget.render(field, value)
124
+
125
+ if f is None:
126
+ if getattr(attr, "boolean", False):
127
+ result_repr = _boolean_icon(value)
128
+ else:
129
+ if hasattr(value, "__html__"):
130
+ result_repr = value
131
+ else:
132
+ result_repr = linebreaksbr(value)
133
+ else:
134
+ if isinstance(f.remote_field, ManyToManyRel) and value is not None:
135
+ result_repr = ", ".join(map(str, value.all()))
136
+ elif (
137
+ isinstance(f.remote_field, (ForeignObjectRel, OneToOneField))
138
+ and value is not None
139
+ ):
140
+ result_repr = self.get_admin_url(f.remote_field, value)
141
+ elif isinstance(f, models.URLField):
142
+ return format_html(
143
+ '<a href="{}" class="text-primary-600 underline whitespace-nowrap">{}</a>',
144
+ value,
145
+ value,
146
+ )
147
+ else:
148
+ result_repr = display_for_field(value, f, self.empty_value_display)
149
+ return conditional_escape(result_repr)
150
+ result_repr = linebreaksbr(result_repr)
151
+ return conditional_escape(result_repr)
152
+
153
+ def _preprocess_field(self, contents: str) -> str:
154
+ if (
155
+ hasattr(self.model_admin, "readonly_preprocess_fields")
156
+ and self.field["field"] in self.model_admin.readonly_preprocess_fields
157
+ ):
158
+ func = self.model_admin.readonly_preprocess_fields[self.field["field"]]
159
+ if isinstance(func, str):
160
+ contents = import_string(func)(contents)
161
+ elif callable(func):
162
+ contents = func(contents)
163
+
164
+ return contents
165
+
166
+
167
+ class UnfoldAdminField(helpers.AdminField):
168
+ def label_tag(self) -> SafeText:
169
+ classes = []
170
+ if not self.field.field.widget.__class__.__name__.startswith(
171
+ "Unfold"
172
+ ) and not self.field.field.widget.template_name.startswith("unfold"):
173
+ return super().label_tag()
174
+
175
+ # TODO load config from current AdminSite (override Fieldline.__iter__ method)
176
+ for lang, flag in get_config()["EXTENSIONS"]["modeltranslation"][
177
+ "flags"
178
+ ].items():
179
+ if f"[{lang}]" in self.field.label:
180
+ self.field.label = self.field.label.replace(f"[{lang}]", flag)
181
+ break
182
+
183
+ contents = conditional_escape(self.field.label)
184
+
185
+ if self.is_checkbox:
186
+ classes.append(" ".join(CHECKBOX_LABEL_CLASSES))
187
+ else:
188
+ classes.append(" ".join(LABEL_CLASSES))
189
+
190
+ if self.field.field.required:
191
+ classes.append("required")
192
+
193
+ attrs = {"class": " ".join(classes)} if classes else {}
194
+ required = mark_safe(' <span class="text-red-600">*</span>')
195
+
196
+ return self.field.label_tag(
197
+ contents=mark_safe(contents),
198
+ attrs=attrs,
199
+ label_suffix=required if self.field.field.required else "",
200
+ )
unfold/forms.py CHANGED
@@ -10,6 +10,7 @@ from django.contrib.admin.forms import (
10
10
  from django.contrib.auth.forms import (
11
11
  AdminPasswordChangeForm as BaseAdminPasswordChangeForm,
12
12
  )
13
+ from django.contrib.auth.forms import ReadOnlyPasswordHashWidget
13
14
  from django.contrib.auth.forms import UserChangeForm as BaseUserChangeForm
14
15
  from django.contrib.auth.forms import UserCreationForm as BaseUserCreationForm
15
16
  from django.http import HttpRequest
@@ -19,6 +20,10 @@ from django.utils.translation import gettext_lazy as _
19
20
  from .widgets import BASE_INPUT_CLASSES, INPUT_CLASSES, SELECT_CLASSES
20
21
 
21
22
 
23
+ class UnfoldReadOnlyPasswordHashWidget(ReadOnlyPasswordHashWidget):
24
+ pass
25
+
26
+
22
27
  class ActionForm(forms.Form):
23
28
  action = forms.ChoiceField(
24
29
  label="",
@@ -72,6 +77,7 @@ class UserChangeForm(BaseUserChangeForm):
72
77
  **kwargs,
73
78
  ) -> None:
74
79
  super().__init__(request, *args, **kwargs)
80
+ self.fields["password"].widget = UnfoldReadOnlyPasswordHashWidget()
75
81
 
76
82
  self.fields["password"].help_text = _(
77
83
  "Raw passwords are not stored, so there is no way to see this "