django-lucy-assist 1.0.6__py3-none-any.whl → 1.0.7__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.
@@ -1,21 +1,258 @@
1
1
  """
2
2
  Service pour exécuter des opérations CRUD via Lucy Assist.
3
+
4
+ Ce service utilise les vues du projet hôte quand elles sont disponibles,
5
+ ce qui permet de bénéficier de toute la logique métier (services, logs, etc.)
6
+ présente dans les vues.
7
+
8
+ Si aucune vue n'est trouvée, il utilise un fallback direct sur les modèles.
3
9
  """
4
10
  from typing import Dict, List, Optional, Any
5
11
 
6
12
  from django.apps import apps
7
13
  from django.db import transaction
8
14
  from django.forms import modelform_factory
15
+ from django.test import RequestFactory
16
+ from django.urls import reverse, NoReverseMatch
17
+ from django.contrib.messages.storage.fallback import FallbackStorage
18
+ from django.contrib.sessions.backends.db import SessionStore
9
19
 
10
20
  from lucy_assist.utils.log_utils import LogUtils
11
21
  from lucy_assist.conf import lucy_assist_settings
12
22
 
13
23
 
14
24
  class CRUDService:
15
- """Service pour les opérations CRUD pilotées par Lucy Assist."""
25
+ """
26
+ Service pour les opérations CRUD pilotées par Lucy Assist.
27
+
28
+ Utilise les vues du projet hôte via RequestFactory quand elles sont
29
+ disponibles, sinon effectue les opérations directement sur les modèles.
30
+ """
16
31
 
17
32
  def __init__(self, user):
18
33
  self.user = user
