karni 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. karni/__init__.py +23 -0
  2. karni/accounts/__init__.py +10 -0
  3. karni/accounts/admin.py +381 -0
  4. karni/accounts/api/__init__.py +15 -0
  5. karni/accounts/api/matrix.py +132 -0
  6. karni/accounts/api/serializers.py +221 -0
  7. karni/accounts/api/throttles.py +116 -0
  8. karni/accounts/api/views.py +512 -0
  9. karni/accounts/apps.py +61 -0
  10. karni/accounts/emails.py +217 -0
  11. karni/accounts/managers.py +80 -0
  12. karni/accounts/matrix.py +772 -0
  13. karni/accounts/matrix_admin.py +185 -0
  14. karni/accounts/migrations/0001_initial.py +77 -0
  15. karni/accounts/migrations/0002_roles.py +109 -0
  16. karni/accounts/migrations/0003_matrix.py +30 -0
  17. karni/accounts/migrations/0004_pg_trgm.py +42 -0
  18. karni/accounts/migrations/__init__.py +0 -0
  19. karni/accounts/models.py +290 -0
  20. karni/accounts/permissions.py +214 -0
  21. karni/accounts/roles.py +570 -0
  22. karni/accounts/services.py +276 -0
  23. karni/accounts/tasks.py +48 -0
  24. karni/accounts/templates/karni_accounts/effective_permissions.html +81 -0
  25. karni/accounts/templates/karni_accounts/email/change_email_body.html +24 -0
  26. karni/accounts/templates/karni_accounts/email/change_email_body.txt +11 -0
  27. karni/accounts/templates/karni_accounts/email/change_email_subject.txt +1 -0
  28. karni/accounts/templates/karni_accounts/email/reset_password_body.html +24 -0
  29. karni/accounts/templates/karni_accounts/email/reset_password_body.txt +11 -0
  30. karni/accounts/templates/karni_accounts/email/reset_password_subject.txt +1 -0
  31. karni/accounts/templates/karni_accounts/email/verify_email_body.html +22 -0
  32. karni/accounts/templates/karni_accounts/email/verify_email_body.txt +9 -0
  33. karni/accounts/templates/karni_accounts/email/verify_email_subject.txt +1 -0
  34. karni/accounts/templates/karni_accounts/permission_matrix.html +169 -0
  35. karni/accounts/templates/karni_accounts/permission_matrix_confirm.html +73 -0
  36. karni/accounts/tokens.py +157 -0
  37. karni/accounts/twofa.py +461 -0
  38. karni/accounts/urls.py +123 -0
  39. karni/accounts/visibility.py +75 -0
  40. karni/admin/__init__.py +185 -0
  41. karni/admin/apps.py +40 -0
  42. karni/admin/base.py +623 -0
  43. karni/admin/derive.py +223 -0
  44. karni/admin/inlines.py +121 -0
  45. karni/admin/scope.py +73 -0
  46. karni/admin/soft_delete.py +357 -0
  47. karni/admin/static/karni/brand/karni-mark-light.svg +5 -0
  48. karni/admin/static/karni/brand/karni-mark-simple.svg +8 -0
  49. karni/admin/static/karni/brand/karni-mark.svg +5 -0
  50. karni/admin/templates/karni_admin/hard_delete_selected_confirmation.html +59 -0
  51. karni/admin/templates/karni_admin/import.html +150 -0
  52. karni/admin/templates/karni_admin/object_history.html +40 -0
  53. karni/admin/transfer.py +293 -0
  54. karni/agent/__init__.py +82 -0
  55. karni/agent/__main__.py +44 -0
  56. karni/agent/check.py +658 -0
  57. karni/agent/history.py +328 -0
  58. karni/agent/history_chronicle.py +246 -0
  59. karni/agent/overrides.py +194 -0
  60. karni/agent/rules.py +673 -0
  61. karni/api/__init__.py +97 -0
  62. karni/api/access.py +1197 -0
  63. karni/api/caching.py +333 -0
  64. karni/api/expansion.py +349 -0
  65. karni/api/filters.py +335 -0
  66. karni/api/optimizer.py +423 -0
  67. karni/api/routers.py +302 -0
  68. karni/api/schema.py +378 -0
  69. karni/api/search/__init__.py +104 -0
  70. karni/api/search/postgres.py +159 -0
  71. karni/api/search/simple.py +54 -0
  72. karni/api/serializers.py +523 -0
  73. karni/api/urls.py +35 -0
  74. karni/api/viewsets.py +962 -0
  75. karni/apps.py +49 -0
  76. karni/audit/__init__.py +41 -0
  77. karni/audit/admin.py +32 -0
  78. karni/audit/apps.py +17 -0
  79. karni/audit/capture.py +335 -0
  80. karni/audit/migrations/0001_initial.py +76 -0
  81. karni/audit/migrations/0002_batches.py +62 -0
  82. karni/audit/migrations/__init__.py +0 -0
  83. karni/audit/models.py +115 -0
  84. karni/audit/schedules.py +10 -0
  85. karni/audit/serializers.py +45 -0
  86. karni/audit/services.py +28 -0
  87. karni/audit/tasks.py +38 -0
  88. karni/celery.py +226 -0
  89. karni/changelog/0.2.0.toml +304 -0
  90. karni/changelog/0.3.0.toml +25 -0
  91. karni/changelog/__init__.py +248 -0
  92. karni/checks.py +466 -0
  93. karni/conf/__init__.py +442 -0
  94. karni/conf/admin_logo.py +54 -0
  95. karni/conf/base.py +514 -0
  96. karni/conf/dev.py +36 -0
  97. karni/conf/logging.py +95 -0
  98. karni/conf/prod.py +46 -0
  99. karni/conf/reference.py +200 -0
  100. karni/conf/sentry.py +52 -0
  101. karni/conf/validation.py +466 -0
  102. karni/deprecation.py +77 -0
  103. karni/dotenv.py +48 -0
  104. karni/errorcodes/__init__.py +1171 -0
  105. karni/errorcodes/__main__.py +34 -0
  106. karni/events.py +418 -0
  107. karni/exceptions.py +133 -0
  108. karni/health/__init__.py +42 -0
  109. karni/health/checks.py +83 -0
  110. karni/health/urls.py +23 -0
  111. karni/health/views.py +95 -0
  112. karni/imports.py +34 -0
  113. karni/introspect.py +313 -0
  114. karni/links.py +217 -0
  115. karni/management/__init__.py +0 -0
  116. karni/management/commands/__init__.py +0 -0
  117. karni/management/commands/karni_check.py +56 -0
  118. karni/management/commands/karni_context.py +22 -0
  119. karni/management/commands/karni_routes.py +165 -0
  120. karni/management/commands/karni_schedules.py +81 -0
  121. karni/management/commands/karni_seed.py +76 -0
  122. karni/management/commands/karni_sync_roles.py +38 -0
  123. karni/managers.py +278 -0
  124. karni/meta.py +334 -0
  125. karni/middleware.py +159 -0
  126. karni/models.py +251 -0
  127. karni/pagination.py +229 -0
  128. karni/performance.py +404 -0
  129. karni/rows.py +78 -0
  130. karni/schedule.py +547 -0
  131. karni/scopes.py +413 -0
  132. karni/scoping.py +63 -0
  133. karni/seeding.py +734 -0
  134. karni/signals.py +30 -0
  135. karni/storage/__init__.py +78 -0
  136. karni/storage/s3.py +144 -0
  137. karni/storage/thumbnails.py +171 -0
  138. karni/testing/__init__.py +73 -0
  139. karni/testing/_source.py +202 -0
  140. karni/testing/api.py +1148 -0
  141. karni/testing/architecture.py +525 -0
  142. karni/testing/duplication.py +133 -0
  143. karni/testing/factories.py +578 -0
  144. karni/testing/isolation.py +718 -0
  145. karni/testing/performance.py +414 -0
  146. karni/testing/pytest_plugin.py +161 -0
  147. karni/testing/schema.py +402 -0
  148. karni/testing/values.py +728 -0
  149. karni/transfer/__init__.py +36 -0
  150. karni/transfer/apps.py +14 -0
  151. karni/transfer/exports.py +172 -0
  152. karni/transfer/fields.py +186 -0
  153. karni/transfer/formats.py +200 -0
  154. karni/transfer/imports.py +528 -0
  155. karni/transfer/migrations/0001_initial.py +165 -0
  156. karni/transfer/migrations/__init__.py +0 -0
  157. karni/transfer/models.py +130 -0
  158. karni/transfer/serializers.py +72 -0
  159. karni/transfer/services.py +232 -0
  160. karni/transfer/tasks.py +22 -0
  161. karni/transfer/template.py +51 -0
  162. karni/vetoes.py +90 -0
  163. karni/welcome/__init__.py +63 -0
  164. karni/welcome/inventory.py +213 -0
  165. karni/welcome/page.py +269 -0
  166. karni/welcome/status.py +171 -0
  167. karni/welcome/views.py +29 -0
  168. karni-0.3.1.dist-info/METADATA +202 -0
  169. karni-0.3.1.dist-info/RECORD +172 -0
  170. karni-0.3.1.dist-info/WHEEL +4 -0
  171. karni-0.3.1.dist-info/entry_points.txt +9 -0
  172. karni-0.3.1.dist-info/licenses/LICENSE +201 -0
