arthexis 0.1.13__py3-none-any.whl → 0.1.15__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 (108) hide show
  1. {arthexis-0.1.13.dist-info → arthexis-0.1.15.dist-info}/METADATA +224 -221
  2. arthexis-0.1.15.dist-info/RECORD +110 -0
  3. {arthexis-0.1.13.dist-info → arthexis-0.1.15.dist-info}/licenses/LICENSE +674 -674
  4. config/__init__.py +5 -5
  5. config/active_app.py +15 -15
  6. config/asgi.py +43 -43
  7. config/auth_app.py +7 -7
  8. config/celery.py +32 -32
  9. config/context_processors.py +67 -69
  10. config/horologia_app.py +7 -7
  11. config/loadenv.py +11 -11
  12. config/logging.py +59 -48
  13. config/middleware.py +25 -25
  14. config/offline.py +49 -49
  15. config/settings.py +691 -682
  16. config/settings_helpers.py +109 -109
  17. config/urls.py +171 -166
  18. config/wsgi.py +17 -17
  19. core/admin.py +3795 -2809
  20. core/admin_history.py +50 -50
  21. core/admindocs.py +151 -151
  22. core/apps.py +356 -272
  23. core/auto_upgrade.py +57 -57
  24. core/backends.py +265 -236
  25. core/changelog.py +342 -0
  26. core/entity.py +149 -133
  27. core/environment.py +61 -61
  28. core/fields.py +168 -168
  29. core/form_fields.py +75 -75
  30. core/github_helper.py +188 -25
  31. core/github_issues.py +178 -172
  32. core/github_repos.py +72 -0
  33. core/lcd_screen.py +78 -78
  34. core/liveupdate.py +25 -25
  35. core/log_paths.py +114 -100
  36. core/mailer.py +85 -85
  37. core/middleware.py +91 -91
  38. core/models.py +3637 -2795
  39. core/notifications.py +105 -105
  40. core/public_wifi.py +267 -227
  41. core/reference_utils.py +108 -108
  42. core/release.py +840 -368
  43. core/rfid_import_export.py +113 -0
  44. core/sigil_builder.py +149 -149
  45. core/sigil_context.py +20 -20
  46. core/sigil_resolver.py +315 -315
  47. core/system.py +952 -493
  48. core/tasks.py +408 -394
  49. core/temp_passwords.py +181 -181
  50. core/test_system_info.py +186 -139
  51. core/tests.py +2168 -1521
  52. core/tests_liveupdate.py +17 -17
  53. core/urls.py +11 -11
  54. core/user_data.py +641 -633
  55. core/views.py +2201 -1417
  56. core/widgets.py +213 -94
  57. core/workgroup_urls.py +17 -17
  58. core/workgroup_views.py +94 -94
  59. nodes/admin.py +1720 -1161
  60. nodes/apps.py +87 -85
  61. nodes/backends.py +160 -160
  62. nodes/dns.py +203 -203
  63. nodes/feature_checks.py +133 -133
  64. nodes/lcd.py +165 -165
  65. nodes/models.py +1764 -1597
  66. nodes/reports.py +411 -411
  67. nodes/rfid_sync.py +195 -0
  68. nodes/signals.py +18 -0
  69. nodes/tasks.py +46 -46
  70. nodes/tests.py +3830 -3116
  71. nodes/urls.py +15 -14
  72. nodes/utils.py +121 -105
  73. nodes/views.py +683 -619
  74. ocpp/admin.py +948 -948
  75. ocpp/apps.py +25 -25
  76. ocpp/consumers.py +1565 -1459
  77. ocpp/evcs.py +844 -844
  78. ocpp/evcs_discovery.py +158 -158
  79. ocpp/models.py +917 -917
  80. ocpp/reference_utils.py +42 -42
  81. ocpp/routing.py +11 -11
  82. ocpp/simulator.py +745 -745
  83. ocpp/status_display.py +26 -26
  84. ocpp/store.py +601 -541
  85. ocpp/tasks.py +31 -31
  86. ocpp/test_export_import.py +130 -130
  87. ocpp/test_rfid.py +913 -702
  88. ocpp/tests.py +4445 -4094
  89. ocpp/transactions_io.py +189 -189
  90. ocpp/urls.py +50 -50
  91. ocpp/views.py +1479 -1251
  92. pages/admin.py +769 -539
  93. pages/apps.py +10 -10
  94. pages/checks.py +40 -40
  95. pages/context_processors.py +127 -119
  96. pages/defaults.py +13 -13
  97. pages/forms.py +198 -198
  98. pages/middleware.py +209 -153
  99. pages/models.py +643 -426
  100. pages/tasks.py +74 -0
  101. pages/tests.py +3025 -2200
  102. pages/urls.py +26 -25
  103. pages/utils.py +23 -12
  104. pages/views.py +1176 -1128
  105. arthexis-0.1.13.dist-info/RECORD +0 -105
  106. nodes/actions.py +0 -70
  107. {arthexis-0.1.13.dist-info → arthexis-0.1.15.dist-info}/WHEEL +0 -0
  108. {arthexis-0.1.13.dist-info → arthexis-0.1.15.dist-info}/top_level.txt +0 -0
