oxutils 0.1.8__py3-none-any.whl → 0.1.11__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 (53) hide show
  1. oxutils/__init__.py +2 -1
  2. oxutils/constants.py +4 -0
  3. oxutils/jwt/auth.py +150 -1
  4. oxutils/jwt/models.py +13 -0
  5. oxutils/logger/receivers.py +10 -6
  6. oxutils/models/base.py +102 -0
  7. oxutils/models/fields.py +79 -0
  8. oxutils/oxiliere/apps.py +6 -1
  9. oxutils/oxiliere/authorization.py +45 -0
  10. oxutils/oxiliere/caches.py +7 -7
  11. oxutils/oxiliere/checks.py +31 -0
  12. oxutils/oxiliere/constants.py +3 -0
  13. oxutils/oxiliere/context.py +16 -0
  14. oxutils/oxiliere/exceptions.py +16 -0
  15. oxutils/oxiliere/management/commands/grant_tenant_owners.py +19 -0
  16. oxutils/oxiliere/management/commands/init_oxiliere_system.py +30 -11
  17. oxutils/oxiliere/middleware.py +29 -13
  18. oxutils/oxiliere/models.py +130 -19
  19. oxutils/oxiliere/permissions.py +6 -5
  20. oxutils/oxiliere/schemas.py +13 -4
  21. oxutils/oxiliere/signals.py +5 -0
  22. oxutils/oxiliere/utils.py +14 -0
  23. oxutils/pagination/__init__.py +0 -0
  24. oxutils/pagination/cursor.py +367 -0
  25. oxutils/permissions/__init__.py +0 -0
  26. oxutils/permissions/actions.py +57 -0
  27. oxutils/permissions/admin.py +3 -0
  28. oxutils/permissions/apps.py +10 -0
  29. oxutils/permissions/caches.py +19 -0
  30. oxutils/permissions/checks.py +188 -0
  31. oxutils/permissions/constants.py +0 -0
  32. oxutils/permissions/controllers.py +344 -0
  33. oxutils/permissions/exceptions.py +60 -0
  34. oxutils/permissions/management/__init__.py +0 -0
  35. oxutils/permissions/management/commands/__init__.py +0 -0
  36. oxutils/permissions/management/commands/load_permission_preset.py +112 -0
  37. oxutils/permissions/migrations/0001_initial.py +112 -0
  38. oxutils/permissions/migrations/0002_alter_grant_role.py +19 -0
  39. oxutils/permissions/migrations/0003_alter_grant_options_alter_group_options_and_more.py +33 -0
  40. oxutils/permissions/migrations/__init__.py +0 -0
  41. oxutils/permissions/models.py +171 -0
  42. oxutils/permissions/perms.py +95 -0
  43. oxutils/permissions/queryset.py +92 -0
  44. oxutils/permissions/schemas.py +276 -0
  45. oxutils/permissions/services.py +663 -0
  46. oxutils/permissions/tests.py +3 -0
  47. oxutils/permissions/utils.py +628 -0
  48. oxutils/settings.py +1 -0
  49. oxutils/users/migrations/0002_alter_user_first_name_alter_user_last_name.py +23 -0
  50. oxutils/users/models.py +2 -0
  51. {oxutils-0.1.8.dist-info → oxutils-0.1.11.dist-info}/METADATA +1 -1
  52. {oxutils-0.1.8.dist-info → oxutils-0.1.11.dist-info}/RECORD +53 -19
  53. {oxutils-0.1.8.dist-info → oxutils-0.1.11.dist-info}/WHEEL +0 -0
