oxutils 0.1.6__py3-none-any.whl → 0.1.14__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 (68) hide show
  1. oxutils/__init__.py +2 -2
  2. oxutils/audit/migrations/0001_initial.py +2 -2
  3. oxutils/audit/models.py +2 -2
  4. oxutils/constants.py +6 -0
  5. oxutils/jwt/auth.py +150 -1
  6. oxutils/jwt/models.py +81 -0
  7. oxutils/jwt/tokens.py +69 -0
  8. oxutils/jwt/utils.py +45 -0
  9. oxutils/logger/__init__.py +10 -0
  10. oxutils/logger/receivers.py +10 -6
  11. oxutils/logger/settings.py +2 -2
  12. oxutils/models/base.py +102 -0
  13. oxutils/models/fields.py +79 -0
  14. oxutils/oxiliere/apps.py +9 -1
  15. oxutils/oxiliere/authorization.py +45 -0
  16. oxutils/oxiliere/caches.py +13 -11
  17. oxutils/oxiliere/checks.py +31 -0
  18. oxutils/oxiliere/constants.py +3 -0
  19. oxutils/oxiliere/context.py +16 -0
  20. oxutils/oxiliere/exceptions.py +16 -0
  21. oxutils/oxiliere/management/commands/grant_tenant_owners.py +19 -0
  22. oxutils/oxiliere/management/commands/init_oxiliere_system.py +30 -11
  23. oxutils/oxiliere/middleware.py +65 -11
  24. oxutils/oxiliere/models.py +146 -9
  25. oxutils/oxiliere/permissions.py +28 -35
  26. oxutils/oxiliere/schemas.py +16 -6
  27. oxutils/oxiliere/signals.py +5 -0
  28. oxutils/oxiliere/utils.py +36 -1
  29. oxutils/pagination/cursor.py +367 -0
  30. oxutils/permissions/__init__.py +0 -0
  31. oxutils/permissions/actions.py +57 -0
  32. oxutils/permissions/admin.py +3 -0
  33. oxutils/permissions/apps.py +10 -0
  34. oxutils/permissions/caches.py +33 -0
  35. oxutils/permissions/checks.py +188 -0
  36. oxutils/permissions/constants.py +0 -0
  37. oxutils/permissions/controllers.py +344 -0
  38. oxutils/permissions/exceptions.py +60 -0
  39. oxutils/permissions/management/__init__.py +0 -0
  40. oxutils/permissions/management/commands/__init__.py +0 -0
  41. oxutils/permissions/management/commands/load_permission_preset.py +112 -0
  42. oxutils/permissions/migrations/0001_initial.py +112 -0
  43. oxutils/permissions/migrations/0002_alter_grant_role.py +19 -0
  44. oxutils/permissions/migrations/0003_alter_grant_options_alter_group_options_and_more.py +33 -0
  45. oxutils/permissions/migrations/__init__.py +0 -0
  46. oxutils/permissions/models.py +171 -0
  47. oxutils/permissions/perms.py +201 -0
  48. oxutils/permissions/queryset.py +92 -0
  49. oxutils/permissions/schemas.py +276 -0
  50. oxutils/permissions/services.py +663 -0
  51. oxutils/permissions/tests.py +3 -0
  52. oxutils/permissions/utils.py +784 -0
  53. oxutils/settings.py +14 -194
  54. oxutils/users/apps.py +1 -1
  55. oxutils/users/migrations/0001_initial.py +47 -0
  56. oxutils/users/migrations/0002_alter_user_first_name_alter_user_last_name.py +23 -0
  57. oxutils/users/migrations/0003_user_photo.py +18 -0
  58. oxutils/users/models.py +3 -0
  59. oxutils/utils.py +25 -0
  60. {oxutils-0.1.6.dist-info → oxutils-0.1.14.dist-info}/METADATA +14 -11
  61. oxutils-0.1.14.dist-info/RECORD +123 -0
  62. oxutils/jwt/client.py +0 -123
  63. oxutils/jwt/constants.py +0 -1
  64. oxutils/s3/settings.py +0 -34
  65. oxutils/s3/storages.py +0 -130
  66. oxutils-0.1.6.dist-info/RECORD +0 -88
  67. /oxutils/{s3 → pagination}/__init__.py +0 -0
  68. {oxutils-0.1.6.dist-info → oxutils-0.1.14.dist-info}/WHEEL +0 -0