34
+ self._request_factory = RequestFactory()
35
+ self._config = None
36
+
37
+ @property
38
+ def config(self):
39
+ """Lazy loading de la configuration."""
40
+ if self._config is None:
41
+ from lucy_assist.models import ConfigurationLucyAssist
42
+ self._config = ConfigurationLucyAssist.get_config()
43
+ return self._config
44
+
45
+ def _create_request(self, method: str, path: str, data: Dict = None):
46
+ """
47
+ Crée une requête Django simulée avec l'utilisateur et la session.
48
+ Inclut les attributs personnalisés que les middlewares du projet pourraient ajouter.
49
+ """
50
+ if method.upper() == 'POST':
51
+ request = self._request_factory.post(path, data=data or {})
52
+ else:
53
+ request = self._request_factory.get(path, data=data or {})
54
+
55
+ # Attacher l'utilisateur
56
+ request.user = self.user
57
+
58
+ # Configurer la session
59
+ request.session = SessionStore()
60
+ request.session.create()
61
+
62
+ # Configurer les messages Django
63
+ setattr(request, '_messages', FallbackStorage(request))
64
+
65
+ # Simuler HTMX désactivé (on veut une réponse complète)
66
+ request.htmx = False
67
+
68
+ # Ajouter les attributs personnalisés du middleware du projet
69
+ # Ces attributs sont souvent utilisés par les vues
70
+ self._add_custom_middleware_attributes(request)
71
+
72
+ return request
73
+
74
+ def _add_custom_middleware_attributes(self, request):
75
+ """
76
+ Ajoute les attributs personnalisés que les middlewares du projet
77
+ pourraient avoir ajoutés à la requête.
78
+
79
+ Cette méthode copie les attributs personnalisés de l'utilisateur
80
+ vers la requête, permettant aux vues d'y accéder normalement.
81
+ """
82
+ # Copier les attributs personnalisés de l'utilisateur vers la requête
83
+ # Les projets peuvent avoir des middlewares qui ajoutent des attributs
84
+ # comme 'tenant', 'organization', 'company', etc.
85
+ custom_attrs = lucy_assist_settings.get('REQUEST_USER_ATTRS', [])
86
+ for attr in custom_attrs:
87
+ if hasattr(self.user, attr):
88
+ setattr(request, attr, getattr(self.user, attr))
89
+
90
+ def _get_thread_local_paths(self) -> list:
91
+ """
92
+ Retourne la liste des chemins possibles pour le module ThreadLocal.
93
+ Utilise la configuration si disponible, sinon des chemins par défaut.
94
+ """
95
+ paths = []
96
+
97
+ # Utiliser le chemin configuré en priorité
98
+ configured_path = lucy_assist_settings.THREAD_LOCAL_MODULE
99
+ if configured_path:
100
+ paths.append((configured_path, 'set_current_user'))
101
+
102
+ # Chemins par défaut
103
+ paths.extend([
104
+ ('alyse.middleware.middleware', 'set_current_user'),
105
+ ('middleware.middleware', 'set_current_user'),
106
+ ('core.middleware', 'set_current_user'),
107
+ ])
108
+
109
+ return paths
110
+
111
+ def _setup_thread_local(self):
112
+ """
113
+ Configure le ThreadLocal pour les managers qui utilisent get_current_user().
114
+ Essaie de trouver et utiliser la fonction set_current_user du projet.
115
+ """
116
+ for module_path, func_name in self._get_thread_local_paths():
117
+ try:
118
+ module = __import__(module_path, fromlist=[func_name])
119
+ set_current_user = getattr(module, func_name, None)
120
+ if set_current_user:
121
+ set_current_user(self.user)
122
+ LogUtils.info(f"[CRUD] ThreadLocal configuré via {module_path}")
123
+ return
124
+ except (ImportError, AttributeError):
125
+ continue
126
+
127
+ # Fallback: essayer de définir directement dans threading.local
128
+ # si le projet utilise cette approche
129
+ LogUtils.info("[CRUD] Pas de set_current_user trouvé, ThreadLocal non configuré")
130
+
131
+ def _cleanup_thread_local(self):
132
+ """
133
+ Nettoie le ThreadLocal après l'appel de la vue.
134
+ """
135
+ for module_path, func_name in self._get_thread_local_paths():
136
+ try:
137
+ module = __import__(module_path, fromlist=[func_name])
138
+ set_current_user = getattr(module, func_name, None)
139
+ if set_current_user:
140
+ set_current_user(None)
141
+ return
142
+ except (ImportError, AttributeError):
143
+ continue
144
+
145
+ def _extract_messages(self, request) -> List[str]:
146
+ """Extrait les messages Django de la requête."""
147
+ messages = []
148
+ if hasattr(request, '_messages'):
149
+ for msg in request._messages:
150
+ messages.append(str(msg))
151
+ return messages
152
+
153
+ def _call_view(
154
+ self,
155
+ model_name: str,
156
+ action: str,
157
+ data: Dict = None,
158
+ object_id: int = None
159
+ ) -> Optional[Dict]:
160
+ """
161
+ Appelle une vue du projet via RequestFactory.
162
+
163
+ Args:
164
+ model_name: Nom du modèle
165
+ action: Action CRUD ('create', 'update', 'delete', 'list', 'detail')
166
+ data: Données à envoyer (pour POST)
167
+ object_id: ID de l'objet (pour update/delete/detail)
168
+
169
+ Returns:
170
+ Dict avec 'success', 'response', 'messages' ou None si pas de vue
171
+ """
172
+ view_info = self.config.get_crud_view_for_model(model_name, action)
173
+
174
+ # Essayer aussi avec des variations du nom de modèle
175
+ if not view_info:
176
+ from lucy_assist.services.view_discovery_service import ViewDiscoveryService
177
+ service = ViewDiscoveryService()
178
+ view_info = service.get_view_info(model_name, action)
179
+
180
+ if not view_info:
181
+ LogUtils.info(f"[CRUD] Pas de vue trouvée pour {model_name}.{action}")
182
+ return None
183
+
184
+ url_name = view_info.get('url_name')
185
+ method = view_info.get('method', 'GET')
186
+ requires_pk = view_info.get('requires_pk', False)
187
+
188
+ LogUtils.info(f"[CRUD] Appel vue {url_name} ({method}) pour {model_name}.{action}")
189
+
190
+ try:
191
+ # Construire l'URL
192
+ if requires_pk and object_id:
193
+ url = reverse(url_name, kwargs={'pk': object_id})
194
+ else:
195
+ url = reverse(url_name)
196
+
197
+ # Préparer les données POST
198
+ post_data = data.copy() if data else {}
199
+
200
+ # Pour les vues formulaire, ajouter le pk si c'est une modification
201
+ if action == 'update' and object_id:
202
+ post_data['pk'] = object_id
203
+
204
+ # Créer et exécuter la requête
205
+ request = self._create_request(method, url, post_data if method == 'POST' else None)
206
+
207
+ # Configurer le ThreadLocal pour les managers qui en dépendent
208
+ self._setup_thread_local()
209
+
210
+ # Résoudre et appeler la vue
211
+ from django.urls import resolve
212
+ match = resolve(url)
213
+ view_func = match.func
214
+
215
+ try:
216
+ # Appeler la vue
217
+ if hasattr(view_func, 'view_class'):
218
+ # Class-based view
219
+ view = view_func.view_class.as_view()
220
+ response = view(request, **match.kwargs)
221
+ else:
222
+ # Function-based view
223
+ response = view_func(request, **match.kwargs)
224
+ finally:
225
+ # Nettoyer le ThreadLocal
226
+ self._cleanup_thread_local()
227
+
228
+ # Analyser la réponse
229
+ success = response.status_code in [200, 201, 302]
230
+ messages = self._extract_messages(request)
231
+
232
+ # Détecter les erreurs dans la réponse
233
+ if hasattr(response, 'content'):
234
+ content = response.content.decode('utf-8', errors='replace')
235
+ # Chercher des indicateurs d'erreur dans le HTML
236
+ if 'class="error' in content or 'class="invalid' in content:
237
+ success = False
238
+
239
+ return {
240
+ 'success': success,
241
+ 'status_code': response.status_code,
242
+ 'messages': messages,
243
+ 'redirect_url': response.get('Location') if response.status_code == 302 else None
244
+ }
245
+
246
+ except NoReverseMatch as e:
247
+ LogUtils.warning(f"[CRUD] URL non trouvée pour {url_name}: {e}")
248
+ return None
249
+ except Exception as e:
250
+ LogUtils.error(f"[CRUD] Erreur appel vue {url_name}: {e}")
251
+ return {
252
+ 'success': False,
253
+ 'error': str(e),
254
+ 'messages': []
255
+ }
19
256
 
