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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 "