@@ -0,0 +1,628 @@
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
+ def str_check(user: AbstractBaseUser, perm: str, **context: Any) -> bool:
391
+ """
392
+ Vérifie si un utilisateur possède les permissions requises à partir d'une chaîne formatée.
393
+
394
+ Args:
395
+ user: L'utilisateur dont on vérifie les permissions
396
+ perm: Chaîne de permission au format "<scope>:<actions>:<group>?key=value&key2=value2"
397
+ - scope: Le scope à vérifier (ex: 'articles')
398
+ - actions: Actions requises (ex: 'rw', 'r', 'rwdx')
399
+ - group: (Optionnel) Slug du groupe
400
+ - query params: (Optionnel) Contexte sous forme de query parameters
401
+ **context: Contexte additionnel pour filtrer les grants (fusionné avec les query params)
402
+
403
+ Returns:
404
+ True si l'utilisateur possède les permissions requises, False sinon
405
+
406
+ Example:
407
+ >>> # Vérifier lecture sur articles
408
+ >>> str_check(user, 'articles:r')
409
+ True
410
+ >>> # Vérifier écriture sur articles dans le groupe staff
411
+ >>> str_check(user, 'articles:w:staff')
412
+ True
413
+ >>> # Avec contexte via query params
414
+ >>> str_check(user, 'articles:w?tenant_id=123&status=published')
415
+ False
416
+ >>> # Avec groupe et contexte
417
+ >>> str_check(user, 'articles:w:staff?tenant_id=123')
418
+ True
419
+ >>> # Contexte mixte (query params + kwargs)
420
+ >>> str_check(user, 'articles:w?tenant_id=123', level=2)
421
+ False
422
+ """
423
+ from .caches import cache_check
424
+
425
+ # Séparer la partie principale des query params
426
+ if '?' in perm:
427
+ from urllib.parse import parse_qs
428
+
429
+ main_part, query_string = perm.split('?', 1)
430
+ # Parser les query params
431
+ parsed_qs = parse_qs(query_string)
432
+ # Convertir en dict simple (prendre la première valeur de chaque liste)
433
+ query_context = {k: v[0] if len(v) == 1 else v for k, v in parsed_qs.items()}
434
+ # Convertir les valeurs numériques
435
+ for k, v in query_context.items():
436
+ if isinstance(v, str) and v.isdigit():
437
+ query_context[k] = int(v)
438
+ else:
439
+ main_part = perm
440
+ query_context = {}
441
+
442
+ # Parser la partie principale
443
+ parts = main_part.split(':')
444
+
445
+ if len(parts) < 2:
446
+ raise ValueError(
447
+ f"Format de permission invalide: '{perm}'. "
448
+ "Format attendu: '<scope>:<actions>' ou '<scope>:<actions>:<group>' "
449
+ "ou '<scope>:<actions>:<group>?key=value&key2=value2'"
450
+ )
451
+
452
+ scope = parts[0]
453
+ actions_str = parts[1]
454
+ group = parts[2] if len(parts) > 2 else None
455
+
456
+ # Convertir la chaîne d'actions en liste
457
+ # 'rwd' -> ['r', 'w', 'd']
458
+ required = list(actions_str)
459
+
460
+ # Fusionner les contextes (kwargs ont priorité sur query params)
461
+ final_context = {**query_context, **context}
462
+
463
+ return cache_check(user, scope, required, group=group, **final_context)
464
+
465
+ def load_preset(*, force: bool = False) -> dict[str, int]:
466
+ """
467
+ Charge un preset de permissions depuis les settings Django.
468
+ Utilisé par la commande de management load_permission_preset.
469
+
470
+ Par sécurité, si des rôles existent déjà en base, la fonction lève une exception
471
+ sauf si force=True est passé explicitement.
472
+
473
+ Args:
474
+ force: Si True, permet de charger le preset même si des rôles existent déjà.
475
+ Par défaut False pour éviter l'écrasement accidentel.
476
+
477
+ Le preset doit être défini dans settings.PERMISSION_PRESET avec la structure suivante:
478
+
479
+ PERMISSION_PRESET = {
480
+ "roles": [
481
+ {
482
+ "name": "Accountant",
483
+ "slug": "accountant"
484
+ },
485
+ {
486
+ "name": "Admin",
487
+ "slug": "admin"
488
+ }
489
+ ],
490
+ "scopes": ['users', 'articles', 'comments'],
491
+ "group": [
492
+ {
493
+ "name": "Admins",
494
+ "slug": "admins",
495
+ "roles": ["admin"]
496
+ },
497
+ {
498
+ "name": "Accountants",
499
+ "slug": "accountants",
500
+ "roles": ["accountant"]
501
+ }
502
+ ],
503
+ "role_grants": [
504
+ {
505
+ "role": "admin",
506
+ "scope": "users",
507
+ "actions": ["r", "w", "d"],
508
+ "context": {}
509
+ # "group": "slug" # Optionnel: si absent ou None, RoleGrant générique
510
+ },
511
+ {
512
+ "role": "accountant",
513
+ "scope": "users",
514
+ "actions": ["r"],
515
+ "context": {},
516
+ "group": "accountants" # RoleGrant spécifique au groupe accountants
517
+ }
518
+ ]
519
+ }
520
+
521
+ Returns:
522
+ Dictionnaire avec les statistiques de création:
523
+ {
524
+ "roles": nombre de rôles créés,
525
+ "groups": nombre de groupes créés,
526
+ "role_grants": nombre de role_grants créés
527
+ }
528
+
529
+ Raises:
530
+ AttributeError: Si PERMISSION_PRESET n'est pas défini dans settings
531
+ KeyError: Si une clé requise est manquante dans le preset
532
+ PermissionError: Si des rôles existent déjà et force=False
533
+ """
534
+ from django.conf import settings
535
+
536
+ # Récupérer le preset depuis les settings
537
+ preset = getattr(settings, 'PERMISSION_PRESET', None)
538
+ if preset is None:
539
+ raise AttributeError(
540
+ "PERMISSION_PRESET n'est pas défini dans les settings Django"
541
+ )
542
+
543
+ # Sécurité : vérifier si des rôles existent déjà
544
+ existing_roles_count = Role.objects.count()
545
+ if existing_roles_count > 0 and not force:
546
+ raise PermissionError(
547
+ f"Des rôles existent déjà en base de données ({existing_roles_count} rôle(s)). "
548
+ "Pour charger le preset malgré tout, utilisez l'option --force. "
549
+ "Attention : cela peut créer des doublons ou modifier les permissions existantes."
550
+ )
551
+
552
+ stats = {
553
+ "roles": 0,
554
+ "groups": 0,
555
+ "role_grants": 0
556
+ }
557
+
558
+ # Cache local pour éviter les requêtes répétées
559
+ roles_cache: dict[str, Role] = {}
560
+ groups_cache: dict[str, Group] = {}
561
+
562
+ # Créer les rôles et peupler le cache
563
+ roles_data = preset.get('roles', [])
564
+ for role_data in roles_data:
565
+ role, created = Role.objects.get_or_create(
566
+ slug=role_data['slug'],
567
+ defaults={'name': role_data['name']}
568
+ )
569
+ roles_cache[role.slug] = role
570
+ if created:
571
+ stats['roles'] += 1
572
+
573
+ # Créer les groupes et peupler le cache
574
+ groups_data = preset.get('group', [])
575
+ for group_data in groups_data:
576
+ group, created = Group.objects.get_or_create(
577
+ slug=group_data['slug'],
578
+ defaults={'name': group_data['name']}
579
+ )
580
+ groups_cache[group.slug] = group
581
+ if created:
582
+ stats['groups'] += 1
583
+
584
+ # Associer les rôles au groupe en utilisant le cache
585
+ role_slugs = group_data.get('roles', [])
586
+ for role_slug in role_slugs:
587
+ # Utiliser le cache au lieu de requêter la base
588
+ role = roles_cache.get(role_slug)
589
+ if role is None:
590
+ raise ValueError(
591
+ f"Le rôle '{role_slug}' n'existe pas pour le groupe '{group.slug}'"
592
+ )
593
+ group.roles.add(role)
594
+
595
+ # Créer les role_grants en utilisant le cache
596
+ role_grants_data = preset.get('role_grants', [])
597
+ for rg_data in role_grants_data:
598
+ # Utiliser le cache au lieu de requêter la base
599
+ role = roles_cache.get(rg_data['role'])
600
+ if role is None:
601
+ raise ValueError(
602
+ f"Le rôle '{rg_data['role']}' n'existe pas pour le role_grant"
603
+ )
604
+
605
+ # Gérer le groupe optionnel
606
+ group_obj = None
607
+ group_slug = rg_data.get('group')
608
+ if group_slug:
609
+ group_obj = groups_cache.get(group_slug)
610
+ if group_obj is None:
611
+ raise ValueError(
612
+ f"Le groupe '{group_slug}' n'existe pas pour le role_grant"
613
+ )
614
+
615
+ # Utiliser get_or_create avec la contrainte complète (role, scope, group)
616
+ role_grant, created = RoleGrant.objects.get_or_create(
617
+ role=role,
618
+ scope=rg_data['scope'],
619
+ group=group_obj,
620
+ defaults={
621
+ 'actions': rg_data.get('actions', []),
622
+ 'context': rg_data.get('context', {})
623
+ }
624
+ )
625
+ if created:
626
+ stats['role_grants'] += 1
627
+
628
+ return stats
oxutils/settings.py CHANGED
@@ -23,6 +23,7 @@ class OxUtilsSettings(BaseSettings):
23
23
  service_name: Optional[str] = 'Oxutils'