20
257
  def _serialize_value(self, value):
21
258
  """
@@ -201,7 +438,6 @@ class CRUDService:
201
438
 
202
439
  return optional
203
440
 
204
- @transaction.atomic
205
441
  def create_object(
206
442
  self,
207
443
  app_name: str,
@@ -209,7 +445,7 @@ class CRUDService:
209
445
  data: Dict[str, Any]
210
446
  ) -> Dict:
211
447
  """
212
- Crée un nouvel objet.
448
+ Crée un nouvel objet en utilisant la vue du projet si disponible.
213
449
 
214
450
  Args:
215
451
  app_name: Nom de l'app
@@ -217,8 +453,60 @@ class CRUDService:
217
453
  data: Données du formulaire
218
454
 
219
455
  Returns:
220
- Dict avec 'success', 'object_id', 'errors'
456
+ Dict avec 'success', 'object_id', 'errors', 'messages'
221
457
  """
458
+ LogUtils.info(f"[CRUD] create_object: {app_name}.{model_name}")
459
+
460
+ # Essayer d'abord via la vue du projet
461
+ view_result = self._call_view(model_name, 'create', data=data)
462
+
463
+ if view_result is not None:
464
+ if view_result.get('success'):
465
+ # Essayer de récupérer l'ID de l'objet créé depuis la redirection
466
+ redirect_url = view_result.get('redirect_url', '')
467
+ object_id = self._extract_id_from_url(redirect_url)
468
+
469
+ return {
470
+ 'success': True,
471
+ 'object_id': object_id,
472
+ 'message': view_result.get('messages', [f'{model_name} créé avec succès.'])[0] if view_result.get('messages') else f'{model_name} créé avec succès.',
473
+ 'messages': view_result.get('messages', []),
474
+ 'via_view': True
475
+ }
476
+ else:
477
+ return {
478
+ 'success': False,
479
+ 'errors': view_result.get('messages', []) or [view_result.get('error', 'Erreur lors de la création')],
480
+ 'via_view': True
481
+ }
482
+
483
+ # Fallback: création directe via le modèle
484
+ LogUtils.info(f"[CRUD] Fallback création directe pour {model_name}")
485
+ return self._create_object_direct(app_name, model_name, data)
486
+
487
+ @transaction.atomic
488
+ def _create_object_direct(
489
+ self,
490
+ app_name: str,
491
+ model_name: str,
492
+ data: Dict[str, Any]
493
+ ) -> Dict:
494
+ """Création directe via le modèle (fallback)."""
495
+ # Configurer le ThreadLocal pour les managers
496
+ self._setup_thread_local()
497
+
498
+ try:
499
+ return self._create_object_direct_impl(app_name, model_name, data)
500
+ finally:
501
+ self._cleanup_thread_local()
502
+
503
+ def _create_object_direct_impl(
504
+ self,
505
+ app_name: str,
506
+ model_name: str,
507
+ data: Dict[str, Any]
508
+ ) -> Dict:
509
+ """Implémentation de la création directe."""
222
510
  # Vérifier les permissions
223
511
  if not self.can_perform_action(app_name, model_name, 'add'):
224
512
  return {
@@ -254,22 +542,24 @@ class CRUDService:
254
542
  'success': True,
255
543
  'object_id': obj.pk,
256
544
  'object_str': str(obj),
257
- 'message': f'{model_name} créé avec succès.'
545
+ 'message': f'{model_name} créé avec succès.',
546
+ 'via_view': False
258
547
  }
259
548
  else:
260
549
  return {
261
550
  'success': False,
262
- 'errors': [f"{field}: {', '.join(errors)}" for field, errors in form.errors.items()]
551
+ 'errors': [f"{field}: {', '.join(errors)}" for field, errors in form.errors.items()],
552
+ 'via_view': False
263
553
  }
264
554
 
265
555
  except Exception as e:
266
- LogUtils.error(f"Erreur lors de la création de {model_name}")
556
+ LogUtils.error(f"Erreur lors de la création de {model_name}: {e}")
267
557
  return {
268
558
  'success': False,
269
- 'errors': [str(e)]
559
+ 'errors': [str(e)],
560
+ 'via_view': False
270
561
  }
271
562
 
272
- @transaction.atomic
273
563
  def update_object(
274
564
  self,
275
565
  app_name: str,
@@ -278,11 +568,61 @@ class CRUDService:
278
568
  data: Dict[str, Any]
279
569
  ) -> Dict:
280
570
  """
