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.
- {django_unfold-0.25.0.dist-info → django_unfold-0.27.0.dist-info}/METADATA +61 -7
- {django_unfold-0.25.0.dist-info → django_unfold-0.27.0.dist-info}/RECORD +44 -31
- unfold/admin.py +13 -157
- unfold/contrib/forms/templates/unfold/forms/array.html +31 -0
- unfold/contrib/forms/widgets.py +58 -3
- unfold/contrib/import_export/forms.py +17 -0
- unfold/contrib/import_export/templates/admin/import_export/change_form.html +10 -0
- unfold/contrib/import_export/templates/admin/import_export/export.html +38 -3
- unfold/contrib/import_export/templates/admin/import_export/import_form.html +8 -12
- unfold/contrib/import_export/templates/admin/import_export/resource_fields_list.html +1 -1
- unfold/contrib/inlines/__init__.py +0 -0
- unfold/contrib/inlines/admin.py +141 -0
- unfold/contrib/inlines/apps.py +6 -0
- unfold/contrib/inlines/checks.py +18 -0
- unfold/contrib/inlines/forms.py +43 -0
- unfold/decorators.py +3 -0
- unfold/fields.py +200 -0
- unfold/forms.py +6 -0
- unfold/static/unfold/css/simplebar.css +230 -0
- unfold/static/unfold/css/styles.css +1 -1
- unfold/static/unfold/js/simplebar.js +10 -0
- unfold/styles.css +9 -1
- unfold/templates/admin/app_list.html +1 -1
- unfold/templates/admin/change_form.html +11 -11
- unfold/templates/admin/change_list_results.html +2 -2
- unfold/templates/admin/edit_inline/stacked.html +6 -6
- unfold/templates/admin/edit_inline/tabular.html +7 -9
- unfold/templates/admin/includes/fieldset.html +2 -32
- unfold/templates/unfold/helpers/app_list.html +1 -1
- unfold/templates/unfold/helpers/display_header.html +11 -8
- unfold/templates/unfold/helpers/field.html +20 -6
- unfold/templates/unfold/helpers/field_readonly.html +1 -3
- unfold/templates/unfold/helpers/field_readonly_value.html +1 -0
- unfold/templates/unfold/helpers/fieldset_row.html +53 -0
- unfold/templates/unfold/helpers/fieldsets_tabs.html +4 -4
- unfold/templates/unfold/helpers/form_label.html +1 -1
- unfold/templates/unfold/layouts/skeleton.html +2 -0
- unfold/templates/unfold/widgets/clearable_file_input.html +1 -1
- unfold/templates/unfold/widgets/foreign_key_raw_id.html +15 -0
- unfold/templates/unfold/widgets/textarea.html +1 -7
- unfold/templates/unfold/widgets/textarea_expandable.html +7 -0
- unfold/widgets.py +36 -3
- unfold/contrib/import_export/admin.py +0 -37
- {django_unfold-0.25.0.dist-info → django_unfold-0.27.0.dist-info}/LICENSE.md +0 -0
- {django_unfold-0.25.0.dist-info → django_unfold-0.27.0.dist-info}/WHEEL +0 -0
unfold/contrib/forms/widgets.py
CHANGED
@@ -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.
|
4
|
-
from
|
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
|
-
|
40
|
-
|
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
|
-
{%
|
11
|
-
{%
|
12
|
-
|
13
|
-
|
14
|
-
{%
|
15
|
-
|
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-
|
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,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 "
|