karni/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """Karni — batteries-included Django framework glue.
2
+
3
+ Karni packages the settings, Celery, storage and health-check boilerplate that
4
+ every Django + DRF + Celery project repeats, behind a handful of small,
5
+ overridable entry points:
6
+
7
+ * :func:`karni.conf.karni_settings` — assemble a full Django settings module.
8
+ * :func:`karni.celery.create_celery_app` — a configured Celery application.
9
+ * :mod:`karni.storage` — S3-compatible or local file storage plus thumbnails.
10
+ * :mod:`karni.health` — liveness and readiness endpoints.
11
+ * :mod:`karni.events` — ``<app>/listeners.py`` model lifecycle listeners
12
+ (``on_created`` / ``on_updated`` / ``on_deleted``, ``queued``).
13
+ * :mod:`karni.schedule` — ``<app>/schedules.py`` beat schedules
14
+ (``every`` / ``cron``).
15
+
16
+ Every default Karni applies can be overridden from the project's own
17
+ ``settings.py``: values defined before ``karni_settings(globals())`` are never
18
+ overwritten, and values assigned after the call always win.
19
+ """
20
+
21
+ __version__ = "0.3.1"
22
+
23
+ __all__ = ["__version__"]
@@ -0,0 +1,10 @@
1
+ """Karni accounts: email-based users, one-time tokens and auth plumbing.
2
+
3
+ Installed by default via :func:`karni.conf.karni_settings` (app label
4
+ ``karni_accounts``) together with ``AUTH_USER_MODEL = "karni_accounts.User"``.
5
+
6
+ Override / escape hatch: projects that need extra user fields subclass
7
+ :class:`karni.accounts.models.AbstractKarniUser` in their own app and set
8
+ ``AUTH_USER_MODEL = "myapp.User"`` **before the first migrate** — see
9
+ ``docs/accounts.md`` for the full swap path.
10
+ """
@@ -0,0 +1,381 @@
1
+ """Admin integration for the Karni user model and the roles (accounts contract amendment A10).
2
+
3
+ Exposes :class:`KarniUserAdmin`, an email-based ``UserAdmin`` built on
4
+ ``karni.admin.KarniModelAdmin`` with working add/change forms (no username
5
+ field anywhere). When the project has not swapped the user model
6
+ (``AUTH_USER_MODEL == "karni_accounts.User"``) the default user is registered
7
+ here explicitly — ``karni.admin``'s autodiscover deliberately skips
8
+ ``AbstractBaseUser`` subclasses (its amendment C5). Its change form carries
9
+ the **Effective permissions** button (ADR 0002, P5).
10
+
11
+ The role tables are registered here too: :class:`RoleAdmin` (editable, with
12
+ the **Permission matrix** and **Effective permissions** pages as changelist
13
+ buttons — ``admin:karni_accounts_role_permission_matrix`` and
14
+ ``..._effective_permissions``), and read-only admins for ``Permission`` and
15
+ ``RolePermission`` — grants change through the matrix and its guard rails,
16
+ never by editing a row.
17
+
18
+ Override / escape hatch — swapped user models reuse the class as a base::
19
+
20
+ # myapp/admin.py
21
+ from django.contrib import admin
22
+ from karni.accounts.admin import KarniUserAdmin
23
+ from myapp.models import User
24
+
25
+ @admin.register(User)
26
+ class UserAdmin(KarniUserAdmin):
27
+ list_display = (*KarniUserAdmin.list_display, "plan")
28
+
29
+ The class (and therefore ``karni.admin`` / django-unfold) is built lazily on
30
+ first attribute access, so importing this module never drags the admin stack
31
+ in at app-populate time.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ from typing import Any
37
+
38
+ from django.conf import settings
39
+ from django.contrib import admin
40
+ from django.urls import path
41
+
42
+ __all__ = ["KarniUserAdmin", "RoleAdmin"] # noqa: F822 - provided lazily via module __getattr__
43
+
44
+ _user_admin_class: type[Any] | None = None
45
+ _role_admin_classes: tuple[type[Any], type[Any], type[Any]] | None = None
46
+
47
+
48
+ def _unfold_action() -> Any:
49
+ """django-unfold's ``@action`` (a button plus a URL on its ``ModelAdmin``), or a stand-in.
50
+
51
+ On another ``KARNI_ADMIN_BASE`` the stand-in only records ``url_path`` so
52
+ :func:`_fallback_urls` can route the page; there is no button, the URL is
53
+ the documented way in.
54
+ """
55
+ try:
56
+ from unfold.decorators import action
57
+ except ImportError: # pragma: no cover - unfold is a core dependency
58
+
59
+ def action(**options: Any) -> Any:
60
+ def decorate(fn: Any) -> Any:
61
+ fn.url_path = options.get("url_path", fn.__name__)
62
+ return fn
63
+
64
+ return decorate
65
+
66
+ return action
67
+
68
+
69
+ def _fallback_urls(
70
+ model_admin: Any, *, list_actions: tuple[str, ...] = (), detail_actions: tuple[str, ...] = ()
71
+ ) -> list[Any]:
72
+ """Routes for the page methods when the base admin is not django-unfold's.
73
+
74
+ Unfold's ``get_urls`` already routes every ``actions_list`` /
75
+ ``actions_detail`` method; a plain ``ModelAdmin`` does not, so these
76
+ patterns (same names, same paths) fill in.
77
+ """
78
+ if hasattr(model_admin, "_get_base_actions_list"):
79
+ return []
80
+ opts = model_admin.model._meta
81
+ wrap = model_admin.admin_site.admin_view
82
+ urls = []
83
+ for name in list_actions:
84
+ method = getattr(model_admin, name)
85
+ route = getattr(method, "url_path", name)
86
+ urls.append(
87
+ path(f"{route}/", wrap(method), name=f"{opts.app_label}_{opts.model_name}_{name}")
88
+ )
89
+ for name in detail_actions:
90
+ method = getattr(model_admin, name)
91
+ route = getattr(method, "url_path", name)
92
+ urls.append(
93
+ path(
94
+ f"<path:object_id>/{route}/",
95
+ wrap(method),
96
+ name=f"{opts.app_label}_{opts.model_name}_{name}",
97
+ )
98
+ )
99
+ return urls
100
+
101
+
102
+ def _build_user_admin() -> type[Any]:
103
+ """Construct the concrete ``KarniUserAdmin`` class.
104
+
105
+ Deferred so ``karni.admin`` (written for the active ``KARNI_ADMIN_BASE``,
106
+ default django-unfold) and the active user model are only imported when
107
+ the admin is actually used.
108
+ """
109
+ from django.contrib.auth import get_user_model
110
+ from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin
111
+ from django.contrib.auth.forms import UserChangeForm, UserCreationForm
112
+ from django.http import Http404
113
+
114
+ from karni.admin import KarniModelAdmin
115
+
116
+ action = _unfold_action()
117
+ user_model = get_user_model()
118
+
119
+ class KarniUserCreationForm(UserCreationForm):
120
+ """Add form asking for email + password pair only (no username)."""
121
+
122
+ class Meta(UserCreationForm.Meta):
123
+ model = user_model
124
+ fields = ("email",)
125
+
126
+ class KarniUserChangeForm(UserChangeForm):
127
+ """Change form bound to the active (possibly swapped) user model."""
128
+
129
+ class Meta(UserChangeForm.Meta):
130
+ model = user_model
131
+ fields = "__all__"
132
+
133
+ class KarniUserAdmin(KarniModelAdmin, DjangoUserAdmin):
134
+ """Email-based user admin for ``AbstractKarniUser`` models.
135
+
136
+ Reusable base for swapped user models: subclass, extend the class
137
+ attributes (``list_display``, ``fieldsets``, ...) and register it for
138
+ your model. Every attribute is a plain Django ``UserAdmin`` knob.
139
+ """
140
+
141
+ add_form = KarniUserCreationForm
142
+ form = KarniUserChangeForm
143
+ ordering = ("email",)
144
+ list_display = (
145
+ "email",
146
+ "first_name",
147
+ "last_name",
148
+ "is_staff",
149
+ "is_active",
150
+ "email_verified",
151
+ "date_joined",
152
+ )
153
+ list_filter = ("is_staff", "is_superuser", "is_active", "email_verified", "groups")
154
+ search_fields = ("email", "first_name", "last_name")
155
+ readonly_fields = ("last_login", "date_joined", "created_at", "updated_at")
156
+ fieldsets = (
157
+ (None, {"fields": ("email", "password")}),
158
+ ("Personal info", {"fields": ("first_name", "last_name")}),
159
+ ("Status", {"fields": ("is_active", "email_verified")}),
160
+ (
161
+ "Permissions",
162
+ {"fields": ("is_staff", "is_superuser", "groups", "user_permissions")},
163
+ ),
164
+ (
165
+ "Important dates",
166
+ {"fields": ("last_login", "date_joined", "created_at", "updated_at")},
167
+ ),
168
+ )
169
+ add_fieldsets = (
170
+ (None, {"classes": ("wide",), "fields": ("email", "password1", "password2")}),
171
+ )
172
+ actions_detail = ["effective_permissions"]
173
+
174
+ @action(
175
+ description="Effective permissions",
176
+ url_path="permissions",
177
+ permissions=["view"],
178
+ icon="shield_person",
179
+ )
180
+ def effective_permissions(self, request: Any, object_id: str) -> Any:
181
+ """What this user may do, and why (ADR 0002, P5)."""
182
+ from karni.accounts.matrix_admin import effective_view
183
+
184
+ obj = self.get_object(request, object_id)
185
+ if obj is None:
186
+ raise Http404
187
+ return effective_view(self, request, user=obj)
188
+
189
+ def get_urls(self) -> Any:
190
+ return [
191
+ *_fallback_urls(self, detail_actions=("effective_permissions",)),
192
+ *super().get_urls(),
193
+ ]
194
+
195
+ return KarniUserAdmin
196
+
197
+
198
+ def _build_role_admins() -> tuple[type[Any], type[Any], type[Any]]:
199
+ """Construct ``RoleAdmin``, ``PermissionAdmin`` and ``RolePermissionAdmin`` (ADR 0002, P5)."""
200
+ from django.contrib.auth.models import Group
201
+ from django.db.models import Count, Q
202
+
203
+ from karni.admin import KarniModelAdmin
204
+
205
+ action = _unfold_action()
206
+
207
+ class RoleAdmin(KarniModelAdmin):
208
+ """Roles, with the permission matrix and effective-permissions pages as buttons.
209
+
210
+ ``slug`` and ``is_system`` of a declared role follow ``KARNI_ROLES``;
211
+ a role added here gets its Django group on save. Grants are not
212
+ edited on this form — the matrix has the guard rails.
213
+ """
214
+
215
+ list_display = ("slug", "name", "is_system", "grant_count")
216
+ search_fields = ("slug", "name")
217
+ fields = ("slug", "name", "description", "is_system")
218
+ readonly_fields = ("is_system",)
219
+ actions_list = ["permission_matrix", "effective_permissions"]
220
+
221
+ def get_queryset(self, request: Any) -> Any:
222
+ alive = Q(grants__deleted_at__isnull=True) & ~Q(grants__scope="none")
223
+ return (
224
+ super().get_queryset(request).annotate(_grant_count=Count("grants", filter=alive))
225
+ )
226
+
227
+ @admin.display(description="grants", ordering="_grant_count")
228
+ def grant_count(self, obj: Any) -> int:
229
+ return int(getattr(obj, "_grant_count", 0))
230
+
231
+ def get_readonly_fields(self, request: Any, obj: Any = None) -> Any:
232
+ names = list(super().get_readonly_fields(request, obj))
233
+ if obj is not None and obj.is_system and "slug" not in names:
234
+ names.append("slug")
235
+ return names
236
+
237
+ def save_model(self, request: Any, obj: Any, form: Any, change: bool) -> None:
238
+ if not obj.group_id:
239
+ obj.group, _ = Group.objects.get_or_create(name=obj.slug)
240
+ super().save_model(request, obj, form, change)
241
+
242
+ @action(
243
+ description="Permission matrix",
244
+ url_path="matrix",
245
+ permissions=["view"],
246
+ icon="grid_view",
247
+ )
248
+ def permission_matrix(self, request: Any) -> Any:
249
+ """Roles × models × actions, one scope selector per cell."""
250
+ from karni.accounts.matrix_admin import matrix_view
251
+
252
+ return matrix_view(self, request)
253
+
254
+ @action(
255
+ description="Effective permissions",
256
+ url_path="effective",
257
+ permissions=["view"],
258
+ icon="shield_person",
259
+ )
260
+ def effective_permissions(self, request: Any) -> Any:
261
+ """Pick a user, see what they may do and why."""
262
+ from karni.accounts.matrix_admin import effective_view
263
+
264
+ return effective_view(self, request)
265
+
266
+ def get_urls(self) -> Any:
267
+ return [
268
+ *_fallback_urls(self, list_actions=("permission_matrix", "effective_permissions")),
269
+ *super().get_urls(),
270
+ ]
271
+
272
+ class _ReadOnlyAdmin(KarniModelAdmin):
273
+ """A changelist and a detail page, nothing editable: rows are managed elsewhere."""
274
+
275
+ def has_add_permission(self, request: Any) -> bool:
276
+ return False
277
+
278
+ def has_change_permission(self, request: Any, obj: Any = None) -> bool:
279
+ return False
280
+
281
+ def has_delete_permission(self, request: Any, obj: Any = None) -> bool:
282
+ return False
283
+
284
+ class PermissionAdmin(_ReadOnlyAdmin):
285
+ """``app.model:action`` rows, written by ``karni sync-roles``."""
286
+
287
+ list_display = ("__str__", "content_type", "action")
288
+ list_select_related = ("content_type",)
289
+ search_fields = ("action", "content_type__app_label", "content_type__model")
290
+ list_filter = ("action",)
291
+
292
+ class RolePermissionAdmin(_ReadOnlyAdmin):
293
+ """Grant rows, read-only here: change them in the permission matrix."""
294
+
295
+ list_display = ("role", "permission", "scope", "scope_path", "origin")
296
+ list_select_related = ("role", "permission", "permission__content_type")
297
+ list_filter = ("origin", "scope", "role")
298
+ search_fields = ("role__slug", "permission__action", "permission__content_type__model")
299
+ actions_list = ["permission_matrix"]
300
+
301
+ @action(
302
+ description="Permission matrix",
303
+ url_path="matrix",
304
+ permissions=["view"],
305
+ icon="grid_view",
306
+ )
307
+ def permission_matrix(self, request: Any) -> Any:
308
+ from karni.accounts.matrix_admin import matrix_view
309
+
310
+ return matrix_view(self, request)
311
+
312
+ def get_urls(self) -> Any:
313
+ return [
314
+ *_fallback_urls(self, list_actions=("permission_matrix",)),
315
+ *super().get_urls(),
316
+ ]
317
+
318
+ return RoleAdmin, PermissionAdmin, RolePermissionAdmin
319
+
320
+
321
+ def _get_user_admin_class() -> type[Any]:
322
+ global _user_admin_class
323
+ if _user_admin_class is None:
324
+ _user_admin_class = _build_user_admin()
325
+ return _user_admin_class
326
+
327
+
328
+ def _get_role_admin_classes() -> tuple[type[Any], type[Any], type[Any]]:
329
+ global _role_admin_classes
330
+ if _role_admin_classes is None:
331
+ _role_admin_classes = _build_role_admins()
332
+ return _role_admin_classes
333
+
334
+
335
+ def __getattr__(name: str) -> Any:
336
+ """Lazily expose the admin classes (mirrors ``karni.admin``'s C6 pattern)."""
337
+ if name == "KarniUserAdmin":
338
+ return _get_user_admin_class()
339
+ if name == "RoleAdmin":
340
+ return _get_role_admin_classes()[0]
341
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
342
+
343
+
344
+ def _register_role_admins() -> None:
345
+ """Register the role tables on the configured admin site (idempotent)."""
346
+ from karni.accounts.models import Permission, Role, RolePermission
347
+
348
+ site_path: str = getattr(settings, "KARNI_ADMIN_SITE", "django.contrib.admin.site")
349
+ from karni.imports import import_setting
350
+
351
+ site: admin.AdminSite = import_setting("KARNI_ADMIN_SITE", site_path)
352
+ classes = _get_role_admin_classes()
353
+ for model, model_admin in zip((Role, Permission, RolePermission), classes, strict=True):
354
+ if not site.is_registered(model):
355
+ site.register(model, model_admin)
356
+
357
+
358
+ def _register_default_user_admin() -> None:
359
+ """Register the default user on the configured admin site.
360
+
361
+ Runs at import time (``django.contrib.admin`` autodiscovers this module)
362
+ but only when the project kept ``AUTH_USER_MODEL = "karni_accounts.User"``
363
+ — a swapped model registers its own subclass instead (see the module
364
+ docstring). Idempotent: an existing registration is left untouched.
365
+ """
366
+ if settings.AUTH_USER_MODEL != "karni_accounts.User":
367
+ return
368
+
369
+ from django.contrib.auth import get_user_model
370
+
371
+ site_path: str = getattr(settings, "KARNI_ADMIN_SITE", "django.contrib.admin.site")
372
+ from karni.imports import import_setting
373
+
374
+ site: admin.AdminSite = import_setting("KARNI_ADMIN_SITE", site_path)
375
+ user_model = get_user_model()
376
+ if not site.is_registered(user_model):
377
+ site.register(user_model, _get_user_admin_class())
378
+
379
+
380
+ _register_default_user_admin()
381
+ _register_role_admins()
@@ -0,0 +1,15 @@
1
+ """HTTP surface of :mod:`karni.accounts`: serializers, views and throttles.
2
+
3
+ Kept import-light on purpose — importing this package must never pull in
4
+ models or settings-dependent code, so Django's app loading and lazy URLconf
5
+ imports stay safe. Import the concrete pieces from their modules::
6
+
7
+ from karni.accounts.api.serializers import MeSerializer
8
+ from karni.accounts.api.throttles import AccountRateThrottle
9
+ from karni.accounts.api.views import LoginView
10
+
11
+ Override / escape hatch: every endpoint can be replaced by declaring your own
12
+ ``path("api/auth/<endpoint>/", YourView.as_view())`` *before*
13
+ ``include("karni.accounts.urls")`` — first match wins. Subclass the views or
14
+ serializers here to change one behaviour and keep the rest.
15
+ """
@@ -0,0 +1,132 @@
1
+ """The permission matrix and effective permissions as an API (ADR 0002, P5).
2
+
3
+ The same four functions the admin page uses (:mod:`karni.accounts.matrix`),
4
+ so a project can build its own screen, seed the matrix in fixtures and assert
5
+ it in tests:
6
+
7
+ * ``GET permissions/matrix/`` — roles, models (with the scopes each may
8
+ take) and every cell;
9
+ * ``POST permissions/matrix/`` — ``{"changes": [{"role", "permission",
10
+ "scope"}], "dry_run": false}``; ``scope`` is a scope value, ``null`` to
11
+ revoke or ``"declared"`` to hand the cell back to ``KARNI_ROLES``. The
12
+ answer is the plan (changes and refusals); a refused request is a 400
13
+ with code ``E213`` and nothing written; ``dry_run`` returns the plan
14
+ without writing — the confirmation diff for a custom UI;
15
+ * ``GET permissions/effective/`` — the requester's own effective
16
+ permissions; ``permissions/effective/<user id>/`` anyone else's.
17
+
18
+ Gated on the grants: ``karni_accounts.rolepermission:view`` to read the
19
+ matrix or someone else's effective permissions, ``:change`` to post.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import Any, ClassVar
25
+
26
+ from django.contrib.auth import get_user_model
27
+ from django.core.exceptions import ValidationError as DjangoValidationError
28
+ from drf_spectacular.types import OpenApiTypes
29
+ from drf_spectacular.utils import extend_schema
30
+ from rest_framework import serializers, status
31
+ from rest_framework.exceptions import NotFound, PermissionDenied
32
+ from rest_framework.permissions import SAFE_METHODS, BasePermission, IsAuthenticated
33
+ from rest_framework.request import Request
34
+ from rest_framework.response import Response
35
+ from rest_framework.views import APIView
36
+
37
+ from karni.accounts.matrix import (
38
+ MATRIX_PERMISSION,
39
+ MatrixRefused,
40
+ apply_changes,
41
+ build_matrix,
42
+ effective_permissions,
43
+ plan_changes,
44
+ )
45
+ from karni.accounts.roles import user_can
46
+
47
+ __all__ = [
48
+ "EffectivePermissionsView",
49
+ "MatrixChangeSerializer",
50
+ "MatrixChangesSerializer",
51
+ "MatrixPermission",
52
+ "PermissionMatrixView",
53
+ ]
54
+
55
+
56
+ class MatrixChangeSerializer(serializers.Serializer):
57
+ """One requested cell: ``scope`` is a value, ``null`` (revoke) or ``"declared"``."""
58
+
59
+ role = serializers.CharField()
60
+ permission = serializers.CharField()
61
+ scope = serializers.CharField(allow_null=True)
62
+
63
+
64
+ class MatrixChangesSerializer(serializers.Serializer):
65
+ """The ``POST`` body: the cells to change and whether to only plan."""
66
+
67
+ changes = MatrixChangeSerializer(many=True)
68
+ dry_run = serializers.BooleanField(default=False)
69
+
70
+
71
+ class MatrixPermission(BasePermission):
72
+ """``rolepermission:view`` for safe methods, ``rolepermission:change`` otherwise."""
73
+
74
+ message = "This endpoint requires the permission to view or change role permissions."
75
+
76
+ def has_permission(self, request: Request, view: Any) -> bool:
77
+ user = request.user
78
+ if user is None or not getattr(user, "is_authenticated", False):
79
+ return False
80
+ action = "view" if request.method in SAFE_METHODS else "change"
81
+ return user_can(user, f"{MATRIX_PERMISSION}:{action}")
82
+
83
+
84
+ class PermissionMatrixView(APIView):
85
+ """``GET``/``POST permissions/matrix/`` — the matrix, and changes to it."""
86
+
87
+ permission_classes: ClassVar[list[type]] = [MatrixPermission]
88
+ serializer_class = MatrixChangesSerializer
89
+
90
+ def get(self, request: Request) -> Response:
91
+ return Response(build_matrix().to_dict())
92
+
93
+ def post(self, request: Request) -> Response:
94
+ serializer = MatrixChangesSerializer(data=request.data)
95
+ serializer.is_valid(raise_exception=True)
96
+ requested = [
97
+ (change["role"], change["permission"], change["scope"])
98
+ for change in serializer.validated_data["changes"]
99
+ ]
100
+ if serializer.validated_data["dry_run"]:
101
+ return Response(plan_changes(request.user, requested).to_dict())
102
+ try:
103
+ plan = apply_changes(request.user, requested)
104
+ except MatrixRefused as exc:
105
+ return Response(
106
+ {"code": "E213", "detail": str(exc), **exc.plan.to_dict()},
107
+ status=status.HTTP_400_BAD_REQUEST,
108
+ )
109
+ return Response(plan.to_dict())
110
+
111
+
112
+ @extend_schema(responses={200: OpenApiTypes.OBJECT})
113
+ class EffectivePermissionsView(APIView):
114
+ """``GET permissions/effective/[<user id>/]`` — what a user may do, and why."""
115
+
116
+ permission_classes: ClassVar[list[type]] = [IsAuthenticated]
117
+
118
+ def get(self, request: Request, user_id: str | None = None) -> Response:
119
+ target = request.user
120
+ if user_id is not None and str(user_id) != str(target.pk):
121
+ if not user_can(request.user, f"{MATRIX_PERMISSION}:view"):
122
+ raise PermissionDenied(
123
+ detail="Viewing another user's effective permissions requires "
124
+ f"{MATRIX_PERMISSION}:view."
125
+ )
126
+ try:
127
+ target = get_user_model()._default_manager.filter(pk=user_id).first()
128
+ except (ValueError, DjangoValidationError):
129
+ target = None
130
+ if target is None:
131
+ raise NotFound
132
+ return Response(effective_permissions(target).to_dict())