281
- Met à jour un objet existant.
571
+ Met à jour un objet en utilisant la vue du projet si disponible.
282
572
 
283
573
  Returns:
284
- Dict avec 'success', 'errors'
574
+ Dict avec 'success', 'errors', 'messages'
285
575
  """
576
+ LogUtils.info(f"[CRUD] update_object: {app_name}.{model_name} #{object_id}")
577
+
578
+ # Essayer d'abord via la vue du projet
579
+ view_result = self._call_view(model_name, 'update', data=data, object_id=object_id)
580
+
581
+ if view_result is not None:
582
+ if view_result.get('success'):
583
+ return {
584
+ 'success': True,
585
+ 'object_id': object_id,
586
+ 'message': view_result.get('messages', [f'{model_name} mis à jour avec succès.'])[0] if view_result.get('messages') else f'{model_name} mis à jour avec succès.',
587
+ 'messages': view_result.get('messages', []),
588
+ 'via_view': True
589
+ }
590
+ else:
591
+ return {
592
+ 'success': False,
593
+ 'errors': view_result.get('messages', []) or [view_result.get('error', 'Erreur lors de la mise à jour')],
594
+ 'via_view': True
595
+ }
596
+
597
+ # Fallback: mise à jour directe via le modèle
598
+ LogUtils.info(f"[CRUD] Fallback mise à jour directe pour {model_name}")
599
+ return self._update_object_direct(app_name, model_name, object_id, data)
600
+
601
+ @transaction.atomic
602
+ def _update_object_direct(
603
+ self,
604
+ app_name: str,
605
+ model_name: str,
606
+ object_id: int,
607
+ data: Dict[str, Any]
608
+ ) -> Dict:
609
+ """Mise à jour directe via le modèle (fallback)."""
610
+ # Configurer le ThreadLocal pour les managers
611
+ self._setup_thread_local()
612
+
613
+ try:
614
+ return self._update_object_direct_impl(app_name, model_name, object_id, data)
615
+ finally:
616
+ self._cleanup_thread_local()
617
+
618
+ def _update_object_direct_impl(
619
+ self,
620
+ app_name: str,
621
+ model_name: str,
622
+ object_id: int,
623
+ data: Dict[str, Any]
624
+ ) -> Dict:
625
+ """Implémentation de la mise à jour directe."""
286
626
  # Vérifier les permissions
287
627
  if not self.can_perform_action(app_name, model_name, 'change'):
288
628
  return {
@@ -325,22 +665,24 @@ class CRUDService:
325
665
  return {
326
666
  'success': True,
327
667
  'object_id': obj.pk,
328
- 'message': f'{model_name} mis à jour avec succès.'
668
+ 'message': f'{model_name} mis à jour avec succès.',
669
+ 'via_view': False
329
670
  }
330
671
  else:
331
672
  return {
332
673
  'success': False,
333
- 'errors': [f"{field}: {', '.join(errors)}" for field, errors in form.errors.items()]
674
+ 'errors': [f"{field}: {', '.join(errors)}" for field, errors in form.errors.items()],
675
+ 'via_view': False
334
676
  }
335
677
 
336
678
  except Exception as e:
337
- LogUtils.error(f"Erreur lors de la mise à jour de {model_name}")
679
+ LogUtils.error(f"Erreur lors de la mise à jour de {model_name}: {e}")
338
680
  return {
339
681
  'success': False,
340
- 'errors': [str(e)]
682
+ 'errors': [str(e)],
683
+ 'via_view': False
341
684
  }
342
685
 
343
- @transaction.atomic
344
686
  def delete_object(
345
687
  self,
346
688
  app_name: str,
@@ -348,11 +690,58 @@ class CRUDService:
348
690
  object_id: int
349
691
  ) -> Dict:
350
692
  """
