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.
- nilva_django_admin/__init__.py +15 -0
- nilva_django_admin/admin_apps.py +19 -0
- nilva_django_admin/apps.py +7 -0
- nilva_django_admin/base.py +175 -0
- nilva_django_admin/captcha.py +159 -0
- nilva_django_admin/compat.py +47 -0
- nilva_django_admin/settings.py +135 -0
- nilva_django_admin/sites.py +95 -0
- nilva_django_admin/sso.py +186 -0
- nilva_django_admin/templates/nilva_django_admin/login.html +67 -0
- nilva_django_admin/urls.py +15 -0
- nilva_django_admin/views.py +76 -0
- nilva_django_admin-0.2.0.dist-info/METADATA +175 -0
- nilva_django_admin-0.2.0.dist-info/RECORD +17 -0
- nilva_django_admin-0.2.0.dist-info/WHEEL +5 -0
- nilva_django_admin-0.2.0.dist-info/licenses/LICENSE +21 -0
- nilva_django_admin-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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,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,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
|