django-admin-mobile 0.1.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.
- admin_mobile/__init__.py +3 -0
- admin_mobile/apps.py +16 -0
- admin_mobile/checks.py +72 -0
- admin_mobile/conf.py +60 -0
- admin_mobile/mixins.py +29 -0
- admin_mobile/overrides.py +144 -0
- admin_mobile/py.typed +0 -0
- admin_mobile/static/admin_mobile/css/admin_mobile.css +885 -0
- admin_mobile/static/admin_mobile/js/admin_mobile.js +263 -0
- admin_mobile/templates/admin/base.html +13 -0
- admin_mobile/templates/admin_mobile/widgets/split_datetime.html +13 -0
- admin_mobile/templatetags/__init__.py +0 -0
- admin_mobile/templatetags/admin_mobile.py +101 -0
- admin_mobile/widgets.py +118 -0
- django_admin_mobile-0.1.0.dist-info/METADATA +188 -0
- django_admin_mobile-0.1.0.dist-info/RECORD +18 -0
- django_admin_mobile-0.1.0.dist-info/WHEEL +4 -0
- django_admin_mobile-0.1.0.dist-info/licenses/LICENSE +21 -0
admin_mobile/__init__.py
ADDED
admin_mobile/apps.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from django.apps import AppConfig
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AdminMobileConfig(AppConfig):
|
|
7
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
8
|
+
name = "admin_mobile"
|
|
9
|
+
verbose_name = "Admin mobile"
|
|
10
|
+
|
|
11
|
+
def ready(self) -> None:
|
|
12
|
+
from admin_mobile.checks import register_checks
|
|
13
|
+
from admin_mobile.overrides import install_native_datetime_patch
|
|
14
|
+
|
|
15
|
+
register_checks()
|
|
16
|
+
install_native_datetime_patch()
|
admin_mobile/checks.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Install-order check. The template tag remains valid either way."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
|
|
7
|
+
from django.core.checks import Warning, register
|
|
8
|
+
|
|
9
|
+
_REGISTERED = False
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _is_admin_mobile(entry: str) -> bool:
|
|
13
|
+
return entry == "admin_mobile" or entry.endswith(
|
|
14
|
+
"admin_mobile.apps.AdminMobileConfig"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _is_django_admin(entry: str) -> bool:
|
|
19
|
+
return entry in {
|
|
20
|
+
"django.contrib.admin",
|
|
21
|
+
"django.contrib.admin.apps.AdminConfig",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def app_order_warnings(installed_apps: Sequence[str]) -> list[Warning]:
|
|
26
|
+
mobile_at = next(
|
|
27
|
+
(
|
|
28
|
+
index
|
|
29
|
+
for index, entry in enumerate(installed_apps)
|
|
30
|
+
if _is_admin_mobile(entry)
|
|
31
|
+
),
|
|
32
|
+
None,
|
|
33
|
+
)
|
|
34
|
+
admin_at = next(
|
|
35
|
+
(
|
|
36
|
+
index
|
|
37
|
+
for index, entry in enumerate(installed_apps)
|
|
38
|
+
if _is_django_admin(entry)
|
|
39
|
+
),
|
|
40
|
+
None,
|
|
41
|
+
)
|
|
42
|
+
if mobile_at is None or admin_at is None or mobile_at < admin_at:
|
|
43
|
+
return []
|
|
44
|
+
return [
|
|
45
|
+
Warning(
|
|
46
|
+
"admin_mobile is listed after django.contrib.admin, so its "
|
|
47
|
+
"admin/base.html override will not be selected.",
|
|
48
|
+
hint=(
|
|
49
|
+
"Put 'admin_mobile' before 'django.contrib.admin' in "
|
|
50
|
+
"INSTALLED_APPS, or load {% admin_mobile_assets %} from your "
|
|
51
|
+
"own admin/base.html."
|
|
52
|
+
),
|
|
53
|
+
id="admin_mobile.W001",
|
|
54
|
+
)
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def check_installed_before_admin(
|
|
59
|
+
app_configs: object,
|
|
60
|
+
**kwargs: object,
|
|
61
|
+
) -> list[Warning]:
|
|
62
|
+
from django.conf import settings
|
|
63
|
+
|
|
64
|
+
return app_order_warnings(tuple(settings.INSTALLED_APPS))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def register_checks() -> None:
|
|
68
|
+
global _REGISTERED
|
|
69
|
+
if _REGISTERED:
|
|
70
|
+
return
|
|
71
|
+
register(check_installed_before_admin)
|
|
72
|
+
_REGISTERED = True
|
admin_mobile/conf.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Typed view of the ``ADMIN_MOBILE`` setting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from django.conf import settings
|
|
9
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
10
|
+
|
|
11
|
+
_SETTING_TO_FIELD: dict[str, str] = {
|
|
12
|
+
"ENABLED": "enabled",
|
|
13
|
+
"CHANGELIST_CARDS": "changelist_cards",
|
|
14
|
+
"TABULAR_INLINE_STACK": "tabular_inline_stack",
|
|
15
|
+
"STICKY_SUBMIT": "sticky_submit",
|
|
16
|
+
"TOUCH_TARGETS": "touch_targets",
|
|
17
|
+
"COLLAPSIBLE_FILTERS": "collapsible_filters",
|
|
18
|
+
"USE_NATIVE_DATETIME": "use_native_datetime",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class AdminMobileSettings:
|
|
24
|
+
"""Feature flags. Missing keys keep these defaults."""
|
|
25
|
+
|
|
26
|
+
enabled: bool = True
|
|
27
|
+
changelist_cards: bool = True
|
|
28
|
+
tabular_inline_stack: bool = True
|
|
29
|
+
sticky_submit: bool = True
|
|
30
|
+
touch_targets: bool = True
|
|
31
|
+
collapsible_filters: bool = True
|
|
32
|
+
use_native_datetime: bool = False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_config() -> AdminMobileSettings:
|
|
36
|
+
"""Read ``ADMIN_MOBILE`` on each call so ``override_settings`` works."""
|
|
37
|
+
|
|
38
|
+
raw = getattr(settings, "ADMIN_MOBILE", None)
|
|
39
|
+
if raw is None:
|
|
40
|
+
return AdminMobileSettings()
|
|
41
|
+
if not isinstance(raw, Mapping):
|
|
42
|
+
raise ImproperlyConfigured("ADMIN_MOBILE must be a dict of booleans.")
|
|
43
|
+
|
|
44
|
+
unknown = sorted(str(key) for key in raw if key not in _SETTING_TO_FIELD)
|
|
45
|
+
if unknown:
|
|
46
|
+
names = ", ".join(unknown)
|
|
47
|
+
allowed = ", ".join(sorted(_SETTING_TO_FIELD))
|
|
48
|
+
raise ImproperlyConfigured(
|
|
49
|
+
f"Unknown ADMIN_MOBILE keys: {names}. Allowed keys: {allowed}."
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
values: dict[str, bool] = {}
|
|
53
|
+
for key, attr in _SETTING_TO_FIELD.items():
|
|
54
|
+
if key not in raw:
|
|
55
|
+
continue
|
|
56
|
+
value = raw[key]
|
|
57
|
+
if not isinstance(value, bool):
|
|
58
|
+
raise ImproperlyConfigured(f"ADMIN_MOBILE[{key!r}] must be a bool.")
|
|
59
|
+
values[attr] = value
|
|
60
|
+
return AdminMobileSettings(**values)
|
admin_mobile/mixins.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Per-model opt-in for native date and time widgets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from django.contrib.admin.sites import AdminSite
|
|
6
|
+
from django.db import models
|
|
7
|
+
|
|
8
|
+
from admin_mobile.overrides import (
|
|
9
|
+
NATIVE_FORMFIELD_OVERRIDES,
|
|
10
|
+
install_native_overrides,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MobileAdminMixin:
|
|
15
|
+
"""Use native date widgets on one ``ModelAdmin`` or inline.
|
|
16
|
+
|
|
17
|
+
List this mixin before ``admin.ModelAdmin`` (or ``TabularInline``).
|
|
18
|
+
A custom ``formfield_overrides`` widget for a date field is left alone.
|
|
19
|
+
The global ``USE_NATIVE_DATETIME`` flag is a separate opt-in.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
formfield_overrides = {
|
|
23
|
+
field_cls: dict(options)
|
|
24
|
+
for field_cls, options in NATIVE_FORMFIELD_OVERRIDES.items()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
def __init__(self, model: type[models.Model], admin_site: AdminSite) -> None:
|
|
28
|
+
super().__init__(model, admin_site)
|
|
29
|
+
install_native_overrides(self)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Swap stock admin date widgets without touching a custom widget choice."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from django import forms
|
|
6
|
+
from django.contrib.admin import widgets as admin_widgets
|
|
7
|
+
from django.contrib.admin.options import BaseModelAdmin
|
|
8
|
+
from django.db import models
|
|
9
|
+
|
|
10
|
+
from admin_mobile.conf import get_config
|
|
11
|
+
from admin_mobile.widgets import (
|
|
12
|
+
ISO_DATE_INPUT_FORMATS,
|
|
13
|
+
ISO_TIME_INPUT_FORMATS,
|
|
14
|
+
NativeDateInput,
|
|
15
|
+
NativeSplitDateTimeWidget,
|
|
16
|
+
NativeTimeInput,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
STOCK_WIDGETS: frozenset[type[forms.Widget]] = frozenset(
|
|
20
|
+
{
|
|
21
|
+
admin_widgets.AdminDateWidget,
|
|
22
|
+
admin_widgets.AdminTimeWidget,
|
|
23
|
+
admin_widgets.AdminSplitDateTime,
|
|
24
|
+
admin_widgets.BaseAdminDateWidget,
|
|
25
|
+
admin_widgets.BaseAdminTimeWidget,
|
|
26
|
+
}
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
NATIVE_WIDGETS: frozenset[type[forms.Widget]] = frozenset(
|
|
30
|
+
{
|
|
31
|
+
NativeDateInput,
|
|
32
|
+
NativeTimeInput,
|
|
33
|
+
NativeSplitDateTimeWidget,
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
NATIVE_FORMFIELD_OVERRIDES: dict[type[models.Field], dict[str, object]] = {
|
|
38
|
+
models.DateField: {
|
|
39
|
+
"widget": NativeDateInput,
|
|
40
|
+
"input_formats": list(ISO_DATE_INPUT_FORMATS),
|
|
41
|
+
"localize": False,
|
|
42
|
+
},
|
|
43
|
+
models.TimeField: {
|
|
44
|
+
"widget": NativeTimeInput,
|
|
45
|
+
"input_formats": list(ISO_TIME_INPUT_FORMATS),
|
|
46
|
+
"localize": False,
|
|
47
|
+
},
|
|
48
|
+
models.DateTimeField: {
|
|
49
|
+
"form_class": forms.SplitDateTimeField,
|
|
50
|
+
"widget": NativeSplitDateTimeWidget,
|
|
51
|
+
"input_date_formats": list(ISO_DATE_INPUT_FORMATS),
|
|
52
|
+
"input_time_formats": list(ISO_TIME_INPUT_FORMATS),
|
|
53
|
+
"localize": False,
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
_PATCHED = False
|
|
58
|
+
_ORIGINAL_FORMFIELD = BaseModelAdmin.formfield_for_dbfield
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _native_spec(db_field: models.Field) -> dict[str, object] | None:
|
|
62
|
+
for field_cls, spec in NATIVE_FORMFIELD_OVERRIDES.items():
|
|
63
|
+
if isinstance(db_field, field_cls):
|
|
64
|
+
return spec
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _current_widget(admin: BaseModelAdmin, db_field: models.Field) -> object | None:
|
|
69
|
+
overrides = getattr(admin, "formfield_overrides", {})
|
|
70
|
+
for klass in db_field.__class__.mro():
|
|
71
|
+
options = overrides.get(klass)
|
|
72
|
+
if isinstance(options, dict) and "widget" in options:
|
|
73
|
+
return options["widget"]
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _is_replaceable(widget: object) -> bool:
|
|
78
|
+
if widget is None:
|
|
79
|
+
return True
|
|
80
|
+
return widget in STOCK_WIDGETS or widget in NATIVE_WIDGETS
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def install_native_overrides(admin: BaseModelAdmin) -> None:
|
|
84
|
+
"""Point stock date widgets at the native ones. Keep any other widget."""
|
|
85
|
+
|
|
86
|
+
overrides: dict[type[models.Field], dict[str, object]] = {
|
|
87
|
+
field_cls: dict(options)
|
|
88
|
+
for field_cls, options in admin.formfield_overrides.items()
|
|
89
|
+
if isinstance(options, dict)
|
|
90
|
+
}
|
|
91
|
+
for field_cls, spec in NATIVE_FORMFIELD_OVERRIDES.items():
|
|
92
|
+
current = dict(overrides.get(field_cls, {}))
|
|
93
|
+
if not _is_replaceable(current.get("widget")):
|
|
94
|
+
continue
|
|
95
|
+
current.update(spec)
|
|
96
|
+
overrides[field_cls] = current
|
|
97
|
+
admin.formfield_overrides = overrides
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def apply_native_datetime(
|
|
101
|
+
admin: BaseModelAdmin,
|
|
102
|
+
db_field: models.Field,
|
|
103
|
+
kwargs: dict[str, object],
|
|
104
|
+
) -> None:
|
|
105
|
+
"""Fill native widget kwargs when the admin is still on the stock widget.
|
|
106
|
+
|
|
107
|
+
``formfield_for_dbfield`` merges ``formfield_overrides`` under ``kwargs``,
|
|
108
|
+
so values placed here win over ``AdminDateWidget`` and lose to an explicit
|
|
109
|
+
widget argument from a caller.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
if db_field.choices:
|
|
113
|
+
return
|
|
114
|
+
spec = _native_spec(db_field)
|
|
115
|
+
if spec is None or not _is_replaceable(_current_widget(admin, db_field)):
|
|
116
|
+
return
|
|
117
|
+
for key, value in spec.items():
|
|
118
|
+
kwargs.setdefault(key, value)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def install_native_datetime_patch() -> None:
|
|
122
|
+
"""Wrap ``BaseModelAdmin.formfield_for_dbfield`` once.
|
|
123
|
+
|
|
124
|
+
The wrapper checks the setting on every call so tests can toggle it.
|
|
125
|
+
Inlines inherit ``BaseModelAdmin``, not ``ModelAdmin``, so the patch has
|
|
126
|
+
to sit on the shared base.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
global _PATCHED
|
|
130
|
+
if _PATCHED:
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
def formfield_for_dbfield(
|
|
134
|
+
self: BaseModelAdmin,
|
|
135
|
+
db_field: models.Field,
|
|
136
|
+
request: object,
|
|
137
|
+
**kwargs: object,
|
|
138
|
+
) -> forms.Field | None:
|
|
139
|
+
if get_config().use_native_datetime:
|
|
140
|
+
apply_native_datetime(self, db_field, kwargs)
|
|
141
|
+
return _ORIGINAL_FORMFIELD(self, db_field, request, **kwargs)
|
|
142
|
+
|
|
143
|
+
BaseModelAdmin.formfield_for_dbfield = formfield_for_dbfield
|
|
144
|
+
_PATCHED = True
|
admin_mobile/py.typed
ADDED
|
File without changes
|