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.
@@ -0,0 +1,3 @@
1
+ """Phone ergonomics for the native Django admin."""
2
+
3
+ __version__ = "0.1.0"
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