nilva-django-admin 0.2.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,15 @@
1
+ """Nilva Django Admin — the Nilva house Django-admin toolkit.
2
+
3
+ The ``BaseModelAdmin`` / ``BaseInlineAdmin`` composition every Nilva backend
4
+ re-declares (django-object-actions + django-autoutils avatar/filter/JSON
5
+ helpers + editor auditing + Jalali datepickers), the django-autoutils
6
+ Django-6 compatibility patch, an in-house Keycloak SSO admin login (no
7
+ django-admin-keycloak), the Cheshmeh-style captcha on the login form and on
8
+ selected models' change forms, and ``NilvaAdminSite`` with settings-driven
9
+ dynamic model visibility. Theming comes from ``django-admin-interface``.
10
+
11
+ Import the pieces explicitly — ``base``/``sites``/``captcha``/``sso`` need
12
+ Django; importing this package itself pulls nothing heavy.
13
+ """
14
+
15
+ __version__ = "0.2.0"
@@ -0,0 +1,19 @@
1
+ """AdminConfig swap-in — lives in its own module so Django's default-AppConfig
2
+ detection for ``nilva_django_admin`` stays unambiguous.
3
+ """
4
+
5
+ from django.contrib.admin.apps import AdminConfig
6
+
7
+
8
+ class NilvaAdminConfig(AdminConfig):
9
+ """Use in INSTALLED_APPS *instead of* ``django.contrib.admin`` so
10
+ ``admin.site`` becomes a :class:`nilva_django_admin.sites.NilvaAdminSite`::
11
+
12
+ INSTALLED_APPS = [
13
+ ...
14
+ "nilva_django_admin",
15
+ "nilva_django_admin.admin_apps.NilvaAdminConfig", # not django.contrib.admin
16
+ ]
17
+ """
18
+
19
+ default_site = "nilva_django_admin.sites.NilvaAdminSite"
@@ -0,0 +1,7 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class NilvaDjangoAdminConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "nilva_django_admin"
7
+ verbose_name = "Nilva Admin"
@@ -0,0 +1,175 @@
1
+ """House ``BaseModelAdmin`` / ``BaseInlineAdmin`` — the Shahab composition,
2
+ decoupled from project models via duck-typing.
3
+
4
+ Every admin class inherits from these instead of ``admin.ModelAdmin`` /
5
+ ``admin.TabularInline`` directly. They compose django-object-actions,
6
+ django-autoutils (advanced filters, avatars, edit links, limited FKs, pretty
7
+ JSON, related inlines) and editor auditing, and honour the model protocols
8
+ the fleet already uses:
9
+
10
+ * ``get_obj()`` — permission-object indirection (``BASE_PERMISSION_OBJECT``)
11
+ * ``get_prefetch_related_fields()`` — class-level ``PREFETCH_RELATED`` lists
12
+ * ``avatar_img_tag(size=…)`` — avatar-model rendering
13
+ * ``editor`` — audit field shown via :meth:`AbstractEditorAdmin.editor_icon`
14
+ """
15
+
16
+ import logging
17
+ from urllib import parse
18
+
19
+ # Importing django_jalali.admin patches Django's admin FORMFIELD_FOR_DBFIELD_DEFAULTS
20
+ # so jDateField/jDateTimeField render with the Persian datepicker widget. This
21
+ # must happen before any ModelAdmin class is defined, so it lives here in the
22
+ # base admin module that every app imports. Optional: skipped when
23
+ # django-jalali isn't installed.
24
+ try:
25
+ import django_jalali.admin # noqa: F401
26
+ except ImportError:
27
+ pass
28
+
29
+ from django.contrib import admin
30
+ from django.db.models import QuerySet
31
+ from django.utils.translation import gettext_lazy as _
32
+ from django_autoutils.admin_utils import (
33
+ AdvanceListFilter,
34
+ AvatarAdmin,
35
+ EditLinkAdmin,
36
+ InlineRelatedAdmin,
37
+ LimitForeignKeyAdmin,
38
+ PrettyJsonAdmin,
39
+ admin_display,
40
+ )
41
+ from django_autoutils.html_tag import get_edit_icon
42
+ from django_object_actions import DjangoObjectActions
43
+
44
+ from .captcha import captcha_protected, model_requires_captcha
45
+ from .compat import patch_autoutils_html_tag
46
+ from .settings import admin_settings
47
+
48
+ patch_autoutils_html_tag()
49
+
50
+ logger = logging.getLogger("nilva_django_admin")
51
+
52
+
53
+ def _prefetch_for(model, queryset: QuerySet) -> QuerySet:
54
+ if hasattr(model, "get_prefetch_related_fields"):
55
+ return queryset.prefetch_related(*model.get_prefetch_related_fields())
56
+ return queryset
57
+
58
+
59
+ class AbstractEditorAdmin:
60
+ """Mixin for models carrying an ``editor`` audit field."""
61
+
62
+ def _handle_instance(self, obj):
63
+ """For change extra field from inline object call this function."""
64
+
65
+ @admin_display(description=_("editor"))
66
+ def editor_icon(self, obj):
67
+ """Editor icon with editor name in admin panel object page."""
68
+ if not obj:
69
+ return None
70
+ if not hasattr(obj, "editor"):
71
+ return None
72
+ return get_edit_icon(obj.editor)
73
+
74
+
75
+ class BaseModelAdmin(
76
+ DjangoObjectActions,
77
+ AdvanceListFilter,
78
+ AvatarAdmin,
79
+ AbstractEditorAdmin,
80
+ LimitForeignKeyAdmin,
81
+ PrettyJsonAdmin,
82
+ InlineRelatedAdmin,
83
+ admin.ModelAdmin,
84
+ ):
85
+ """Base admin for all models."""
86
+
87
+ search_fields = ("=id",)
88
+ show_full_result_count = False
89
+
90
+ def __init__(self, model, admin_site):
91
+ self.list_per_page = admin_settings.LIST_PER_PAGE
92
+ if not getattr(self, "avatar_icon_field", None):
93
+ self.avatar_icon_field = admin_settings.AVATAR_ICON_FIELD
94
+ super().__init__(model, admin_site)
95
+
96
+ def filter_queryset_by_url(self, request, queryset):
97
+ """Re-apply the changelist filters/search carried in
98
+ ``_changelist_filters`` (object-action round-trips)."""
99
+ changelist_filters = request.GET.get("_changelist_filters", "")
100
+ filters = dict(parse.parse_qsl(changelist_filters))
101
+ filter_fields_data = {}
102
+ for k, v in filters.items():
103
+ if k == "q" and v:
104
+ queryset = self.get_search_results(request, queryset, v)[0]
105
+ elif self.lookup_allowed(k, v, request):
106
+ filter_fields_data[k] = v
107
+ return queryset.filter(**filter_fields_data)
108
+
109
+ def _get_avatar_obj(self, obj):
110
+ return self.get_obj(obj)
111
+
112
+ @staticmethod
113
+ def get_obj(obj):
114
+ """Follow the ``get_obj()`` permission-object protocol when present."""
115
+ if hasattr(obj, "get_obj"):
116
+ return obj.get_obj()
117
+ return obj
118
+
119
+ @admin_display(description=_("icon"))
120
+ def avatar_icon(self, obj=None):
121
+ """Render the avatar via the model's ``avatar_img_tag`` when it has
122
+ one, else fall back to django-autoutils' implementation."""
123
+ target = self._get_avatar_obj(obj) if obj is not None else None
124
+ if hasattr(target, "avatar_img_tag"):
125
+ return target.avatar_img_tag(size=30)
126
+ return super().avatar_icon(obj=obj)
127
+
128
+ def get_queryset(self, request):
129
+ return _prefetch_for(self.model, super().get_queryset(request))
130
+
131
+ def get_form(self, request, obj=None, **kwargs):
132
+ if model_requires_captcha(self.model):
133
+ # inject BEFORE the factory runs so the declared captcha fields
134
+ # legitimise their names in the derived Meta.fields list
135
+ kwargs["form"] = captcha_protected(kwargs.get("form", self.form))
136
+ return super().get_form(request, obj, **kwargs)
137
+
138
+ def get_fieldsets(self, request, obj=None):
139
+ fieldsets = super().get_fieldsets(request, obj)
140
+ if model_requires_captcha(self.model):
141
+ from django.contrib.admin.utils import flatten_fieldsets
142
+
143
+ if "captcha_code" not in flatten_fieldsets(fieldsets):
144
+ fieldsets = [*fieldsets, (_("CAPTCHA"), {"fields": ("captcha_id", "captcha_code")})]
145
+ return fieldsets
146
+
147
+ @staticmethod
148
+ def get_back_url_for_changelist(request):
149
+ return "/".join(request.get_full_path().split("/")[:-3])
150
+
151
+ @staticmethod
152
+ def get_back_url(request):
153
+ full_path = request.get_full_path()
154
+ if "actions" not in full_path:
155
+ return full_path
156
+ return "/".join(full_path.split("/")[:-3])
157
+
158
+
159
+ class BaseInlineAdmin(AvatarAdmin, EditLinkAdmin, LimitForeignKeyAdmin, AbstractEditorAdmin, admin.TabularInline):
160
+ """Base admin for all inlines."""
161
+
162
+ extra = 0
163
+
164
+ def __init__(self, parent_model, admin_site):
165
+ if not getattr(self, "avatar_icon_field", None):
166
+ self.avatar_icon_field = admin_settings.AVATAR_ICON_FIELD
167
+ super().__init__(parent_model, admin_site)
168
+
169
+ def get_queryset(self, request):
170
+ return _prefetch_for(self.model, super().get_queryset(request))
171
+
172
+ def _get_avatar_obj(self, obj):
173
+ if hasattr(obj, "get_obj"):
174
+ return obj.get_obj()
175
+ return obj
@@ -0,0 +1,159 @@
1
+ """Captcha for the admin — port of Cheshmeh's ``CaptchaService``.
2
+
3
+ Talks to the MCI/EBCOM-style captcha HTTP service: ``GET
4
+ {BASE_URL}?width=…&height=…&type=…`` returns ``{"id": …, "image": …}`` and
5
+ ``GET {BASE_URL}/{id}/verify/{code}`` answers 200 for a correct code.
6
+
7
+ Two admin surfaces use it:
8
+
9
+ * the login form (``NILVA_ADMIN["LOGIN_CAPTCHA"] = True``), and
10
+ * the add/change forms of every model listed in
11
+ ``NILVA_ADMIN["CAPTCHA_MODELS"]`` (injected by ``BaseModelAdmin``).
12
+
13
+ The captcha id travels in a hidden form field (stateless — no session), and a
14
+ failed submit automatically swaps in a fresh captcha for the redisplay.
15
+ """
16
+
17
+ import logging
18
+
19
+ import requests
20
+ from django import forms
21
+ from django.core.exceptions import ValidationError
22
+ from django.utils.html import format_html
23
+ from django.utils.translation import gettext_lazy as _
24
+
25
+ from .settings import admin_settings
26
+
27
+ logger = logging.getLogger("nilva_django_admin")
28
+
29
+ #: Persian / Arabic-Indic digits -> ASCII for typed captcha codes.
30
+ _DIGIT_MAP = str.maketrans("۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩", "01234567890123456789")
31
+
32
+
33
+ class CaptchaError(Exception):
34
+ """The captcha service could not be reached or answered garbage."""
35
+
36
+
37
+ class CaptchaService:
38
+ """Client for the captcha HTTP service (config: ``NILVA_ADMIN["CAPTCHA"]``)."""
39
+
40
+ def __init__(self, config: dict | None = None):
41
+ self.config = dict(config) if config is not None else dict(admin_settings.CAPTCHA)
42
+
43
+ @property
44
+ def base_url(self) -> str:
45
+ base_url = self.config.get("BASE_URL")
46
+ if not base_url:
47
+ raise CaptchaError("NILVA_ADMIN['CAPTCHA']['BASE_URL'] is required for captcha support")
48
+ return str(base_url).rstrip("/")
49
+
50
+ @property
51
+ def _proxies(self) -> dict | None:
52
+ proxy = self.config.get("PROXY")
53
+ return {"http": proxy, "https": proxy} if proxy else None
54
+
55
+ def _get(self, url: str) -> requests.Response:
56
+ try:
57
+ return requests.get(url, proxies=self._proxies, timeout=self.config.get("TIMEOUT", 5.0))
58
+ except requests.RequestException as exc:
59
+ logger.error("captcha service unreachable", extra={"url": url, "error_info": str(exc)})
60
+ raise CaptchaError(f"captcha service unreachable: {exc}") from exc
61
+
62
+ def get_captcha(self) -> dict:
63
+ """A fresh challenge: ``{"id": …, "image": …}`` (image is URL/data-URI)."""
64
+ url = (
65
+ f"{self.base_url}?width={self.config.get('WIDTH')}"
66
+ f"&height={self.config.get('HEIGHT')}&type={self.config.get('TYPE')}"
67
+ )
68
+ response = self._get(url)
69
+ if response.status_code != 200:
70
+ raise CaptchaError(f"captcha service returned {response.status_code}")
71
+ try:
72
+ return response.json()["result"]["data"]
73
+ except (ValueError, KeyError) as exc:
74
+ raise CaptchaError("captcha service returned an unexpected payload") from exc
75
+
76
+ def validate(self, captcha_id, code) -> bool:
77
+ """True when ``code`` solves challenge ``captcha_id``.
78
+
79
+ The staging escape hatch (``VALIDATION_IGNORE``) is honoured only
80
+ when ``DEBUG=True`` so it is impossible to enable in production.
81
+ """
82
+ if self.config.get("VALIDATION_IGNORE"):
83
+ from django.conf import settings as django_settings
84
+
85
+ if getattr(django_settings, "DEBUG", False):
86
+ return True
87
+ code = str(code or "").strip().translate(_DIGIT_MAP)
88
+ if not captcha_id or not code:
89
+ return False
90
+ response = self._get(f"{self.base_url}/{captcha_id}/verify/{code}")
91
+ return response.status_code == 200
92
+
93
+
94
+ class CaptchaFormMixin(forms.Form):
95
+ """Adds a stateless captcha challenge to any form (model or auth).
96
+
97
+ The challenge image is exposed as ``form.captcha_image`` and also embedded
98
+ in the ``captcha_code`` field's help text, so it renders with default
99
+ admin templates without any template override.
100
+ """
101
+
102
+ captcha_id = forms.CharField(widget=forms.HiddenInput, required=True, label="")
103
+ captcha_code = forms.CharField(label=_("CAPTCHA"), required=True)
104
+
105
+ def __init__(self, *args, **kwargs):
106
+ super().__init__(*args, **kwargs)
107
+ self.captcha_image = None
108
+ if not self.is_bound:
109
+ self.refresh_captcha()
110
+
111
+ def refresh_captcha(self):
112
+ """Fetch a fresh challenge and wire it into the (possibly bound) form."""
113
+ try:
114
+ data = CaptchaService().get_captcha()
115
+ except CaptchaError:
116
+ data = {}
117
+ captcha_id = data.get("id", "")
118
+ self.captcha_image = data.get("image")
119
+ self.initial["captcha_id"] = captcha_id
120
+ if self.captcha_image:
121
+ self.fields["captcha_code"].help_text = format_html(
122
+ '<img src="{}" alt="CAPTCHA" class="nilva-captcha-image"/>', self.captcha_image
123
+ )
124
+ if self.is_bound:
125
+ mutable = self.data.copy()
126
+ mutable[self.add_prefix("captcha_id")] = str(captcha_id)
127
+ mutable[self.add_prefix("captcha_code")] = ""
128
+ self.data = mutable
129
+
130
+ def clean(self):
131
+ cleaned_data = super().clean()
132
+ captcha_id = cleaned_data.get("captcha_id")
133
+ code = cleaned_data.get("captcha_code")
134
+ try:
135
+ valid = CaptchaService().validate(captcha_id, code)
136
+ except CaptchaError:
137
+ self.refresh_captcha()
138
+ raise ValidationError(
139
+ _("The CAPTCHA service is unavailable. Please try again."), code="captcha_unavailable"
140
+ ) from None
141
+ if not valid:
142
+ self.refresh_captcha()
143
+ raise ValidationError(_("Invalid CAPTCHA. Please try again"), code="invalid_captcha")
144
+ return cleaned_data
145
+
146
+
147
+ def captcha_protected(form_class: type[forms.BaseForm]) -> type[forms.BaseForm]:
148
+ """``form_class`` with the captcha mixin prepended (idempotent)."""
149
+ if issubclass(form_class, CaptchaFormMixin):
150
+ return form_class
151
+ return type(f"Captcha{form_class.__name__}", (CaptchaFormMixin, form_class), {})
152
+
153
+
154
+ def model_requires_captcha(model) -> bool:
155
+ """True when ``model`` matches a ``NILVA_ADMIN["CAPTCHA_MODELS"]`` pattern."""
156
+ from fnmatch import fnmatch
157
+
158
+ label = f"{model._meta.app_label}.{model._meta.object_name}".lower()
159
+ return any(fnmatch(label, str(pattern).lower()) for pattern in admin_settings.CAPTCHA_MODELS)
@@ -0,0 +1,47 @@
1
+ """django-autoutils Django-6 compatibility patch.
2
+
3
+ django-autoutils 2.1.1 builds image HTML with ``format_html(f"...")`` passing
4
+ NO format args. Django 6's ``format_html`` raises "args or kwargs must be
5
+ provided." for that, which 500s every admin page rendering an avatar (e.g.
6
+ the user change view). These replacements use real placeholders — which also
7
+ restores auto-escaping and fixes the invalid ``<image>`` tag — and are
8
+ patched onto the library module so its ``get_avatar_image`` /
9
+ ``get_edit_icon`` pick them up.
10
+
11
+ Applied automatically when :mod:`nilva_django_admin.base` is imported;
12
+ idempotent.
13
+ """
14
+
15
+ from django.utils.html import format_html
16
+
17
+
18
+ def _safe_get_image_link(image, width, height, border_radius, title="image", link=""):
19
+ return format_html(
20
+ '<a href="{}"><img src="{}" width="{}" height="{}" title="{}" style="border-radius: {}%"/></a>',
21
+ link,
22
+ image,
23
+ width,
24
+ height,
25
+ title,
26
+ border_radius,
27
+ )
28
+
29
+
30
+ def _safe_get_image_text(image, width, height, border_radius, text):
31
+ return format_html(
32
+ '<div><img src="{}" width="{}" height="{}" title="{}" style="border-radius: {}%"/>{}</div>',
33
+ image,
34
+ width,
35
+ height,
36
+ text,
37
+ border_radius,
38
+ text,
39
+ )
40
+
41
+
42
+ def patch_autoutils_html_tag():
43
+ """Install the safe builders onto ``django_autoutils.html_tag``."""
44
+ import django_autoutils.html_tag as html_tag
45
+
46
+ html_tag.get_image_link = _safe_get_image_link
47
+ html_tag.get_image_text = _safe_get_image_text
@@ -0,0 +1,135 @@
1
+ """Library settings — overridable via the ``NILVA_ADMIN`` dict in Django settings.
2
+
3
+ Follows the ``nilva-django-logger`` settings-object pattern::
4
+
5
+ # settings.py
6
+ NILVA_ADMIN = {
7
+ "APP_NAME": "shahab",
8
+ "SITE_TITLE": "Shahab administration",
9
+ "LOGIN_CAPTCHA": True,
10
+ "CAPTCHA_MODELS": ("user.User",),
11
+ "HIDDEN_MODELS": ("auth.Group",),
12
+ "KEYCLOAK": {
13
+ "SERVER_URL": "https://sso.nilva.ai",
14
+ "REALM_NAME": "nilva",
15
+ "CLIENT_ID": "...",
16
+ "CLIENT_SECRET": "...",
17
+ },
18
+ "CAPTCHA": {"BASE_URL": "https://.../services/captcha/v1.0"},
19
+ }
20
+
21
+ # anywhere
22
+ from nilva_django_admin.settings import admin_settings
23
+ admin_settings.LIST_PER_PAGE
24
+
25
+ Dict-valued blocks (``KEYCLOAK``, ``CAPTCHA``) merge key-by-key over their
26
+ defaults. Django is optional at import time; before ``django.conf`` is
27
+ configured the defaults apply.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ DEFAULTS = {
33
+ # Used to derive default Keycloak role names ("{APP_NAME}_staff", …).
34
+ "APP_NAME": None,
35
+ # BaseModelAdmin defaults.
36
+ "LIST_PER_PAGE": 20,
37
+ "AVATAR_ICON_FIELD": "avatar_32",
38
+ # NilvaAdminSite: header/title override and dynamic model visibility.
39
+ # VISIBLE_MODELS None → every registered model; else fnmatch patterns like
40
+ # "worklog.*" or "user.User". HIDDEN_MODELS always removes.
41
+ "SITE_TITLE": None,
42
+ "VISIBLE_MODELS": None,
43
+ "HIDDEN_MODELS": (),
44
+ # Admin login.
45
+ "LOGIN_CAPTCHA": False,
46
+ "SSO_BUTTON_TEXT": "Login with SSO",
47
+ # Change-form captcha: "app_label.Model" patterns whose admin add/change
48
+ # forms require a captcha.
49
+ "CAPTCHA_MODELS": (),
50
+ # The MCI/EBCOM-style captcha HTTP service (Cheshmeh's CaptchaService).
51
+ "CAPTCHA": {
52
+ "BASE_URL": None, # e.g. https://host/services/captcha/v1.0
53
+ "WIDTH": 200,
54
+ "HEIGHT": 100,
55
+ "TYPE": "DIGITS",
56
+ "PROXY": None,
57
+ "TIMEOUT": 5.0,
58
+ "VALIDATION_IGNORE": False, # honoured only when DEBUG=True
59
+ },
60
+ # In-house Keycloak SSO login for the admin (no django-admin-keycloak).
61
+ "KEYCLOAK": {
62
+ "SERVER_URL": None,
63
+ "REALM_NAME": None,
64
+ "CLIENT_ID": None,
65
+ "CLIENT_SECRET": None,
66
+ "REDIRECT_URI": "/admin/", # post-login destination
67
+ "SCOPE": "openid",
68
+ "ROLE_STAFF_USER": None, # None → "{APP_NAME}_staff"
69
+ "ROLE_SUPER_USER": None, # None → "{APP_NAME}_super_user"
70
+ "SYNC_GROUPS": True, # mirror Keycloak groups/roles onto django Groups
71
+ "VERIFY_SSL": False,
72
+ "TIMEOUT": 5.0,
73
+ },
74
+ }
75
+
76
+
77
+ def _django_user_settings() -> dict:
78
+ try:
79
+ from django.conf import settings as django_settings
80
+ except ImportError:
81
+ return {}
82
+ if not django_settings.configured:
83
+ return {}
84
+ return getattr(django_settings, "NILVA_ADMIN", {}) or {}
85
+
86
+
87
+ class AdminSettings:
88
+ """Attribute access over ``DEFAULTS`` overridden by ``NILVA_ADMIN``."""
89
+
90
+ def __init__(self, defaults: dict | None = None):
91
+ self.defaults = defaults or DEFAULTS
92
+ self._cached_attrs = set()
93
+
94
+ @property
95
+ def user_settings(self) -> dict:
96
+ if not hasattr(self, "_user_settings"):
97
+ self._user_settings = _django_user_settings()
98
+ return self._user_settings
99
+
100
+ def __getattr__(self, attr):
101
+ if attr not in self.defaults:
102
+ raise AttributeError(f"Invalid NILVA_ADMIN setting: {attr!r}")
103
+ default = self.defaults[attr]
104
+ try:
105
+ value = self.user_settings[attr]
106
+ if isinstance(default, dict) and isinstance(value, dict):
107
+ value = {**default, **value}
108
+ except KeyError:
109
+ value = default
110
+ self._cached_attrs.add(attr)
111
+ setattr(self, attr, value)
112
+ return value
113
+
114
+ def reload(self):
115
+ for attr in self._cached_attrs:
116
+ delattr(self, attr)
117
+ self._cached_attrs.clear()
118
+ if hasattr(self, "_user_settings"):
119
+ del self._user_settings
120
+
121
+
122
+ admin_settings = AdminSettings()
123
+
124
+
125
+ def _reload_admin_settings(*args, **kwargs):
126
+ if kwargs.get("setting") == "NILVA_ADMIN":
127
+ admin_settings.reload()
128
+
129
+
130
+ try: # keep @override_settings(NILVA_ADMIN=...) working in tests
131
+ from django.test.signals import setting_changed
132
+
133
+ setting_changed.connect(_reload_admin_settings)
134
+ except ImportError: # pragma: no cover - plain-Python usage
135
+ pass
@@ -0,0 +1,95 @@
1
+ """``NilvaAdminSite`` — dynamic model visibility, SSO login button,
2
+ optional login captcha and settings-driven titles.
3
+
4
+ Swap it in by replacing ``django.contrib.admin`` in ``INSTALLED_APPS`` with
5
+ ``"nilva_django_admin.apps.NilvaAdminConfig"`` (plus ``"nilva_django_admin"``
6
+ itself for the login template).
7
+
8
+ Model visibility is dynamic: ``NILVA_ADMIN["VISIBLE_MODELS"]`` /
9
+ ``["HIDDEN_MODELS"]`` take ``fnmatch`` patterns ("user.User", "worklog.*")
10
+ evaluated per request, so which models show up in the admin follows settings
11
+ without code changes. ``auto_register()`` registers every still-unregistered
12
+ concrete model with :class:`~nilva_django_admin.base.BaseModelAdmin`.
13
+ """
14
+
15
+ from fnmatch import fnmatch
16
+
17
+ from django.apps import apps as django_apps
18
+ from django.contrib import admin
19
+ from django.contrib.admin.exceptions import AlreadyRegistered
20
+ from django.contrib.admin.forms import AdminAuthenticationForm
21
+ from django.urls import NoReverseMatch, reverse
22
+
23
+ from .captcha import captcha_protected
24
+ from .settings import admin_settings
25
+ from .sso import KeycloakSSO
26
+
27
+ CaptchaAdminAuthenticationForm = captcha_protected(AdminAuthenticationForm)
28
+
29
+
30
+ def _matches(model_label: str, patterns) -> bool:
31
+ return any(fnmatch(model_label, str(pattern).lower()) for pattern in patterns)
32
+
33
+
34
+ def model_is_visible(model) -> bool:
35
+ """Apply the VISIBLE_MODELS whitelist (None → all) then HIDDEN_MODELS."""
36
+ label = f"{model._meta.app_label}.{model._meta.object_name}".lower()
37
+ visible = admin_settings.VISIBLE_MODELS
38
+ if visible is not None and not _matches(label, visible):
39
+ return False
40
+ return not _matches(label, admin_settings.HIDDEN_MODELS)
41
+
42
+
43
+ class NilvaAdminSite(admin.AdminSite):
44
+ login_template = "nilva_django_admin/login.html"
45
+
46
+ @property
47
+ def login_form(self):
48
+ if admin_settings.LOGIN_CAPTCHA:
49
+ return CaptchaAdminAuthenticationForm
50
+ return AdminAuthenticationForm
51
+
52
+ def each_context(self, request):
53
+ context = super().each_context(request)
54
+ title = admin_settings.SITE_TITLE
55
+ if title:
56
+ context["site_header"] = title
57
+ context["site_title"] = title
58
+ context["sso_button_text"] = admin_settings.SSO_BUTTON_TEXT
59
+ context["sso_login_url"] = None
60
+ if KeycloakSSO.is_configured():
61
+ try:
62
+ context["sso_login_url"] = reverse("nilva-sso-login")
63
+ except NoReverseMatch: # urls not mounted — hide the button
64
+ pass
65
+ return context
66
+
67
+ def get_app_list(self, request, app_label=None):
68
+ app_list = super().get_app_list(request, app_label)
69
+ filtered = []
70
+ for app in app_list:
71
+ models = [entry for entry in app["models"] if model_is_visible(entry["model"])]
72
+ if models:
73
+ filtered.append({**app, "models": models})
74
+ return filtered
75
+
76
+ def auto_register(self, admin_class=None, exclude=()):
77
+ """Register every unregistered concrete model with ``BaseModelAdmin``
78
+ (or ``admin_class``). ``exclude`` takes the same fnmatch patterns."""
79
+ if admin_class is None:
80
+ from .base import BaseModelAdmin
81
+
82
+ admin_class = BaseModelAdmin
83
+ registered = []
84
+ for model in django_apps.get_models():
85
+ label = f"{model._meta.app_label}.{model._meta.object_name}".lower()
86
+ if model._meta.auto_created or model._meta.abstract or model._meta.swapped:
87
+ continue
88
+ if model in self._registry or _matches(label, exclude):
89
+ continue
90
+ try:
91
+ self.register(model, admin_class)
92
+ registered.append(model)
93
+ except AlreadyRegistered: # pragma: no cover - raced by app admin.py
94
+ pass
95
+ return registered
@@ -0,0 +1,186 @@
1
+ """In-house Keycloak SSO for the Django admin — replaces django-admin-keycloak.
2
+
3
+ Plain OIDC authorization-code flow over ``requests``: redirect to the realm's
4
+ ``/auth`` endpoint, exchange the code at ``/token``, read the profile from
5
+ ``/userinfo`` and the realm roles from the (server-to-server obtained) access
6
+ token payload. Role mapping follows the house convention:
7
+ ``{APP_NAME}_staff`` → ``is_staff``, ``{APP_NAME}_super_user`` →
8
+ ``is_superuser``; users without the staff role are rejected. Keycloak
9
+ groups/roles are optionally mirrored onto existing Django ``Group`` rows.
10
+
11
+ Config: ``NILVA_ADMIN["KEYCLOAK"]`` (see :mod:`nilva_django_admin.settings`).
12
+ """
13
+
14
+ import base64
15
+ import binascii
16
+ import json
17
+ import logging
18
+ from urllib.parse import urlencode
19
+
20
+ import requests
21
+ from django.contrib.auth import get_user_model, login
22
+ from django.contrib.auth.models import Group
23
+ from django.core.exceptions import ImproperlyConfigured, PermissionDenied
24
+
25
+ from .settings import admin_settings
26
+
27
+ logger = logging.getLogger("nilva_django_admin")
28
+
29
+ STATE_SESSION_KEY = "nilva_sso_state"
30
+ NEXT_SESSION_KEY = "nilva_sso_next"
31
+ ID_TOKEN_SESSION_KEY = "nilva_sso_id_token"
32
+
33
+
34
+ class SSOError(Exception):
35
+ """Token exchange / userinfo failure."""
36
+
37
+
38
+ def _decode_jwt_payload(token: str) -> dict:
39
+ """Payload of a JWT *without* signature verification — only ever used on
40
+ tokens received directly from the token endpoint over TLS."""
41
+ try:
42
+ payload = token.split(".")[1]
43
+ padded = payload + "=" * (-len(payload) % 4)
44
+ return json.loads(base64.urlsafe_b64decode(padded))
45
+ except IndexError, ValueError, binascii.Error:
46
+ return {}
47
+
48
+
49
+ class KeycloakSSO:
50
+ """OIDC client over the ``NILVA_ADMIN["KEYCLOAK"]`` block."""
51
+
52
+ def __init__(self, config: dict | None = None):
53
+ self.config = dict(config) if config is not None else dict(admin_settings.KEYCLOAK)
54
+ missing = [key for key in ("SERVER_URL", "REALM_NAME", "CLIENT_ID") if not self.config.get(key)]
55
+ if missing:
56
+ raise ImproperlyConfigured(f"NILVA_ADMIN['KEYCLOAK'] is missing: {', '.join(missing)}")
57
+
58
+ @classmethod
59
+ def is_configured(cls) -> bool:
60
+ conf = admin_settings.KEYCLOAK
61
+ return bool(conf.get("SERVER_URL") and conf.get("REALM_NAME") and conf.get("CLIENT_ID"))
62
+
63
+ @property
64
+ def _realm_url(self) -> str:
65
+ server = str(self.config["SERVER_URL"]).rstrip("/")
66
+ return f"{server}/realms/{self.config['REALM_NAME']}/protocol/openid-connect"
67
+
68
+ def authorize_url(self, redirect_uri: str, state: str) -> str:
69
+ params = urlencode(
70
+ {
71
+ "client_id": self.config["CLIENT_ID"],
72
+ "response_type": "code",
73
+ "scope": self.config.get("SCOPE") or "openid",
74
+ "redirect_uri": redirect_uri,
75
+ "state": state,
76
+ }
77
+ )
78
+ return f"{self._realm_url}/auth?{params}"
79
+
80
+ def _request(self, method: str, url: str, **kwargs) -> dict:
81
+ try:
82
+ response = requests.request(
83
+ method,
84
+ url,
85
+ timeout=self.config.get("TIMEOUT", 5.0),
86
+ verify=self.config.get("VERIFY_SSL", False),
87
+ **kwargs,
88
+ )
89
+ except requests.RequestException as exc:
90
+ raise SSOError(f"keycloak unreachable: {exc}") from exc
91
+ if response.status_code != 200:
92
+ raise SSOError(f"keycloak returned {response.status_code}: {response.text}")
93
+ try:
94
+ return response.json()
95
+ except ValueError as exc:
96
+ raise SSOError("keycloak returned a non-JSON response") from exc
97
+
98
+ def exchange_code(self, code: str, redirect_uri: str) -> dict:
99
+ return self._request(
100
+ "post",
101
+ f"{self._realm_url}/token",
102
+ data={
103
+ "grant_type": "authorization_code",
104
+ "code": code,
105
+ "redirect_uri": redirect_uri,
106
+ "client_id": self.config["CLIENT_ID"],
107
+ "client_secret": self.config.get("CLIENT_SECRET") or "",
108
+ },
109
+ )
110
+
111
+ def userinfo(self, access_token: str) -> dict:
112
+ return self._request("get", f"{self._realm_url}/userinfo", headers={"Authorization": f"Bearer {access_token}"})
113
+
114
+ def logout_url(self, post_logout_redirect_uri: str, id_token: str | None = None) -> str:
115
+ params = {"client_id": self.config["CLIENT_ID"], "post_logout_redirect_uri": post_logout_redirect_uri}
116
+ if id_token:
117
+ params["id_token_hint"] = id_token
118
+ return f"{self._realm_url}/logout?{urlencode(params)}"
119
+
120
+ # ── role / user mapping ─────────────────────────────────────────────────
121
+
122
+ def _role_names(self) -> tuple[str, str]:
123
+ app_name = admin_settings.APP_NAME
124
+ staff = self.config.get("ROLE_STAFF_USER") or (app_name and f"{app_name}_staff")
125
+ superuser = self.config.get("ROLE_SUPER_USER") or (app_name and f"{app_name}_super_user")
126
+ if not staff or not superuser:
127
+ raise ImproperlyConfigured(
128
+ "Set NILVA_ADMIN['KEYCLOAK']['ROLE_STAFF_USER'/'ROLE_SUPER_USER'] or NILVA_ADMIN['APP_NAME']"
129
+ )
130
+ return staff, superuser
131
+
132
+ @staticmethod
133
+ def extract_roles(token_response: dict, userinfo: dict) -> set[str]:
134
+ """Realm roles from the access-token payload plus any ``groups``
135
+ claim from userinfo (group names without their leading slash)."""
136
+ payload = _decode_jwt_payload(token_response.get("access_token", ""))
137
+ roles = set(payload.get("realm_access", {}).get("roles", []))
138
+ for client_access in payload.get("resource_access", {}).values():
139
+ roles.update(client_access.get("roles", []))
140
+ roles.update(str(group).lstrip("/") for group in userinfo.get("groups") or [])
141
+ return roles
142
+
143
+ def authenticate(self, request, code: str, redirect_uri: str):
144
+ """Complete the flow: exchange, map, upsert the user and log in.
145
+
146
+ Raises ``PermissionDenied`` when the user lacks the staff role and
147
+ ``SSOError`` on gateway trouble.
148
+ """
149
+ token_response = self.exchange_code(code, redirect_uri)
150
+ userinfo = self.userinfo(token_response["access_token"])
151
+ roles = self.extract_roles(token_response, userinfo)
152
+ role_staff, role_super = self._role_names()
153
+
154
+ is_superuser = role_super in roles
155
+ is_staff = role_staff in roles or is_superuser
156
+ if not is_staff:
157
+ logger.warning("sso login rejected: missing staff role", extra={"roles": sorted(roles)})
158
+ raise PermissionDenied("SSO account has no admin access role.")
159
+
160
+ user = self._upsert_user(userinfo, is_staff=is_staff, is_superuser=is_superuser)
161
+ if self.config.get("SYNC_GROUPS"):
162
+ user.groups.set(Group.objects.filter(name__in=roles))
163
+
164
+ login(request, user)
165
+ request.session[ID_TOKEN_SESSION_KEY] = token_response.get("id_token", "")
166
+ logger.info("sso login success", extra={"username": user.get_username()})
167
+ return user
168
+
169
+ @staticmethod
170
+ def _upsert_user(userinfo: dict, *, is_staff: bool, is_superuser: bool):
171
+ user_model = get_user_model()
172
+ username = userinfo.get("preferred_username") or userinfo.get("email")
173
+ if not username:
174
+ raise SSOError("userinfo carries neither preferred_username nor email")
175
+ try:
176
+ user = user_model._default_manager.get_by_natural_key(username)
177
+ except user_model.DoesNotExist:
178
+ user = user_model.objects.create_user(username=username)
179
+ user.set_unusable_password()
180
+ user.email = userinfo.get("email") or user.email
181
+ user.first_name = userinfo.get("given_name") or user.first_name
182
+ user.last_name = userinfo.get("family_name") or user.last_name
183
+ user.is_staff = is_staff
184
+ user.is_superuser = is_superuser
185
+ user.save()
186
+ return user
@@ -0,0 +1,67 @@
1
+ {% extends "admin/login.html" %}
2
+ {% load i18n %}
3
+
4
+ {% block content %}
5
+ {% if form.errors and not form.non_field_errors %}
6
+ <p class="errornote">
7
+ {% blocktranslate count counter=form.errors.items|length %}Please correct the error below.{% plural %}Please correct the errors below.{% endblocktranslate %}
8
+ </p>
9
+ {% endif %}
10
+
11
+ {% if form.non_field_errors %}
12
+ {% for error in form.non_field_errors %}
13
+ <p class="errornote">
14
+ {{ error }}
15
+ </p>
16
+ {% endfor %}
17
+ {% endif %}
18
+
19
+ <div id="content-main">
20
+
21
+ {% if user.is_authenticated %}
22
+ <p class="errornote">
23
+ {% blocktranslate trimmed %}
24
+ You are authenticated as {{ username }}, but are not authorized to
25
+ access this page. Would you like to login to a different account?
26
+ {% endblocktranslate %}
27
+ </p>
28
+ {% endif %}
29
+
30
+ <form action="{{ app_path }}" method="post" id="login-form">{% csrf_token %}
31
+ <div class="form-row">
32
+ {{ form.username.errors }}
33
+ {{ form.username.label_tag }} {{ form.username }}
34
+ </div>
35
+ <div class="form-row">
36
+ {{ form.password.errors }}
37
+ {{ form.password.label_tag }} {{ form.password }}
38
+ <input type="hidden" name="next" value="{{ next }}">
39
+ </div>
40
+ {% if form.captcha_code %}
41
+ <div class="form-row nilva-captcha-row">
42
+ {{ form.captcha_code.errors }}
43
+ {% if form.captcha_image %}<img src="{{ form.captcha_image }}" alt="CAPTCHA" class="nilva-captcha-image">{% endif %}
44
+ {{ form.captcha_code.label_tag }} {{ form.captcha_code }}
45
+ {{ form.captcha_id }}
46
+ <a href="{{ app_path }}" class="nilva-captcha-refresh">{% translate 'Refresh CAPTCHA' %}</a>
47
+ </div>
48
+ {% endif %}
49
+ {% url 'admin_password_reset' as password_reset_url %}
50
+ {% if password_reset_url %}
51
+ <div class="password-reset-link">
52
+ <a href="{{ password_reset_url }}">{% translate 'Forgotten your login credentials?' %}</a>
53
+ </div>
54
+ {% endif %}
55
+ <div class="submit-row">
56
+ <input type="submit" value="{% translate 'Log in' %}">
57
+ </div>
58
+ </form>
59
+
60
+ {% if sso_login_url %}
61
+ <div class="submit-row nilva-sso-row">
62
+ <a class="button" href="{{ sso_login_url }}{% if next %}?next={{ next|urlencode }}{% endif %}">{{ sso_button_text }}</a>
63
+ </div>
64
+ {% endif %}
65
+
66
+ </div>
67
+ {% endblock %}
@@ -0,0 +1,15 @@
1
+ """SSO URLs — mount under any prefix::
2
+
3
+ # urls.py
4
+ path("api/sso/", include("nilva_django_admin.urls")),
5
+ """
6
+
7
+ from django.urls import path
8
+
9
+ from .views import SsoCallbackView, SsoLoginView, SsoLogoutView
10
+
11
+ urlpatterns = [
12
+ path("login/", SsoLoginView.as_view(), name="nilva-sso-login"),
13
+ path("callback/", SsoCallbackView.as_view(), name="nilva-sso-callback"),
14
+ path("logout/", SsoLogoutView.as_view(), name="nilva-sso-logout"),
15
+ ]
@@ -0,0 +1,76 @@
1
+ """SSO login / callback / logout views (wired via ``nilva_django_admin.urls``)."""
2
+
3
+ import logging
4
+
5
+ from django.contrib import messages
6
+ from django.contrib.auth import logout
7
+ from django.core.exceptions import PermissionDenied
8
+ from django.http import HttpResponseBadRequest, HttpResponseRedirect
9
+ from django.urls import reverse
10
+ from django.utils.crypto import get_random_string
11
+ from django.utils.http import url_has_allowed_host_and_scheme
12
+ from django.utils.translation import gettext_lazy as _
13
+ from django.views import View
14
+
15
+ from .settings import admin_settings
16
+ from .sso import ID_TOKEN_SESSION_KEY, NEXT_SESSION_KEY, STATE_SESSION_KEY, KeycloakSSO, SSOError
17
+
18
+ logger = logging.getLogger("nilva_django_admin")
19
+
20
+
21
+ def _callback_uri(request) -> str:
22
+ return request.build_absolute_uri(reverse("nilva-sso-callback"))
23
+
24
+
25
+ def _safe_next(request, candidate: str | None) -> str:
26
+ default = admin_settings.KEYCLOAK["REDIRECT_URI"]
27
+ if candidate and url_has_allowed_host_and_scheme(candidate, allowed_hosts={request.get_host()}):
28
+ return candidate
29
+ return default
30
+
31
+
32
+ class SsoLoginView(View):
33
+ """Kick off the authorization-code flow."""
34
+
35
+ def get(self, request):
36
+ sso = KeycloakSSO()
37
+ state = get_random_string(32)
38
+ request.session[STATE_SESSION_KEY] = state
39
+ request.session[NEXT_SESSION_KEY] = _safe_next(request, request.GET.get("next"))
40
+ return HttpResponseRedirect(sso.authorize_url(_callback_uri(request), state))
41
+
42
+
43
+ class SsoCallbackView(View):
44
+ """Finish the flow: verify state, exchange the code, log the user in."""
45
+
46
+ def get(self, request):
47
+ state = request.GET.get("state")
48
+ code = request.GET.get("code")
49
+ if not code or not state or state != request.session.get(STATE_SESSION_KEY):
50
+ return HttpResponseBadRequest("Invalid SSO state.")
51
+ request.session.pop(STATE_SESSION_KEY, None)
52
+ next_url = request.session.pop(NEXT_SESSION_KEY, admin_settings.KEYCLOAK["REDIRECT_URI"])
53
+
54
+ try:
55
+ KeycloakSSO().authenticate(request, code, _callback_uri(request))
56
+ except PermissionDenied:
57
+ messages.error(request, _("Your SSO account has no admin access role."))
58
+ return HttpResponseRedirect(admin_settings.KEYCLOAK["REDIRECT_URI"])
59
+ except SSOError as exc:
60
+ logger.error("sso login failed", extra={"error_info": str(exc)})
61
+ messages.error(request, _("SSO login failed. Please try again."))
62
+ return HttpResponseRedirect(admin_settings.KEYCLOAK["REDIRECT_URI"])
63
+
64
+ return HttpResponseRedirect(next_url)
65
+
66
+
67
+ class SsoLogoutView(View):
68
+ """Log out of Django, then out of the Keycloak session."""
69
+
70
+ def get(self, request):
71
+ id_token = request.session.get(ID_TOKEN_SESSION_KEY)
72
+ logout(request)
73
+ redirect_uri = request.build_absolute_uri(admin_settings.KEYCLOAK["REDIRECT_URI"])
74
+ if KeycloakSSO.is_configured():
75
+ return HttpResponseRedirect(KeycloakSSO().logout_url(redirect_uri, id_token))
76
+ return HttpResponseRedirect(redirect_uri)
@@ -0,0 +1,175 @@
1
+ Metadata-Version: 2.4
2
+ Name: nilva-django-admin
3
+ Version: 0.2.0
4
+ Summary: Nilva house Django-admin toolkit — BaseModelAdmin/BaseInlineAdmin composition, in-house Keycloak SSO admin login, captcha on login + selected models, dynamic model visibility, django-admin-interface theming
5
+ Author-email: Nilva <dev@nilva.ai>
6
+ License-Expression: MIT
7
+ Project-URL: homepage, https://gitlab.nilva.ai/nilva/dev-assets/django/nilva-django-admin
8
+ Project-URL: repository, https://gitlab.nilva.ai/nilva/dev-assets/django/nilva-django-admin
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.14
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Framework :: Django
13
+ Classifier: Framework :: Django :: 6.0
14
+ Requires-Python: >=3.14
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: django>=6.0
18
+ Requires-Dist: django-autoutils>=2.1.1
19
+ Requires-Dist: django-object-actions>=5.0.0
20
+ Requires-Dist: requests>=2.32
21
+ Requires-Dist: django-admin-interface>=0.30.1
22
+ Provides-Extra: jalali
23
+ Requires-Dist: django-jalali>=7.4.0; extra == "jalali"
24
+ Dynamic: license-file
25
+
26
+ # nilva-django-admin
27
+
28
+ Nilva's house Django-admin toolkit: the `BaseModelAdmin` / `BaseInlineAdmin`
29
+ composition every backend re-declares, an **in-house Keycloak SSO admin
30
+ login** (no `django-admin-keycloak`), the **Cheshmeh-style captcha** on the
31
+ login form and on selected models' change forms, **dynamic model
32
+ visibility** driven by settings, the django-autoutils Django-6 compatibility
33
+ patch, and [django-admin-interface](https://pypi.org/project/django-admin-interface/)
34
+ theming as a dependency.
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ uv add nilva-django-admin # everything below
40
+ uv add "nilva-django-admin[jalali]" # + Persian datepickers for jDateField models
41
+ ```
42
+
43
+ ## Wiring
44
+
45
+ ```python
46
+ # settings.py
47
+ INSTALLED_APPS = [
48
+ "admin_interface", # theming (ships with this package)
49
+ "colorfield",
50
+ ...
51
+ "nilva_django_admin", # templates + app
52
+ "nilva_django_admin.admin_apps.NilvaAdminConfig", # INSTEAD of django.contrib.admin
53
+ ...
54
+ ]
55
+
56
+ NILVA_ADMIN = {
57
+ "APP_NAME": "shahab",
58
+ "SITE_TITLE": "Shahab administration",
59
+ "LOGIN_CAPTCHA": True,
60
+ "CAPTCHA_MODELS": ("user.User", "salary.*"),
61
+ "HIDDEN_MODELS": ("auth.Group",),
62
+ "KEYCLOAK": {
63
+ "SERVER_URL": "https://sso.nilva.ai",
64
+ "REALM_NAME": "nilva",
65
+ "CLIENT_ID": "...",
66
+ "CLIENT_SECRET": "...",
67
+ },
68
+ "CAPTCHA": {"BASE_URL": "https://host/services/captcha/v1.0"},
69
+ }
70
+
71
+ # urls.py
72
+ path("api/sso/", include("nilva_django_admin.urls")),
73
+ ```
74
+
75
+ ## Base admin classes
76
+
77
+ ```python
78
+ from nilva_django_admin.base import BaseInlineAdmin, BaseModelAdmin
79
+
80
+ @admin.register(Delivery)
81
+ class DeliveryAdmin(BaseModelAdmin):
82
+ list_display = ("id", "avatar_icon", "name", "editor_icon")
83
+ ```
84
+
85
+ django-object-actions buttons, django-autoutils filters/avatars/edit-links/
86
+ pretty-JSON, `editor_icon` auditing, the duck-typed house protocols
87
+ (`get_obj()`, `get_prefetch_related_fields()`, `avatar_img_tag()`), Jalali
88
+ datepickers when `django-jalali` is installed, and the Django-6
89
+ `format_html` compat patch for django-autoutils 2.1.1. Defaults
90
+ (`LIST_PER_PAGE`, `AVATAR_ICON_FIELD`) come from `NILVA_ADMIN`.
91
+
92
+ ## SSO admin login (in-house, no django-admin-keycloak)
93
+
94
+ Plain OIDC authorization-code flow implemented in
95
+ `nilva_django_admin.sso` / `.views`:
96
+
97
+ * `GET <mount>/login/` redirects to the realm's `/auth` endpoint (CSRF
98
+ `state` kept in the session, `?next=` honoured when it is same-host).
99
+ * `GET <mount>/callback/` exchanges the code, reads roles from the access
100
+ token (`realm_access` + `resource_access` + userinfo `groups`), maps
101
+ `{APP_NAME}_staff` → `is_staff` and `{APP_NAME}_super_user` →
102
+ `is_superuser` (overridable via `ROLE_STAFF_USER` / `ROLE_SUPER_USER`),
103
+ creates/updates the Django user and logs it in. Accounts without the
104
+ staff role are rejected. With `SYNC_GROUPS` (default) matching Django
105
+ `Group` rows are assigned.
106
+ * `GET <mount>/logout/` logs out of Django and the Keycloak session.
107
+
108
+ The admin login page (rendered by `NilvaAdminSite`) shows a **Login with
109
+ SSO** button automatically whenever `KEYCLOAK` is configured and the URLs
110
+ are mounted.
111
+
112
+ ## Captcha (Cheshmeh's service)
113
+
114
+ `nilva_django_admin.captcha.CaptchaService` talks to the MCI/EBCOM-style
115
+ captcha HTTP service (`GET ?width=…` → `{id, image}`; `GET
116
+ /{id}/verify/{code}`). Typed codes are Persian/Arabic-digit normalized; the
117
+ `VALIDATION_IGNORE` staging bypass only works when `DEBUG=True`.
118
+
119
+ * **Login captcha** — `NILVA_ADMIN["LOGIN_CAPTCHA"] = True` puts the captcha
120
+ on the admin login form.
121
+ * **Model captcha** — every model matching a `CAPTCHA_MODELS` pattern gets a
122
+ captcha on its admin add/change form. `BaseModelAdmin` injects the field
123
+ and appends a CAPTCHA fieldset when the admin declares explicit
124
+ `fieldsets`. A failed code re-renders with a fresh challenge; the
125
+ challenge id travels in a hidden field, so no session state is involved.
126
+
127
+ ## Dynamic model visibility
128
+
129
+ Which models the admin shows follows settings, per request — no code
130
+ changes or restarts of registration logic:
131
+
132
+ ```python
133
+ NILVA_ADMIN = {
134
+ "VISIBLE_MODELS": ("worklog.*", "user.User"), # None → all registered
135
+ "HIDDEN_MODELS": ("auth.Permission",), # always wins
136
+ }
137
+ ```
138
+
139
+ `NilvaAdminSite.get_app_list` applies the `fnmatch` patterns, so the index
140
+ and sidebar only show matching models. And for the inverse — surfacing
141
+ models nobody wrote an admin for:
142
+
143
+ ```python
144
+ # urls.py, after admin.autodiscover
145
+ admin.site.auto_register() # every unregistered model → BaseModelAdmin
146
+ admin.site.auto_register(exclude=("django_celery_beat.*",))
147
+ ```
148
+
149
+ ## Theming
150
+
151
+ `django-admin-interface` is a dependency: add `admin_interface` and
152
+ `colorfield` to `INSTALLED_APPS` (before the admin), run `migrate`, and
153
+ manage themes from the admin itself.
154
+
155
+ ## Settings reference — `NILVA_ADMIN`
156
+
157
+ | key | default | notes |
158
+ |-----|---------|-------|
159
+ | `APP_NAME` | `None` | role-name defaults |
160
+ | `LIST_PER_PAGE` / `AVATAR_ICON_FIELD` | `20` / `"avatar_32"` | `BaseModelAdmin` |
161
+ | `SITE_TITLE` | `None` | site header/title |
162
+ | `VISIBLE_MODELS` / `HIDDEN_MODELS` | `None` / `()` | fnmatch patterns |
163
+ | `LOGIN_CAPTCHA` | `False` | captcha on the login form |
164
+ | `CAPTCHA_MODELS` | `()` | captcha on these models' change forms |
165
+ | `CAPTCHA.BASE_URL` … | — | captcha service (`WIDTH`/`HEIGHT`/`TYPE`/`PROXY`/`TIMEOUT`/`VALIDATION_IGNORE`) |
166
+ | `KEYCLOAK.SERVER_URL` … | — | SSO (`REALM_NAME`/`CLIENT_ID`/`CLIENT_SECRET`/`SCOPE`/`REDIRECT_URI`/`ROLE_*`/`SYNC_GROUPS`/`VERIFY_SSL`/`TIMEOUT`) |
167
+ | `SSO_BUTTON_TEXT` | `"Login with SSO"` | login page button |
168
+
169
+ ## Development
170
+
171
+ ```bash
172
+ uv sync --group dev
173
+ ./run_tests.sh # ./run_tests.sh -c for coverage
174
+ uv run ruff check .
175
+ ```
@@ -0,0 +1,17 @@
1
+ nilva_django_admin/__init__.py,sha256=Sme_VIYUzy1CTtWRQbKVFtvjDD1FDbQO8YFNrhMdcsg,744
2
+ nilva_django_admin/admin_apps.py,sha256=mLJYTrDK4nuZFTsH7jc7_3LITu26LpnkNwXv2NYxZOQ,629
3
+ nilva_django_admin/apps.py,sha256=6fuw0FiqAhfpJ2Bf5KGzSVHKZvvpTmoiX614-UsLhI8,199
4
+ nilva_django_admin/base.py,sha256=73ecYxrSKK7_5gE-wkSkfhXfV7rclhzJuc9kjAm0ILM,6395
5
+ nilva_django_admin/captcha.py,sha256=iG0yo9_SfImRwByps2XOi7KiW4ifbzMl6OnkEDExLCg,6397
6
+ nilva_django_admin/compat.py,sha256=qFcNUKYuZV3en7QgNE8fwrtulcFkKclFm8uHM3SVx1k,1534
7
+ nilva_django_admin/settings.py,sha256=Fm4eWYvE-813VotOVDSSrCh0UVJ7Z5Bj40vg2KeASPw,4431
8
+ nilva_django_admin/sites.py,sha256=zryogF6-_rmiQnsnbvV8mqOgC37BQAB3VJ5VKHdp5XQ,3797
9
+ nilva_django_admin/sso.py,sha256=k7lGlgpZeiwRxCv_1MRnMrbypAKRSpJH33HwoRi2UtI,8031
10
+ nilva_django_admin/urls.py,sha256=UE9QlUx4f6IcDV9GLTPl2FzUOPElRZtxqsvQ1paY1qI,440
11
+ nilva_django_admin/views.py,sha256=4DST0u14DNWaLqaHLp7hWKBeprri-5mTNY1M1-WMVEo,3070
12
+ nilva_django_admin/templates/nilva_django_admin/login.html,sha256=ZuZyQ24IGX7A-x9pc6f_dRGOjZwxdbxhF4crg-JM7j8,2130
13
+ nilva_django_admin-0.2.0.dist-info/licenses/LICENSE,sha256=GaZw6y1gmDVsgrV6Kbba2e0GWozUuG_DP41_BT7fHUg,1061
14
+ nilva_django_admin-0.2.0.dist-info/METADATA,sha256=QJ8xHl3pHcQckaFlbxvE0BsRI_dLi7uWanLivqUhrKM,6826
15
+ nilva_django_admin-0.2.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
16
+ nilva_django_admin-0.2.0.dist-info/top_level.txt,sha256=B3VWRdw-c_cT9hxYXijNqdShn0O44n3HXL4ATrL8B7c,19
17
+ nilva_django_admin-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Nilva
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ nilva_django_admin