351
- Supprime un objet.
693
+ Supprime un objet en utilisant la vue du projet si disponible.
352
694
 
353
695
  Returns:
354
- Dict avec 'success', 'errors'
696
+ Dict avec 'success', 'errors', 'messages'
355
697
  """
698
+ LogUtils.info(f"[CRUD] delete_object: {app_name}.{model_name} #{object_id}")
699
+
700
+ # Essayer d'abord via la vue du projet
701
+ view_result = self._call_view(model_name, 'delete', object_id=object_id)
702
+
703
+ if view_result is not None:
704
+ if view_result.get('success'):
705
+ return {
706
+ 'success': True,
707
+ 'message': view_result.get('messages', [f'{model_name} supprimé avec succès.'])[0] if view_result.get('messages') else f'{model_name} supprimé avec succès.',
708
+ 'messages': view_result.get('messages', []),
709
+ 'via_view': True
710
+ }
711
+ else:
712
+ return {
713
+ 'success': False,
714
+ 'errors': view_result.get('messages', []) or [view_result.get('error', 'Erreur lors de la suppression')],
715
+ 'via_view': True
716
+ }
717
+
718
+ # Fallback: suppression directe via le modèle
719
+ LogUtils.info(f"[CRUD] Fallback suppression directe pour {model_name}")
720
+ return self._delete_object_direct(app_name, model_name, object_id)
721
+
722
+ @transaction.atomic
723
+ def _delete_object_direct(
724
+ self,
725
+ app_name: str,
726
+ model_name: str,
727
+ object_id: int
728
+ ) -> Dict:
729
+ """Suppression directe via le modèle (fallback)."""
730
+ # Configurer le ThreadLocal pour les managers
731
+ self._setup_thread_local()
732
+
733
+ try:
734
+ return self._delete_object_direct_impl(app_name, model_name, object_id)
735
+ finally:
736
+ self._cleanup_thread_local()
737
+
738
+ def _delete_object_direct_impl(
739
+ self,
740
+ app_name: str,
741
+ model_name: str,
742
+ object_id: int
743
+ ) -> Dict:
744
+ """Implémentation de la suppression directe."""
356
745
  # Vérifier les permissions
357
746
  if not self.can_perform_action(app_name, model_name, 'delete'):
358
747
  return {
@@ -375,19 +764,22 @@ class CRUDService:
375
764
 
376
765
  return {
377
766
  'success': True,
378
- 'message': f'{model_name} "{obj_str}" supprimé avec succès.'
767
+ 'message': f'{model_name} "{obj_str}" supprimé avec succès.',
768
+ 'via_view': False
379
769
  }
380
770
 
381
771
  except model.DoesNotExist:
382
772
  return {
383
773
  'success': False,
384
- 'errors': [f'{model_name} #{object_id} non trouvé.']
774
+ 'errors': [f'{model_name} #{object_id} non trouvé.'],
775
+ 'via_view': False
385
776
  }
386
777
  except Exception as e:
387
- LogUtils.error(f"Erreur lors de la suppression de {model_name}")
778
+ LogUtils.error(f"Erreur lors de la suppression de {model_name}: {e}")
388
779
  return {
389
780
  'success': False,
390
- 'errors': [str(e)]
781
+ 'errors': [str(e)],
782
+ 'via_view': False
391
783
  }
392
784
 
393
785
  def get_object(
@@ -404,6 +796,21 @@ class CRUDService:
404
796
  """