@@ -0,0 +1,784 @@
1
+ from typing import Optional, Any
2
+ from django.db.models import Q
3
+ from django.db import transaction
4
+ from django.contrib.auth.models import AbstractBaseUser
5
+
6
+ from .models import Grant, RoleGrant, Group, UserGroup, Role
7
+ from .actions import expand_actions
8
+ from .exceptions import (
9
+ RoleNotFoundException,
10
+ GroupNotFoundException,
11
+ GrantNotFoundException,
12
+ GroupAlreadyAssignedException
13
+ )
14
+
15
+
16
+
17
+
18
+ @transaction.atomic
19
+ def assign_role(
20
+ user: AbstractBaseUser,
21
+ role: str,
22
+ *,
23
+ by: Optional[AbstractBaseUser] = None,
24
+ user_group: Optional[UserGroup] = None
25
+ ) -> None:
26
+ """
27
+ Assigne un rôle à un utilisateur en créant ou mettant à jour les grants correspondants.
28
+
29
+ Args:
30
+ user: L'utilisateur à qui assigner le rôle
31
+ role: Le slug du rôle à assigner
32
+ by: L'utilisateur qui effectue l'assignation (pour traçabilité)
33
+ user_group: Le UserGroup associé si le rôle est assigné via un groupe
34
+
35
+ Raises:
36
+ RoleNotFoundException: Si le rôle n'existe pas
37
+ """
38
+ try:
39
+ role_obj = Role.objects.get(slug=role)
40
+ except Role.DoesNotExist:
41
+ raise RoleNotFoundException(detail=f"Le rôle '{role}' n'existe pas")
42
+
43
+ # Filtrer les RoleGrants selon le groupe si fourni
44
+ if user_group:
45
+ # Si assigné via un groupe, utiliser les RoleGrants spécifiques au groupe ou génériques
46
+ role_grants = RoleGrant.objects.filter(
47
+ role__slug=role
48
+ ).filter(
49
+ Q(group=user_group.group) | Q(group__isnull=True)
50
+ )
51
+ else:
52
+ # Si assigné directement, utiliser uniquement les RoleGrants génériques
53
+ role_grants = RoleGrant.objects.filter(role__slug=role, group__isnull=True)
54
+
55
+ for rg in role_grants:
56
+ Grant.objects.update_or_create(
57
+ user=user,
58
+ scope=rg.scope,
59
+ role=role_obj,
60
+ defaults={
61
+ "actions": expand_actions(rg.actions),
62
+ "context": rg.context,
63
+ "user_group": user_group,
64
+ "created_by": by,
65
+ }
66
+ )
67
+
68
+ def revoke_role(user: AbstractBaseUser, role: str) -> tuple[int, dict[str, int]]:
69
+ """
70
+ Révoque un rôle d'un utilisateur en supprimant tous les grants associés.
71
+
72
+ Args:
73
+ user: L'utilisateur dont on révoque le rôle
74
+ role: Le slug du rôle à révoquer
75
+
76
+ Returns:
77
+ Tuple contenant le nombre d'objets supprimés et un dictionnaire des types supprimés
78
+
79
+ Raises:
80
+ RoleNotFoundException: Si le rôle n'existe pas
81
+ """
82
+ try:
83
+ role_obj = Role.objects.get(slug=role)
84
+ except Role.DoesNotExist:
85
+ raise RoleNotFoundException(detail=f"Le rôle '{role}' n'existe pas")
86
+
87
+ return Grant.objects.filter(
88
+ user__pk=user.pk,
89
+ role__slug=role
90
+ ).delete()
91
+
92
+
93
+ @transaction.atomic
94
+ def assign_group(user: AbstractBaseUser, group: str, by: Optional[AbstractBaseUser] = None) -> UserGroup:
95
+ """
96
+ Assigne tous les rôles d'un groupe à un utilisateur.
97
+
98
+ Args:
99
+ user: L'utilisateur à qui assigner le groupe
100
+ group: Le slug du groupe à assigner
101
+ by: L'utilisateur qui effectue l'assignation (pour traçabilité)
102
+
103
+ Returns:
104
+ L'objet UserGroup créé ou existant
105
+
106
+ Raises:
107
+ GroupNotFoundException: Si le groupe n'existe pas
108
+ GroupAlreadyAssignedException: Si le groupe est déjà assigné
109
+ """
110
+ if UserGroup.objects.filter(user=user, group__slug=group).exists():
111
+ raise GroupAlreadyAssignedException(
112
+ detail=f"Le groupe '{group}' est déjà assigné à l'utilisateur"
113
+ )
114
+
115
+ try:
116
+ _group: Group = Group.objects.get(slug=group)
117
+ except Group.DoesNotExist:
118
+ raise GroupNotFoundException(detail=f"Le groupe '{group}' n'existe pas")
119
+
120
+ # Créer le UserGroup d'abord
121
+ user_group, created = UserGroup.objects.get_or_create(user=user, group=_group)
122
+
123
+ # Assigner tous les rôles du groupe avec le lien vers UserGroup
124
+ for role in _group.roles.all():
125
+ assign_role(user, role.slug, by=by, user_group=user_group)
126
+
127
+ return user_group
128
+
129
+
130
+ @transaction.atomic
131
+ def revoke_group(user: AbstractBaseUser, group: str) -> tuple[int, dict[str, int]]:
132
+ """
133
+ Révoque tous les rôles d'un groupe d'un utilisateur.
134
+ Supprime tous les grants liés au UserGroup et le UserGroup lui-même.
135
+
136
+ Args:
137
+ user: L'utilisateur dont on révoque le groupe
138
+ group: Le slug du groupe à révoquer
139
+
140
+ Returns:
141
+ Tuple contenant le nombre d'objets supprimés et un dictionnaire des types supprimés
142
+
143
+ Raises:
144
+ GroupNotFoundException: Si le groupe n'existe pas
145
+ GroupNotFoundException: Si le groupe n'est pas assigné à l'utilisateur
146
+ """
147
+ try:
148
+ _group: Group = Group.objects.get(slug=group)
149
+ except Group.DoesNotExist:
150
+ raise GroupNotFoundException(detail=f"Le groupe '{group}' n'existe pas")
151
+
152
+ try:
153
+ user_group = UserGroup.objects.get(user=user, group=_group)
154
+ except UserGroup.DoesNotExist:
155
+ raise GroupNotFoundException(
156
+ detail=f"Le groupe '{group}' n'est pas assigné à l'utilisateur"
157
+ )
158
+
159
+ # Supprimer tous les grants liés à ce UserGroup
160
+ grants_deleted, grants_info = Grant.objects.filter(
161
+ user=user,
162
+ user_group=user_group
163
+ ).delete()
164
+
165
+ # Supprimer le UserGroup
166
+ user_group.delete()
167
+
168
+ return grants_deleted, grants_info
169
+
170
+
171
+ @transaction.atomic
172
+ def override_grant(
173
+ user: AbstractBaseUser,
174
+ scope: str,
175
+ remove_actions: list[str]
176
+ ) -> None:
177
+ """
178
+ Modifie un grant existant en retirant certaines actions.
179
+ Si toutes les actions sont retirées, le grant est supprimé.
180
+ Le grant devient personnalisé (role=None) après modification.
181
+
182
+ Args:
183
+ user: L'utilisateur dont on modifie le grant
184
+ scope: Le scope du grant à modifier
185
+ remove_actions: Liste des actions à retirer (seront expandées)
186
+
187
+ Raises:
188
+ GrantNotFoundException: Si le grant n'existe pas
189
+ """
190
+ grant: Optional[Grant] = Grant.objects.select_related("user_group", "role").filter(user__pk=user.pk, scope=scope).first()
191
+ if not grant:
192
+ raise GrantNotFoundException(
193
+ detail=f"Aucun grant trouvé pour l'utilisateur sur le scope '{scope}'"
194
+ )
195
+
196
+ # Travailler avec les actions expandées du grant
197
+ current_actions: set[str] = set(grant.actions)
198
+ # Ne PAS expander les actions à retirer - on retire seulement ce qui est demandé
199
+ actions_to_remove: set[str] = set(remove_actions)
200
+
201
+ # Retirer les actions demandées des actions actuelles
202
+ remaining_actions = current_actions - actions_to_remove
203
+
204
+ # Si plus d'actions, supprimer le grant
205
+ if not remaining_actions:
206
+ user_group = grant.user_group
207
+ grant.delete()
208
+
209
+ # Si le grant était lié à un UserGroup, vérifier s'il reste des grants pour ce groupe
210
+ if user_group:
211
+ remaining_grants = Grant.objects.filter(
212
+ user=user,
213
+ user_group=user_group
214
+ ).exists()
215
+
216
+ # Si plus aucun grant lié à ce UserGroup, supprimer le UserGroup
217
+ if not remaining_grants:
218
+ user_group.delete()
219
+
220
+ return
221
+
222
+ # Mettre à jour le grant avec les nouvelles actions (garder la forme expandée)
223
+ grant.actions = sorted(remaining_actions)
224
+ grant.role = None # Le grant devient personnalisé
225
+ grant.save(update_fields=["actions", "role", "updated_at"])
226
+
227
+
228
+ @transaction.atomic
229
+ def group_sync(group_slug: str) -> dict[str, int]:
230
+ """
231
+ Synchronise les grants de tous les utilisateurs d'un groupe après modification des RoleGrants.
232
+ Réapplique tous les rôles du groupe pour assurer la cohérence des permissions héritées.
233
+
234
+ Cette fonction doit être appelée après :
235
+ - Création/modification/suppression d'un RoleGrant lié à un groupe
236
+ - Ajout/suppression d'un rôle dans un groupe
237
+
238
+ Args:
239
+ group_slug: Le slug du groupe à synchroniser
240
+
241
+ Returns:
242
+ Dictionnaire avec les statistiques:
243
+ {
244
+ "users_synced": nombre d'utilisateurs synchronisés,
245
+ "grants_updated": nombre de grants mis à jour/créés
246
+ }
247
+
248
+ Raises:
249
+ GroupNotFoundException: Si le groupe n'existe pas
250
+
251
+ Example:
252
+ >>> # Après modification d'un RoleGrant
253
+ >>> group_sync("admins")
254
+ {"users_synced": 5, "grants_updated": 15}
255
+ """
256
+ try:
257
+ group = Group.objects.prefetch_related('roles').get(slug=group_slug)
258
+ except Group.DoesNotExist:
259
+ raise GroupNotFoundException(detail=f"Le groupe '{group_slug}' n'existe pas")
260
+
261
+ # Récupérer tous les UserGroups liés à ce groupe
262
+ user_groups = UserGroup.objects.filter(group=group).select_related('user')
263
+
264
+ stats = {
265
+ "users_synced": 0,
266
+ "grants_updated": 0
267
+ }
268
+
269
+ # Pour chaque utilisateur du groupe
270
+ for user_group in user_groups:
271
+ user = user_group.user
272
+
273
+ # Récupérer les scopes avec des grants personnalisés (role=None) pour cet utilisateur et ce UserGroup
274
+ # Ces scopes doivent être exclus de la synchronisation
275
+ overridden_scopes = set(
276
+ Grant.objects.filter(
277
+ user=user,
278
+ user_group=user_group,
279
+ role__isnull=True
280
+ ).values_list('scope', flat=True)
281
+ )
282
+
283
+ # Supprimer uniquement les grants liés à ce UserGroup qui ont un rôle
284
+ # Les grants avec role=None sont des grants personnalisés (overridés) et doivent être préservés
285
+ deleted_count, _ = Grant.objects.filter(
286
+ user=user,
287
+ user_group=user_group,
288
+ role__isnull=False # Ne supprimer que les grants avec un rôle
289
+ ).delete()
290
+
291
+ # Préparer les grants à créer en bulk
292
+ grants_to_create = []
293
+
294
+ # Réassigner tous les rôles du groupe
295
+ for role in group.roles.all():
296
+ # Récupérer les RoleGrants pour ce rôle (spécifiques au groupe + génériques)
297
+ role_grants = RoleGrant.objects.filter(
298
+ role=role
299
+ ).filter(
300
+ Q(group=group) | Q(group__isnull=True)
301
+ )
302
+
303
+ # Préparer les grants correspondants, en excluant les scopes overridés
304
+ for rg in role_grants:
305
+ # Ignorer ce scope s'il a un grant personnalisé
306
+ if rg.scope in overridden_scopes:
307
+ continue
308
+
309
+ grants_to_create.append(
310
+ Grant(
311
+ user=user,
312
+ scope=rg.scope,
313
+ role=role,
314
+ actions=expand_actions(rg.actions),
315
+ context=rg.context,
316
+ user_group=user_group,
317
+ )
318
+ )
319
+
320
+ # Créer tous les grants en une seule requête
321
+ if grants_to_create:
322
+ Grant.objects.bulk_create(
323
+ grants_to_create,
324
+ update_conflicts=True,
325
+ unique_fields=["user", "scope", "role", "user_group"],
326
+ update_fields=["actions", "context", "updated_at"]
327
+ )
328
+ stats["grants_updated"] += len(grants_to_create)
329
+
330
+ stats["users_synced"] += 1
331
+
332
+ return stats
333
+
334
+
335
+ def check(
336
+ user: AbstractBaseUser,
337
+ scope: str,
338
+ required: list[str],
339
+ group: Optional[str] = None,
340
+ **context: Any
341
+ ) -> bool:
342
+ """
343
+ Vérifie si un utilisateur possède les permissions requises pour un scope donné.
344
+ Utilise l'opérateur PostgreSQL @> (contains) pour vérifier que toutes les actions
345
+ requises sont présentes dans le grant.
346
+
347
+ Args:
348
+ user: L'utilisateur dont on vérifie les permissions
349
+ scope: Le scope à vérifier (ex: 'articles', 'users', 'comments')
350
+ required: Liste des actions requises (ex: ['r'], ['w', 'r'], ['d'])
351
+ group: Slug du groupe optionnel pour filtrer les grants par groupe
352
+ **context: Contexte additionnel pour filtrer les grants (clés JSON)
353
+
354
+ Returns:
355
+ True si l'utilisateur possède toutes les actions requises, False sinon
356
+
357
+ Example:
358
+ >>> # Vérifier si l'utilisateur peut lire les articles
359
+ >>> check(user, 'articles', ['r'])
360
+ True
361
+ >>> # Vérifier avec contexte
362
+ >>> check(user, 'articles', ['w'], tenant_id=123)
363
+ False
364
+ >>> # Vérifier dans le contexte d'un groupe spécifique
365
+ >>> check(user, 'articles', ['w'], group='staff')
366
+ True
367
+
368
+ Note:
369
+ Les actions sont automatiquement expandées lors de la création du grant,
370
+ donc vérifier ['w'] vérifiera aussi ['r'] implicitement.
371
+ """
372
+ # Construire le filtre de base
373
+ grant_filter = Q(
374
+ user__pk=user.pk,
375
+ scope=scope,
376
+ actions__contains=list(required),
377
+ )
378
+
379
+ # Filtrer par groupe si spécifié
380
+ if group:
381
+ grant_filter &= Q(user_group__group__slug=group)
382
+
383
+ # Ajouter les filtres de contexte si fournis
384
+ if context:
385
+ grant_filter &= Q(context__contains=context)
386
+
387
+ # Vérifier l'existence d'un grant correspondant
388
+ return Grant.objects.filter(grant_filter).exists()
389
+
390
+
391
+ def any_action_check(
392
+ user: AbstractBaseUser,
393
+ scope: str,
394
+ required: list[str],
395
+ group: Optional[str] = None,
396
+ **context: Any
397
+ ) -> bool:
398
+ """
399
+ Vérifie si un utilisateur possède au moins une des actions requises pour un scope donné.
400
+
401
+ Cette fonction utilise une seule requête optimisée avec des conditions OR pour vérifier
402
+ si l'utilisateur possède au moins une des actions dans la liste.
403
+
404
+ Args:
405
+ user: L'utilisateur dont on vérifie les permissions
406
+ scope: Le scope à vérifier (ex: 'articles', 'invoices')
407
+ required: Liste des actions dont au moins une est requise (ex: ['r', 'w'], ['d'])
408
+ group: Slug du groupe optionnel pour filtrer les grants par groupe
409
+ **context: Contexte additionnel pour filtrer les grants (clés JSON)
410
+
411
+ Returns:
412
+ True si l'utilisateur possède au moins une des actions requises, False sinon
413
+
414
+ Example:
415
+ >>> # Vérifier si l'utilisateur peut lire OU écrire les articles
416
+ >>> any_action_check(user, 'articles', ['r', 'w'])
417
+ True
418
+ >>> # Vérifier avec contexte
419
+ >>> any_action_check(user, 'articles', ['w', 'd'], tenant_id=123)
420
+ False
421
+ >>> # Vérifier dans le contexte d'un groupe spécifique
422
+ >>> any_action_check(user, 'articles', ['r', 'w'], group='staff')
423
+ True
424
+
425
+ Note:
426
+ Les actions sont automatiquement expandées lors de la création du grant,
427
+ donc si un grant contient ['w'], il contient aussi ['r'] implicitement.
428
+ Cette fonction vérifie si AU MOINS UNE des actions requises est présente.
429
+ """
430
+ # Construire le filtre de base pour l'utilisateur et le scope
431
+ grant_filter = Q(user__pk=user.pk, scope=scope)
432
+
433
+ # Filtrer par groupe si spécifié
434
+ if group:
435
+ grant_filter &= Q(user_group__group__slug=group)
436
+
437
+ # Ajouter les filtres de contexte si fournis
438
+ if context:
439
+ grant_filter &= Q(context__contains=context)
440
+
441
+ # Vérifier si au moins une des actions requises est présente dans le grant
442
+ # Utilise l'opérateur overlap (&&) pour une requête optimale
443
+ grant_filter &= Q(actions__overlap=required)
444
+
445
+ # Vérifier l'existence d'un grant correspondant
446
+ return Grant.objects.filter(grant_filter).exists()
447
+
448
+
449
+ def any_permission_check(user: AbstractBaseUser, *str_perms: str) -> bool:
450
+ """
451
+ Vérifie si un utilisateur possède au moins une des permissions fournies.
452
+
453
+ Cette fonction parse toutes les permissions fournies et effectue une seule requête
454
+ optimisée avec des conditions OR pour vérifier si l'utilisateur possède au moins
455
+ une des permissions.
456
+
457
+ Args:
458
+ user: L'utilisateur dont on vérifie les permissions
459
+ *str_perms: Liste de chaînes de permissions au format standard
460
+ (ex: 'articles:r', 'invoices:w:staff', 'users:d?tenant_id=123')
461
+
462
+ Returns:
463
+ True si l'utilisateur possède au moins une des permissions, False sinon
464
+
465
+ Example:
466
+ >>> # Vérifier si l'utilisateur peut lire les articles OU écrire les factures
467
+ >>> any_permission_check(user, 'articles:r', 'invoices:w')
468
+ True
469
+ >>> # Avec différents groupes et contextes
470
+ >>> any_permission_check(
471
+ ... user,
472
+ ... 'articles:w:staff',
473
+ ... 'invoices:r:admin',
474
+ ... 'users:d?tenant_id=123'
475
+ ... )
476
+ False
477
+
478
+ Note:
479
+ Toute la vérification se fait au niveau de la base de données avec une seule
480
+ requête utilisant des conditions OR pour optimiser les performances.
481
+ """
482
+ if not str_perms:
483
+ return False
484
+
485
+ # Construire le filtre de base pour l'utilisateur
486
+ base_filter = Q(user__pk=user.pk)
487
+
488
+ # Construire les conditions OR pour chaque permission
489
+ permission_filters = Q()
490
+
491
+ for perm in str_perms:
492
+ # Parser la permission
493
+ scope, actions, group, context = parse_permission(perm)
494
+
495
+ # Construire le filtre pour cette permission spécifique
496
+ perm_filter = Q(scope=scope, actions__overlap=actions)
497
+
498
+ # Ajouter le filtre de groupe si spécifié
499
+ if group:
500
+ perm_filter &= Q(user_group__group__slug=group)
501
+
502
+ # Ajouter le filtre de contexte si fourni
503
+ if context:
504
+ perm_filter &= Q(context__contains=context)
505
+
506
+ # Ajouter cette permission aux conditions OR
507
+ permission_filters |= perm_filter
508
+
509
+ # Combiner le filtre de base avec les conditions OR et vérifier l'existence
510
+ return Grant.objects.filter(base_filter & permission_filters).exists()
511
+
512
+
513
+ def parse_permission(perm: str) -> tuple[str, list[str], Optional[str], dict[str, Any]]:
514
+ """
515
+ Parse une chaîne de permission et retourne ses composants.
516
+
517
+ Args:
518
+ perm: Chaîne de permission au format "<scope>:<actions>:<group>?key=value&key2=value2"
519
+ - scope: Le scope (ex: 'articles')
520
+ - actions: Actions requises (ex: 'rw', 'r', 'rwdx')
521
+ - group: (Optionnel) Slug du groupe
522
+ - query params: (Optionnel) Contexte sous forme de query parameters
523
+
524
+ Returns:
525
+ Tuple contenant (scope, actions_list, group, context_dict)
526
+
527
+ Raises:
528
+ ValueError: Si le format de la permission est invalide
529
+
530
+ Example:
531
+ >>> parse_permission('articles:rw')
532
+ ('articles', ['r', 'w'], None, {})
533
+ >>> parse_permission('articles:w:staff')
534
+ ('articles', ['w'], 'staff', {})
535
+ >>> parse_permission('articles:rw?tenant_id=123&status=published')
536
+ ('articles', ['r', 'w'], None, {'tenant_id': 123, 'status': 'published'})
537
+ >>> parse_permission('articles:w:staff?tenant_id=123')
538
+ ('articles', ['w'], 'staff', {'tenant_id': 123})
539
+ """
540
+ # Séparer la partie principale des query params
541
+ if '?' in perm:
542
+ from urllib.parse import parse_qs
543
+
544
+ main_part, query_string = perm.split('?', 1)
545
+ # Parser les query params
546
+ parsed_qs = parse_qs(query_string)
547
+ # Convertir en dict simple (prendre la première valeur de chaque liste)
548
+ query_context = {k: v[0] if len(v) == 1 else v for k, v in parsed_qs.items()}
549
+ # Convertir les valeurs numériques
550
+ for k, v in query_context.items():
551
+ if isinstance(v, str) and v.isdigit():
552
+ query_context[k] = int(v)
553
+ else:
554
+ main_part = perm
555
+ query_context = {}
556
+
557
+ # Parser la partie principale
558
+ parts = main_part.split(':')
559
+
560
+ if len(parts) < 2:
561
+ raise ValueError(
562
+ f"Format de permission invalide: '{perm}'. "
563
+ "Format attendu: '<scope>:<actions>' ou '<scope>:<actions>:<group>' "
564
+ "ou '<scope>:<actions>:<group>?key=value&key2=value2'"
565
+ )
566
+
567
+ scope = parts[0]
568
+ actions_str = parts[1]
569
+ group = parts[2] if len(parts) > 2 else None
570
+
571
+ # Convertir la chaîne d'actions en liste
572
+ # 'rwd' -> ['r', 'w', 'd']
573
+ actions_list = list(actions_str)
574
+
575
+ return scope, actions_list, group, query_context
576
+
577
+
578
+ def str_check(user: AbstractBaseUser, perm: str, **context: Any) -> bool:
579
+ """
580
+ Vérifie si un utilisateur possède les permissions requises à partir d'une chaîne formatée.
581
+
582
+ Args:
583
+ user: L'utilisateur dont on vérifie les permissions
584
+ perm: Chaîne de permission au format "<scope>:<actions>:<group>?key=value&key2=value2"
585
+ - scope: Le scope à vérifier (ex: 'articles')
586
+ - actions: Actions requises (ex: 'rw', 'r', 'rwdx')
587
+ - group: (Optionnel) Slug du groupe
588
+ - query params: (Optionnel) Contexte sous forme de query parameters
589
+ **context: Contexte additionnel pour filtrer les grants (fusionné avec les query params)
590
+
591
+ Returns:
592
+ True si l'utilisateur possède les permissions requises, False sinon
593
+
594
+ Example:
595
+ >>> # Vérifier lecture sur articles
596
+ >>> str_check(user, 'articles:r')
597
+ True
598
+ >>> # Vérifier écriture sur articles dans le groupe staff
599
+ >>> str_check(user, 'articles:w:staff')
600
+ True
601
+ >>> # Avec contexte via query params
602
+ >>> str_check(user, 'articles:w?tenant_id=123&status=published')
603
+ False
604
+ >>> # Avec groupe et contexte
605
+ >>> str_check(user, 'articles:w:staff?tenant_id=123')
606
+ True
607
+ >>> # Contexte mixte (query params + kwargs)
608
+ >>> str_check(user, 'articles:w?tenant_id=123', level=2)
609
+ False
610
+ """
611
+ from .caches import cache_check
612
+
613
+ # Parser la chaîne de permission
614
+ scope, required, group, query_context = parse_permission(perm)
615
+
616
+ # Fusionner les contextes (kwargs ont priorité sur query params)
617
+ final_context = {**query_context, **context}
618
+
619
+ return cache_check(user, scope, required, group=group, **final_context)
620
+
621
+ def load_preset(*, force: bool = False) -> dict[str, int]:
622
+ """
623
+ Charge un preset de permissions depuis les settings Django.
624
+ Utilisé par la commande de management load_permission_preset.
625
+
626
+ Par sécurité, si des rôles existent déjà en base, la fonction lève une exception
627
+ sauf si force=True est passé explicitement.
628
+
629
+ Args:
630
+ force: Si True, permet de charger le preset même si des rôles existent déjà.
631
+ Par défaut False pour éviter l'écrasement accidentel.
632
+
633
+ Le preset doit être défini dans settings.PERMISSION_PRESET avec la structure suivante:
634
+
635
+ PERMISSION_PRESET = {
636
+ "roles": [
637
+ {
638
+ "name": "Accountant",
639
+ "slug": "accountant"
640
+ },
641
+ {
642
+ "name": "Admin",
643
+ "slug": "admin"
644
+ }
645
+ ],
646
+ "scopes": ['users', 'articles', 'comments'],
647
+ "group": [
648
+ {
649
+ "name": "Admins",
650
+ "slug": "admins",
651
+ "roles": ["admin"]
652
+ },
653
+ {
654
+ "name": "Accountants",
655
+ "slug": "accountants",
656
+ "roles": ["accountant"]
657
+ }
658
+ ],
659
+ "role_grants": [
660
+ {
661
+ "role": "admin",
662
+ "scope": "users",
663
+ "actions": ["r", "w", "d"],
664
+ "context": {}
665
+ # "group": "slug" # Optionnel: si absent ou None, RoleGrant générique
666
+ },
667
+ {
668
+ "role": "accountant",
669
+ "scope": "users",
670
+ "actions": ["r"],
671
+ "context": {},
672
+ "group": "accountants" # RoleGrant spécifique au groupe accountants
673
+ }
674
+ ]
675
+ }
676
+
677
+ Returns:
678
+ Dictionnaire avec les statistiques de création:
679
+ {
680
+ "roles": nombre de rôles créés,
681
+ "groups": nombre de groupes créés,
682
+ "role_grants": nombre de role_grants créés
683
+ }
684
+
685
+ Raises:
686
+ AttributeError: Si PERMISSION_PRESET n'est pas défini dans settings
687
+ KeyError: Si une clé requise est manquante dans le preset
688
+ PermissionError: Si des rôles existent déjà et force=False
689
+ """
690
+ from django.conf import settings
691
+
692
+ # Récupérer le preset depuis les settings
693
+ preset = getattr(settings, 'PERMISSION_PRESET', None)
694
+ if preset is None:
695
+ raise AttributeError(
696
+ "PERMISSION_PRESET n'est pas défini dans les settings Django"
697
+ )
698
+
699
+ # Sécurité : vérifier si des rôles existent déjà
700
+ existing_roles_count = Role.objects.count()
701
+ if existing_roles_count > 0 and not force:
702
+ raise PermissionError(
703
+ f"Des rôles existent déjà en base de données ({existing_roles_count} rôle(s)). "
704
+ "Pour charger le preset malgré tout, utilisez l'option --force. "
705
+ "Attention : cela peut créer des doublons ou modifier les permissions existantes."
706
+ )
707
+
708
+ stats = {
709
+ "roles": 0,
710
+ "groups": 0,
711
+ "role_grants": 0
712
+ }
713
+
714
+ # Cache local pour éviter les requêtes répétées
715
+ roles_cache: dict[str, Role] = {}
716
+ groups_cache: dict[str, Group] = {}
717
+
718
+ # Créer les rôles et peupler le cache
719
+ roles_data = preset.get('roles', [])
720
+ for role_data in roles_data:
721
+ role, created = Role.objects.get_or_create(
722
+ slug=role_data['slug'],
723
+ defaults={'name': role_data['name']}
724
+ )
725
+ roles_cache[role.slug] = role
726
+ if created:
727
+ stats['roles'] += 1
728
+
729
+ # Créer les groupes et peupler le cache
730
+ groups_data = preset.get('group', [])
731
+ for group_data in groups_data:
732
+ group, created = Group.objects.get_or_create(
733
+ slug=group_data['slug'],
734
+ defaults={'name': group_data['name']}
735
+ )
736
+ groups_cache[group.slug] = group
737
+ if created:
738
+ stats['groups'] += 1
739
+
740
+ # Associer les rôles au groupe en utilisant le cache
741
+ role_slugs = group_data.get('roles', [])
742
+ for role_slug in role_slugs:
743
+ # Utiliser le cache au lieu de requêter la base
744
+ role = roles_cache.get(role_slug)
745
+ if role is None:
746
+ raise ValueError(
747
+ f"Le rôle '{role_slug}' n'existe pas pour le groupe '{group.slug}'"
748
+ )
749
+ group.roles.add(role)
750
+
751
+ # Créer les role_grants en utilisant le cache
752
+ role_grants_data = preset.get('role_grants', [])
753
+ for rg_data in role_grants_data:
754
+ # Utiliser le cache au lieu de requêter la base
755
+ role = roles_cache.get(rg_data['role'])
756
+ if role is None:
757
+ raise ValueError(
758
+ f"Le rôle '{rg_data['role']}' n'existe pas pour le role_grant"
759
+ )
760
+
761
+ # Gérer le groupe optionnel
762
+ group_obj = None
763
+ group_slug = rg_data.get('group')
764
+ if group_slug:
765
+ group_obj = groups_cache.get(group_slug)
766
+ if group_obj is None:
767
+ raise ValueError(
768
+ f"Le groupe '{group_slug}' n'existe pas pour le role_grant"
769
+ )
770
+
771
+ # Utiliser get_or_create avec la contrainte complète (role, scope, group)
772
+ role_grant, created = RoleGrant.objects.get_or_create(
773
+ role=role,
774
+ scope=rg_data['scope'],
775
+ group=group_obj,
776
+ defaults={
777
+ 'actions': rg_data.get('actions', []),
778
+ 'context': rg_data.get('context', {})
779
+ }
780
+ )
781
+ if created:
782
+ stats['role_grants'] += 1
783
+
784
+ return stats