24
24
  site_name: Optional[str] = 'Oxiliere'
25
25
  site_domain: Optional[str] = 'oxiliere.com'
26
+ multitenancy: bool = Field(False)
26
27
 
27
28
  # Auth JWT Settings (JWT_SIGNING_KEY)
28
29
  jwt_signing_key: Optional[str] = None
@@ -0,0 +1,23 @@
1
+ # Generated by Django 5.2.9 on 2025-12-29 13:28
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('users', '0001_initial'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AlterField(
14
+ model_name='user',
15
+ name='first_name',
16
+ field=models.CharField(blank=True, max_length=255, null=True),
17
+ ),
18
+ migrations.AlterField(
19
+ model_name='user',
20
+ name='last_name',
21
+ field=models.CharField(blank=True, max_length=255, null=True),
22
+ ),
23
+ ]
oxutils/users/models.py CHANGED
@@ -57,6 +57,8 @@ class User(AbstractUser, SafeDeleteModel, BaseModelMixin):
57
57
 
58
58
  oxi_id = models.UUIDField(unique=True) # id venant de auth.oxi.com
59
59
  email = models.EmailField(unique=True)
60
+ first_name = models.CharField(max_length=255, blank=True, null=True)
61
+ last_name = models.CharField(max_length=255, blank=True, null=True)
60
62
  is_active = models.BooleanField(default=True)
61
63
  subscription_plan = models.CharField(max_length=255, null=True, blank=True)
62
64
  subscription_status = models.CharField(max_length=255, null=True, blank=True)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oxutils
3
- Version: 0.1.8
3
+ Version: 0.1.11
4
4
  Summary: Production-ready utilities for Django applications in the Oxiliere ecosystem
5
5
  Keywords: django,utilities,jwt,s3,audit,logging,celery,structlog
6
6
  Author: Edimedia Mutoke