405
797
  LogUtils.info(f"[CRUD] get_object: {app_name}.{model_name} #{object_id}")
406
798
 
799
+ # Configurer le ThreadLocal pour les managers
800
+ self._setup_thread_local()
801
+
802
+ try:
803
+ return self._get_object_impl(app_name, model_name, object_id)
804
+ finally:
805
+ self._cleanup_thread_local()
806
+
807
+ def _get_object_impl(
808
+ self,
809
+ app_name: str,
810
+ model_name: str,
811
+ object_id: int
812
+ ) -> Optional[Dict]:
813
+ """Implémentation de la récupération d'objet."""
407
814
  # Vérifier les permissions
408
815
  if not self.can_perform_action(app_name, model_name, 'view'):
409
816
  LogUtils.info(f"[CRUD] get_object: permission refusée pour {model_name}")
@@ -438,3 +845,269 @@ class CRUDService:
438
845
  except Exception as e:
439
846
  LogUtils.error(f"[CRUD] get_object: erreur pour {model_name} #{object_id}: {e}")
440
847
  return None
848
+
849
+ def get_deletion_impact(
850
+ self,
851
+ app_name: str,
852
+ model_name: str,
853
+ object_id: int
854
+ ) -> Dict:
855
+ """
856
+ Analyse l'impact d'une suppression et retourne toutes les conséquences.
857
+
858
+ Utilise le Collector Django pour simuler la suppression et identifier
859
+ tous les objets qui seront affectés (CASCADE, SET_NULL, PROTECT, etc.)
860
+
861
+ Returns:
862
+ Dict avec 'can_delete', 'cascade_deletions', 'set_null', 'protected', 'summary'
863
+ """
864
+ LogUtils.info(f"[CRUD] get_deletion_impact: {app_name}.{model_name} #{object_id}")
865
+
866
+ # Configurer le ThreadLocal pour les managers
867
+ self._setup_thread_local()
868
+
869
+ try:
870
+ return self._get_deletion_impact_impl(app_name, model_name, object_id)
871
+ finally:
872
+ self._cleanup_thread_local()
873
+
874
+ def _get_deletion_impact_impl(
875
+ self,
876
+ app_name: str,
877
+ model_name: str,
878
+ object_id: int
879
+ ) -> Dict:
880
+ """Implémentation de l'analyse d'impact de suppression."""
881
+ from django.db.models import ProtectedError
882
+ from django.db.models.deletion import Collector
883
+
884
+ result = {
885
+ 'can_delete': True,
886
+ 'object_str': None,
887
+ 'cascade_deletions': [], # Objets qui seront supprimés en cascade
888
+ 'set_null': [], # Objets dont la FK sera mise à NULL
889
+ 'set_default': [], # Objets dont la FK sera mise à la valeur par défaut
890
+ 'protected': [], # Objets qui bloquent la suppression
891
+ 'restricted': [], # Objets avec RESTRICT
892
+ 'summary': {}, # Résumé par modèle
893
+ 'total_deletions': 0,
894
+ 'warnings': []
895
+ }
896
+
897
+ # Vérifier les permissions
898
+ if not self.can_perform_action(app_name, model_name, 'view'):
899
+ result['can_delete'] = False
900
+ result['warnings'].append("Permission insuffisante pour voir cet objet.")
901
+ return result
902
+
903
+ # Récupérer le modèle et l'objet
904
+ model = self.get_model(app_name, model_name)
905
+ if not model:
906
+ result['can_delete'] = False
907
+ result['warnings'].append(f"Modèle {model_name} non trouvé.")
908
+ return result
909
+
910
+ try:
911
+ obj = model.objects.all().get(pk=object_id)
912
+ result['object_str'] = str(obj)
913
+ except model.DoesNotExist:
914
+ result['can_delete'] = False
915
+ result['warnings'].append(f"{model_name} #{object_id} non trouvé.")
916
+ return result
917
+
918
+ # Utiliser le Collector Django pour simuler la suppression
919
+ try:
920
+ from django.db import router
921
+ using = router.db_for_write(model, instance=obj)
922
+ collector = Collector(using=using)
923
+
924
+ try:
925
+ collector.collect([obj])
926
+ except ProtectedError as e:
927
+ result['can_delete'] = False
928
+ # Extraire les objets protégés
929
+ protected_objects = list(e.protected_objects)
930
+ for protected_obj in protected_objects[:20]: # Limiter à 20
931
+ result['protected'].append({
932
+ 'model': protected_obj.__class__.__name__,
933
+ 'id': protected_obj.pk,
934
+ 'str': str(protected_obj)[:100]
935
+ })
936
+ result['warnings'].append(
937
+ f"Suppression impossible: {len(protected_objects)} objet(s) protégé(s) référencent cet élément."
938
+ )
939
+ return result
940
+
941
+ # Analyser les objets collectés pour suppression
942
+ # collector.data contient {model: set(instances)}
943
+ for collected_model, instances in collector.data.items():
944
+ if collected_model == model and len(instances) == 1:
945
+ # C'est l'objet principal, on le skip
946
+ continue
947
+
948
+ model_name_collected = collected_model.__name__
949
+ count = len(instances)
950
+
951
+ # Ajouter au résumé
952
+ if model_name_collected not in result['summary']:
953
+ result['summary'][model_name_collected] = 0
954
+ result['summary'][model_name_collected] += count
955
+ result['total_deletions'] += count
956
+
957
+ # Ajouter les détails (limité à 10 par modèle)
958
+ for instance in list(instances)[:10]:
959
+ result['cascade_deletions'].append({
960
+ 'model': model_name_collected,
961
+ 'id': instance.pk,
962
+ 'str': str(instance)[:100]
963
+ })
964
+
965
+ # Analyser les champs qui seront mis à NULL (SET_NULL)
966
+ # et ceux avec SET_DEFAULT
967
+ self._analyze_on_delete_actions(obj, result)
968
+
969
+ except Exception as e:
970
+ LogUtils.error(f"[CRUD] Erreur analyse impact suppression: {e}")
971
+ result['warnings'].append(f"Erreur lors de l'analyse: {str(e)}")
972
+
973
+ return result
974
+
975
+ def _analyze_on_delete_actions(self, obj, result: Dict):
976
+ """
977
+ Analyse les relations pour identifier les actions SET_NULL, SET_DEFAULT, etc.
978
+ """
979
+ from django.db.models import SET_NULL, SET_DEFAULT, DO_NOTHING
980
+ from django.db.models.fields.related import ForeignKey, OneToOneField
981
+
982
+ model = obj.__class__
983
+
984
+ # Parcourir les relations inverses (objets qui pointent vers cet objet)
985
+ for related in model._meta.get_fields():
986
+ if not hasattr(related, 'remote_field'):
987
+ continue
988
+ if not hasattr(related, 'related_model'):
989
+ continue
990
+
991
+ # C'est une relation inverse
992
+ related_model = related.related_model
993
+ if related_model == model:
994
+ continue
995
+
996
+ # Trouver le champ FK dans le modèle lié
997
+ for field in related_model._meta.get_fields():
998
+ if not isinstance(field, (ForeignKey, OneToOneField)):
999
+ continue
1000
+ if field.related_model != model:
1001
+ continue
1002
+
1003
+ on_delete = field.remote_field.on_delete
1004
+ field_name = field.name
1005
+
1006
+ try:
1007
+ # Compter les objets liés
1008
+ related_objects = related_model.objects.filter(**{field_name: obj})
1009
+ count = related_objects.count()
1010
+
1011
+ if count == 0:
1012
+ continue
1013
+
1014
+ # Identifier l'action on_delete
1015
+ if on_delete == SET_NULL:
1016
+ result['set_null'].append({
1017
+ 'model': related_model.__name__,
1018
+ 'field': field_name,
1019
+ 'count': count,
1020
+ 'action': f"Le champ '{field_name}' sera mis à NULL"
1021
+ })
1022
+ elif on_delete == SET_DEFAULT:
1023
+ default_value = field.default
1024
+ result['set_default'].append({
1025
+ 'model': related_model.__name__,
1026
+ 'field': field_name,
1027
+ 'count': count,
1028
+ 'action': f"Le champ '{field_name}' sera mis à la valeur par défaut ({default_value})"
1029
+ })
1030
+ elif on_delete == DO_NOTHING:
1031
+ result['warnings'].append(
1032
+ f"{count} {related_model.__name__}(s) ont DO_NOTHING sur '{field_name}' - "
1033
+ "cela pourrait causer des erreurs d'intégrité référentielle."
1034
+ )
1035
+
1036
+ except Exception as e:
1037
+ LogUtils.info(f"[CRUD] Erreur analyse relation {related_model.__name__}: {e}")
1038
+
1039
+ def format_deletion_impact_message(self, impact: Dict) -> str:
1040
+ """
1041
+ Formate l'impact de suppression en message lisible.
1042
+ """
1043
+ lines = []
1044
+
1045
+ if not impact.get('can_delete'):
1046
+ lines.append("**SUPPRESSION IMPOSSIBLE**")
1047
+ for warning in impact.get('warnings', []):
1048
+ lines.append(f"- {warning}")
1049
+ if impact.get('protected'):
1050
+ lines.append("\nObjets protégés :")
1051
+ for item in impact['protected'][:10]:
1052
+ lines.append(f" - {item['model']} #{item['id']}: {item['str']}")
1053
+ return '\n'.join(lines)
1054
+
1055
+ # Objet principal
1056
+ lines.append(f"**Suppression de:** {impact.get('object_str', 'Objet')}")
1057
+
1058
+ # Résumé des suppressions en cascade
1059
+ if impact.get('total_deletions', 0) > 0:
1060
+ lines.append(f"\n**Suppressions en cascade:** {impact['total_deletions']} objet(s)")
1061
+ lines.append("\nDétail par type :")
1062
+ for model_name, count in sorted(impact.get('summary', {}).items()):
1063
+ lines.append(f" - {model_name}: {count}")
1064
+
1065
+ # Détails des objets (limités)
1066
+ if impact.get('cascade_deletions'):
1067
+ lines.append("\nObjets qui seront supprimés :")
1068
+ for item in impact['cascade_deletions'][:15]:
1069
+ lines.append(f" - {item['model']} #{item['id']}: {item['str']}")
1070
+ if len(impact['cascade_deletions']) > 15:
1071
+ lines.append(f" ... et {len(impact['cascade_deletions']) - 15} autres")
1072
+ else:
1073
+ lines.append("\nAucune suppression en cascade.")
1074
+
1075
+ # Objets qui seront mis à NULL
1076
+ if impact.get('set_null'):
1077
+ lines.append("\n**Champs mis à NULL :**")
1078
+ for item in impact['set_null']:
1079
+ lines.append(f" - {item['count']} {item['model']}(s): {item['action']}")
1080
+
1081
+ # Objets qui seront mis à default
1082
+ if impact.get('set_default'):
1083
+ lines.append("\n**Champs mis à valeur par défaut :**")
1084
+ for item in impact['set_default']:
1085
+ lines.append(f" - {item['count']} {item['model']}(s): {item['action']}")
1086
+
1087
+ # Avertissements
1088
+ if impact.get('warnings'):
1089
+ lines.append("\n**Avertissements :**")
1090
+ for warning in impact['warnings']:
1091
+ lines.append(f" - {warning}")
1092
+
1093
+ return '\n'.join(lines)
1094
+
1095
+ def _extract_id_from_url(self, url: str) -> Optional[int]:
1096
+ """
1097
+ Extrait un ID d'objet depuis une URL de redirection.
1098
+
1099
+ Args:
1100
+ url: URL (ex: '/client/detail/123/')
1101
+
1102
+ Returns:
1103
+ ID extrait ou None
1104
+ """
1105
+ if not url:
1106
+ return None
1107
+
1108
+ import re
1109
+ # Chercher un nombre dans l'URL
1110
+ match = re.search(r'/(\d+)/?', url)
1111
+ if match:
1112
+ return int(match.group(1))
1113
+ return None