core/environment.py CHANGED
@@ -1,61 +1,61 @@
1
- from __future__ import annotations
2
-
3
- import os
4
-
5
- from django.conf import settings
6
- from django.contrib import admin
7
- from django.template.response import TemplateResponse
8
- from django.urls import path
9
- from django.utils.translation import gettext_lazy as _
10
-
11
-
12
- def _get_django_settings():
13
- return sorted(
14
- [(name, getattr(settings, name)) for name in dir(settings) if name.isupper()]
15
- )
16
-
17
-
18
- def _environment_view(request):
19
- env_vars = sorted(os.environ.items())
20
- context = admin.site.each_context(request)
21
- context.update(
22
- {
23
- "title": _("Environ"),
24
- "env_vars": env_vars,
25
- }
26
- )
27
- return TemplateResponse(request, "admin/environment.html", context)
28
-
29
-
30
- def _config_view(request):
31
- context = admin.site.each_context(request)
32
- context.update(
33
- {
34
- "title": _("Config"),
35
- "django_settings": _get_django_settings(),
36
- }
37
- )
38
- return TemplateResponse(request, "admin/config.html", context)
39
-
40
-
41
- def patch_admin_environment_view() -> None:
42
- """Add custom admin view for environment information."""
43
- original_get_urls = admin.site.get_urls
44
-
45
- def get_urls():
46
- urls = original_get_urls()
47
- custom = [
48
- path(
49
- "environment/",
50
- admin.site.admin_view(_environment_view),
51
- name="environment",
52
- ),
53
- path(
54
- "config/",
55
- admin.site.admin_view(_config_view),
56
- name="config",
57
- ),
58
- ]
59
- return custom + urls
60
-
61
- admin.site.get_urls = get_urls
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+ from django.conf import settings
6
+ from django.contrib import admin
7
+ from django.template.response import TemplateResponse
8
+ from django.urls import path
9
+ from django.utils.translation import gettext_lazy as _
10
+
11
+
12
+ def _get_django_settings():
13
+ return sorted(
14
+ [(name, getattr(settings, name)) for name in dir(settings) if name.isupper()]
15
+ )
16
+
17
+
18
+ def _environment_view(request):
19
+ env_vars = sorted(os.environ.items())
20
+ context = admin.site.each_context(request)
21
+ context.update(
22
+ {
23
+ "title": _("Environ"),
24
+ "env_vars": env_vars,
25
+ }
26
+ )
27
+ return TemplateResponse(request, "admin/environment.html", context)
28
+
29
+
30
+ def _config_view(request):
31
+ context = admin.site.each_context(request)
32
+ context.update(
33
+ {
34
+ "title": _("Config"),
35
+ "django_settings": _get_django_settings(),
36
+ }
37
+ )
38
+ return TemplateResponse(request, "admin/config.html", context)
39
+
40
+
41
+ def patch_admin_environment_view() -> None:
42
+ """Add custom admin view for environment information."""
43
+ original_get_urls = admin.site.get_urls
44
+
45
+ def get_urls():
46
+ urls = original_get_urls()
47
+ custom = [
48
+ path(
49
+ "environment/",
50
+ admin.site.admin_view(_environment_view),
51
+ name="environment",
52
+ ),
53
+ path(
54
+ "config/",
55
+ admin.site.admin_view(_config_view),
56
+ name="config",
57
+ ),
58
+ ]
59
+ return custom + urls
60
+
61
+ admin.site.get_urls = get_urls
core/fields.py CHANGED
@@ -1,168 +1,168 @@
1
- from dataclasses import dataclass
2
- import re
3
- import sqlite3
4
-
5
- from django.db import models
6
- from django.db.models.fields import DeferredAttribute
7
- from django.utils.translation import gettext_lazy as _
8
-
9
-
10
- class _BaseSigilDescriptor(DeferredAttribute):
11
- def __set__(self, instance, value):
12
- instance.__dict__[self.field.attname] = value
13
-
14
-
15
- class _CheckSigilDescriptor(_BaseSigilDescriptor):
16
- def __get__(self, instance, cls=None):
17
- value = super().__get__(instance, cls)
18
- if instance is None:
19
- return value
20
- if getattr(instance, f"{self.field.name}_resolve_sigils", False):
21
- return instance.resolve_sigils(self.field.name)
22
- return value
23
-
24
-
25
- class _AutoSigilDescriptor(_BaseSigilDescriptor):
26
- def __get__(self, instance, cls=None):
27
- value = super().__get__(instance, cls)
28
- if instance is None:
29
- return value
30
- return instance.resolve_sigils(self.field.name)
31
-
32
-
33
- class _SigilBaseField:
34
- def value_from_object(self, obj):
35
- return obj.__dict__.get(self.attname)
36
-
37
- def pre_save(self, model_instance, add):
38
- # ``models.Field.pre_save`` uses ``getattr`` which would resolve the
39
- # sigil descriptor. Persist the raw database value instead so env-based
40
- # placeholders remain intact when editing through admin forms.
41
- return self.value_from_object(model_instance)
42
-
43
-
44
- class SigilCheckFieldMixin(_SigilBaseField):
45
- descriptor_class = _CheckSigilDescriptor
46
-
47
- def contribute_to_class(self, cls, name, private_only=False):
48
- super().contribute_to_class(cls, name, private_only=private_only)
49
- extra_name = f"{name}_resolve_sigils"
50
- if not any(f.name == extra_name for f in cls._meta.fields):
51
- cls.add_to_class(
52
- extra_name,
53
- models.BooleanField(
54
- default=False,
55
- verbose_name="Resolve [SIGILS] in templates",
56
- ),
57
- )
58
-
59
-
60
- class SigilAutoFieldMixin(_SigilBaseField):
61
- descriptor_class = _AutoSigilDescriptor
62
-
63
- def contribute_to_class(self, cls, name, private_only=False):
64
- super().contribute_to_class(cls, name, private_only=private_only)
65
-
66
-
67
- class SigilShortCheckField(SigilCheckFieldMixin, models.CharField):
68
- pass
69
-
70
-
71
- class SigilLongCheckField(SigilCheckFieldMixin, models.TextField):
72
- pass
73
-
74
-
75
- class SigilShortAutoField(SigilAutoFieldMixin, models.CharField):
76
- pass
77
-
78
-
79
- class SigilLongAutoField(SigilAutoFieldMixin, models.TextField):
80
- pass
81
-
82
-
83
- class ConditionEvaluationError(Exception):
84
- """Raised when a condition expression cannot be evaluated."""
85
-
86
-
87
- @dataclass
88
- class ConditionCheckResult:
89
- """Represents the outcome of evaluating a condition field."""
90
-
91
- passed: bool
92
- resolved: str
93
- error: str | None = None
94
-
95
-
96
- _COMMENT_PATTERN = re.compile(r"(--|/\*)")
97
- _FORBIDDEN_KEYWORDS = re.compile(
98
- r"\b(ATTACH|DETACH|ALTER|ANALYZE|CREATE|DROP|INSERT|UPDATE|DELETE|REPLACE|"
99
- r"VACUUM|TRIGGER|TABLE|INDEX|VIEW|PRAGMA|BEGIN|COMMIT|ROLLBACK|SAVEPOINT|WITH)\b",
100
- re.IGNORECASE,
101
- )
102
-
103
-
104
- def _evaluate_sql_condition(expression: str) -> bool:
105
- """Evaluate a SQL expression in an isolated SQLite connection."""
106
-
107
- if ";" in expression:
108
- raise ConditionEvaluationError(
109
- _("Semicolons are not allowed in conditions."),
110
- )
111
- if _COMMENT_PATTERN.search(expression):
112
- raise ConditionEvaluationError(
113
- _("SQL comments are not allowed in conditions."),
114
- )
115
- match = _FORBIDDEN_KEYWORDS.search(expression)
116
- if match:
117
- raise ConditionEvaluationError(
118
- _("Disallowed keyword in condition: %(keyword)s")
119
- % {"keyword": match.group(1)},
120
- )
121
-
122
- try:
123
- conn = sqlite3.connect(":memory:")
124
- try:
125
- conn.execute("PRAGMA trusted_schema = OFF")
126
- conn.execute("PRAGMA foreign_keys = OFF")
127
- try:
128
- conn.enable_load_extension(False)
129
- except AttributeError:
130
- # ``enable_load_extension`` is not available on some platforms.
131
- pass
132
- cursor = conn.execute(
133
- f"SELECT CASE WHEN ({expression}) THEN 1 ELSE 0 END"
134
- )
135
- row = cursor.fetchone()
136
- return bool(row[0]) if row else False
137
- finally:
138
- conn.close()
139
- except sqlite3.Error as exc: # pragma: no cover - exact error message varies
140
- raise ConditionEvaluationError(str(exc)) from exc
141
-
142
-
143
- class ConditionTextField(models.TextField):
144
- """Field storing a conditional SQL expression resolved through [sigils]."""
145
-
146
- def evaluate(self, instance) -> ConditionCheckResult:
147
- """Evaluate the stored expression for ``instance``."""
148
-
149
- value = self.value_from_object(instance)
150
- if hasattr(instance, "resolve_sigils"):
151
- resolved = instance.resolve_sigils(self.name)
152
- else:
153
- resolved = value
154
-
155
- if resolved is None:
156
- resolved_text = ""
157
- else:
158
- resolved_text = str(resolved)
159
-
160
- resolved_text = resolved_text.strip()
161
- if not resolved_text:
162
- return ConditionCheckResult(True, resolved_text)
163
-
164
- try:
165
- passed = _evaluate_sql_condition(resolved_text)
166
- return ConditionCheckResult(passed, resolved_text)
167
- except ConditionEvaluationError as exc:
168
- return ConditionCheckResult(False, resolved_text, str(exc))
1
+ from dataclasses import dataclass
2
+ import re
3
+ import sqlite3
4
+
5
+ from django.db import models
6
+ from django.db.models.fields import DeferredAttribute
7
+ from django.utils.translation import gettext_lazy as _
8
+
9
+
10
+ class _BaseSigilDescriptor(DeferredAttribute):
11
+ def __set__(self, instance, value):
12
+ instance.__dict__[self.field.attname] = value
13
+
14
+
15
+ class _CheckSigilDescriptor(_BaseSigilDescriptor):
16
+ def __get__(self, instance, cls=None):
17
+ value = super().__get__(instance, cls)
18
+ if instance is None:
19
+ return value
20
+ if getattr(instance, f"{self.field.name}_resolve_sigils", False):
21
+ return instance.resolve_sigils(self.field.name)
22
+ return value
23
+
24
+
25
+ class _AutoSigilDescriptor(_BaseSigilDescriptor):
26
+ def __get__(self, instance, cls=None):
27
+ value = super().__get__(instance, cls)
28
+ if instance is None:
29
+ return value
30
+ return instance.resolve_sigils(self.field.name)
31
+
32
+
33
+ class _SigilBaseField:
34
+ def value_from_object(self, obj):
35
+ return obj.__dict__.get(self.attname)
36
+
37
+ def pre_save(self, model_instance, add):
38
+ # ``models.Field.pre_save`` uses ``getattr`` which would resolve the
39
+ # sigil descriptor. Persist the raw database value instead so env-based
40
+ # placeholders remain intact when editing through admin forms.
41
+ return self.value_from_object(model_instance)
42
+
43
+
44
+ class SigilCheckFieldMixin(_SigilBaseField):
45
+ descriptor_class = _CheckSigilDescriptor
46
+
47
+ def contribute_to_class(self, cls, name, private_only=False):
48
+ super().contribute_to_class(cls, name, private_only=private_only)
49
+ extra_name = f"{name}_resolve_sigils"
50
+ if not any(f.name == extra_name for f in cls._meta.fields):
51
+ cls.add_to_class(
52
+ extra_name,
53
+ models.BooleanField(
54
+ default=False,
55
+ verbose_name="Resolve [SIGILS] in templates",
56
+ ),
57
+ )
58
+
59
+
60
+ class SigilAutoFieldMixin(_SigilBaseField):
61
+ descriptor_class = _AutoSigilDescriptor
62
+
63
+ def contribute_to_class(self, cls, name, private_only=False):
64
+ super().contribute_to_class(cls, name, private_only=private_only)
65
+
66
+
67
+ class SigilShortCheckField(SigilCheckFieldMixin, models.CharField):
68
+ pass
69
+
70
+
71
+ class SigilLongCheckField(SigilCheckFieldMixin, models.TextField):
72
+ pass
73
+
74
+
75
+ class SigilShortAutoField(SigilAutoFieldMixin, models.CharField):
76
+ pass
77
+
78
+
79
+ class SigilLongAutoField(SigilAutoFieldMixin, models.TextField):
80
+ pass
81
+
82
+
83
+ class ConditionEvaluationError(Exception):
84
+ """Raised when a condition expression cannot be evaluated."""
85
+
86
+
87
+ @dataclass
88
+ class ConditionCheckResult:
89
+ """Represents the outcome of evaluating a condition field."""
90
+
91
+ passed: bool
92
+ resolved: str
93
+ error: str | None = None
94
+
95
+
96
+ _COMMENT_PATTERN = re.compile(r"(--|/\*)")
97
+ _FORBIDDEN_KEYWORDS = re.compile(
98
+ r"\b(ATTACH|DETACH|ALTER|ANALYZE|CREATE|DROP|INSERT|UPDATE|DELETE|REPLACE|"
99
+ r"VACUUM|TRIGGER|TABLE|INDEX|VIEW|PRAGMA|BEGIN|COMMIT|ROLLBACK|SAVEPOINT|WITH)\b",
100
+ re.IGNORECASE,
101
+ )
102
+
103
+
104
+ def _evaluate_sql_condition(expression: str) -> bool:
105
+ """Evaluate a SQL expression in an isolated SQLite connection."""
106
+
107
+ if ";" in expression:
108
+ raise ConditionEvaluationError(
109
+ _("Semicolons are not allowed in conditions."),
110
+ )
111
+ if _COMMENT_PATTERN.search(expression):
112
+ raise ConditionEvaluationError(
113
+ _("SQL comments are not allowed in conditions."),
114
+ )
115
+ match = _FORBIDDEN_KEYWORDS.search(expression)
116
+ if match:
117
+ raise ConditionEvaluationError(
118
+ _("Disallowed keyword in condition: %(keyword)s")
119
+ % {"keyword": match.group(1)},
120
+ )
121
+
122
+ try:
123
+ conn = sqlite3.connect(":memory:")
124
+ try:
125
+ conn.execute("PRAGMA trusted_schema = OFF")
126
+ conn.execute("PRAGMA foreign_keys = OFF")
127
+ try:
128
+ conn.enable_load_extension(False)
129
+ except AttributeError:
130
+ # ``enable_load_extension`` is not available on some platforms.
131
+ pass
132
+ cursor = conn.execute(
133
+ f"SELECT CASE WHEN ({expression}) THEN 1 ELSE 0 END"
134
+ )
135
+ row = cursor.fetchone()
136
+ return bool(row[0]) if row else False
137
+ finally:
138
+ conn.close()
139
+ except sqlite3.Error as exc: # pragma: no cover - exact error message varies
140
+ raise ConditionEvaluationError(str(exc)) from exc
141
+
142
+
143
+ class ConditionTextField(models.TextField):
144
+ """Field storing a conditional SQL expression resolved through [sigils]."""
145
+
146
+ def evaluate(self, instance) -> ConditionCheckResult:
147
+ """Evaluate the stored expression for ``instance``."""
148
+
149
+ value = self.value_from_object(instance)
150
+ if hasattr(instance, "resolve_sigils"):
151
+ resolved = instance.resolve_sigils(self.name)
152
+ else:
153
+ resolved = value
154
+
155
+ if resolved is None:
156
+ resolved_text = ""
157
+ else:
158
+ resolved_text = str(resolved)
159
+
160
+ resolved_text = resolved_text.strip()
161
+ if not resolved_text:
162
+ return ConditionCheckResult(True, resolved_text)
163
+
164
+ try:
165
+ passed = _evaluate_sql_condition(resolved_text)
166
+ return ConditionCheckResult(passed, resolved_text)
167
+ except ConditionEvaluationError as exc:
168
+ return ConditionCheckResult(False, resolved_text, str(exc))
core/form_fields.py CHANGED
@@ -1,75 +1,75 @@
1
- """Custom form fields for the Arthexis admin."""
2
-
3
- from __future__ import annotations
4
-
5
- import base64
6
- from typing import Any
7
-
8
- from django.core.exceptions import ValidationError
9
- from django.forms.fields import FileField
10
- from django.forms.widgets import FILE_INPUT_CONTRADICTION
11
- from django.utils.translation import gettext_lazy as _
12
-
13
- from .widgets import AdminBase64FileWidget
14
-
15
-
16
- class Base64FileField(FileField):
17
- """Form field storing uploaded files as base64 encoded strings.
18
-
19
- The field behaves like :class:`~django.forms.FileField` from the user's
20
- perspective. Uploaded files are converted to base64 and returned as text so
21
- they can be stored in ``TextField`` columns. When no new file is uploaded the
22
- initial base64 value is preserved, while clearing the field stores an empty
23
- string.
24
- """
25
-
26
- widget = AdminBase64FileWidget
27
- default_error_messages = {
28
- **FileField.default_error_messages,
29
- "contradiction": _(
30
- "Please either submit a file or check the clear checkbox, not both."
31
- ),
32
- }
33
-
34
- def __init__(
35
- self,
36
- *,
37
- download_name: str | None = None,
38
- content_type: str = "application/octet-stream",
39
- **kwargs: Any,
40
- ) -> None:
41
- widget = kwargs.pop("widget", None) or self.widget()
42
- if download_name:
43
- widget.download_name = download_name
44
- if content_type:
45
- widget.content_type = content_type
46
- super().__init__(widget=widget, **kwargs)
47
-
48
- def to_python(self, data: Any) -> str | None:
49
- """Convert uploaded data to a base64 string."""
50
-
51
- if isinstance(data, str):
52
- return data
53
- uploaded = super().to_python(data)
54
- if uploaded is None:
55
- return None
56
- content = uploaded.read()
57
- if hasattr(uploaded, "seek"):
58
- uploaded.seek(0)
59
- return base64.b64encode(content).decode("ascii")
60
-
61
- def clean(self, data: Any, initial: str | None = None) -> str:
62
- if data is FILE_INPUT_CONTRADICTION:
63
- raise ValidationError(
64
- self.error_messages["contradiction"], code="contradiction"
65
- )
66
- cleaned = super().clean(data, initial)
67
- if cleaned in {None, False}:
68
- return ""
69
- return cleaned
70
-
71
- def bound_data(self, data: Any, initial: str | None) -> str | None:
72
- return initial
73
-
74
- def has_changed(self, initial: str | None, data: Any) -> bool:
75
- return not self.disabled and data is not None
1
+ """Custom form fields for the Arthexis admin."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ from typing import Any
7
+
8
+ from django.core.exceptions import ValidationError
9
+ from django.forms.fields import FileField
10
+ from django.forms.widgets import FILE_INPUT_CONTRADICTION
11
+ from django.utils.translation import gettext_lazy as _
12
+
13
+ from .widgets import AdminBase64FileWidget
14
+
15
+
16
+ class Base64FileField(FileField):
17
+ """Form field storing uploaded files as base64 encoded strings.
18
+
19
+ The field behaves like :class:`~django.forms.FileField` from the user's
20
+ perspective. Uploaded files are converted to base64 and returned as text so
21
+ they can be stored in ``TextField`` columns. When no new file is uploaded the
22
+ initial base64 value is preserved, while clearing the field stores an empty
23
+ string.
24
+ """
25
+
26
+ widget = AdminBase64FileWidget
27
+ default_error_messages = {
28
+ **FileField.default_error_messages,
29
+ "contradiction": _(
30
+ "Please either submit a file or check the clear checkbox, not both."
31
+ ),
32
+ }
33
+
34
+ def __init__(
35
+ self,
36
+ *,
37
+ download_name: str | None = None,
38
+ content_type: str = "application/octet-stream",
39
+ **kwargs: Any,
40
+ ) -> None:
41
+ widget = kwargs.pop("widget", None) or self.widget()
42
+ if download_name:
43
+ widget.download_name = download_name
44
+ if content_type:
45
+ widget.content_type = content_type
46
+ super().__init__(widget=widget, **kwargs)
47
+
48
+ def to_python(self, data: Any) -> str | None:
49
+ """Convert uploaded data to a base64 string."""
50
+
51
+ if isinstance(data, str):
52
+ return data
53
+ uploaded = super().to_python(data)
54
+ if uploaded is None:
55
+ return None
56
+ content = uploaded.read()
57
+ if hasattr(uploaded, "seek"):
58
+ uploaded.seek(0)
59
+ return base64.b64encode(content).decode("ascii")
60
+
61
+ def clean(self, data: Any, initial: str | None = None) -> str:
62
+ if data is FILE_INPUT_CONTRADICTION:
63
+ raise ValidationError(
64
+ self.error_messages["contradiction"], code="contradiction"
65
+ )
66
+ cleaned = super().clean(data, initial)
67
+ if cleaned in {None, False}:
68
+ return ""
69
+ return cleaned
70
+
71
+ def bound_data(self, data: Any, initial: str | None) -> str | None:
72
+ return initial
73
+
74
+ def has_changed(self, initial: str | None, data: Any) -> bool:
75
+ return not self.disabled and data is not None