django-lucy-assist 1.0.5__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,296 @@
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
+ }
256
+
257
+ def _serialize_value(self, value):
258
+ """
259
+ Convertit une valeur en un format sérialisable JSON.
260
+ """
261
+ from datetime import datetime, date, time
262
+ from decimal import Decimal
263
+ import uuid
264
+
265
+ if value is None:
266
+ return None
267
+ elif isinstance(value, (datetime,)):
268
+ return value.isoformat()
269
+ elif isinstance(value, (date,)):
270
+ return value.isoformat()
271
+ elif isinstance(value, (time,)):
272
+ return value.isoformat()
273
+ elif isinstance(value, Decimal):
274
+ return float(value)
275
+ elif isinstance(value, uuid.UUID):
276
+ return str(value)
277
+ elif hasattr(value, 'pk'):
278
+ # ForeignKey ou relation
279
+ return {'id': value.pk, 'str': str(value)}
280
+ elif hasattr(value, 'all'):
281
+ # ManyToMany ou reverse FK - retourner juste le count
282
+ return {'count': value.count()}
283
+ elif isinstance(value, bytes):
284
+ return value.decode('utf-8', errors='replace')
285
+ else:
286
+ # Essayer de retourner directement, sinon convertir en string
287
+ try:
288
+ # Types simples (str, int, float, bool, list, dict)
289
+ import json
290
+ json.dumps(value)
291
+ return value
292
+ except (TypeError, ValueError):
293
+ return str(value)
19
294
 
20
295
  def can_perform_action(self, app_name: str, model_name: str, action: str) -> bool:
21
296
  """
@@ -163,7 +438,6 @@ class CRUDService:
163
438
 
164
439
  return optional
165
440
 
166
- @transaction.atomic
167
441
  def create_object(
168
442
  self,
169
443
  app_name: str,
@@ -171,7 +445,7 @@ class CRUDService:
171
445
  data: Dict[str, Any]
172
446
  ) -> Dict:
173
447
  """
174
- Crée un nouvel objet.
448
+ Crée un nouvel objet en utilisant la vue du projet si disponible.
175
449
 
176
450
  Args:
177
451
  app_name: Nom de l'app
@@ -179,8 +453,60 @@ class CRUDService:
179
453
  data: Données du formulaire
180
454
 
181
455
  Returns:
182
- Dict avec 'success', 'object_id', 'errors'
456
+ Dict avec 'success', 'object_id', 'errors', 'messages'
183
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."""
184
510
  # Vérifier les permissions
185
511
  if not self.can_perform_action(app_name, model_name, 'add'):
186
512
  return {
@@ -216,22 +542,24 @@ class CRUDService:
216
542
  'success': True,
217
543
  'object_id': obj.pk,
218
544
  'object_str': str(obj),
219
- 'message': f'{model_name} créé avec succès.'
545
+ 'message': f'{model_name} créé avec succès.',
546
+ 'via_view': False
220
547
  }
221
548
  else:
222
549
  return {
223
550
  'success': False,
224
- '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
225
553
  }
226
554
 
227
555
  except Exception as e:
228
- 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}")
229
557
  return {
230
558
  'success': False,
231
- 'errors': [str(e)]
559
+ 'errors': [str(e)],
560
+ 'via_view': False
232
561
  }
233
562
 
234
- @transaction.atomic
235
563
  def update_object(
236
564
  self,
237
565
  app_name: str,
@@ -240,11 +568,61 @@ class CRUDService:
240
568
  data: Dict[str, Any]
241
569
  ) -> Dict:
242
570
  """
243
- Met à jour un objet existant.
571
+ Met à jour un objet en utilisant la vue du projet si disponible.
244
572
 
245
573
  Returns:
246
- Dict avec 'success', 'errors'
574
+ Dict avec 'success', 'errors', 'messages'
247
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."""
248
626
  # Vérifier les permissions
249
627
  if not self.can_perform_action(app_name, model_name, 'change'):
250
628
  return {
@@ -287,22 +665,24 @@ class CRUDService:
287
665
  return {
288
666
  'success': True,
289
667
  'object_id': obj.pk,
290
- 'message': f'{model_name} mis à jour avec succès.'
668
+ 'message': f'{model_name} mis à jour avec succès.',
669
+ 'via_view': False
291
670
  }
292
671
  else:
293
672
  return {
294
673
  'success': False,
295
- '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
296
676
  }
297
677
 
298
678
  except Exception as e:
299
- 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}")
300
680
  return {
301
681
  'success': False,
302
- 'errors': [str(e)]
682
+ 'errors': [str(e)],
683
+ 'via_view': False
303
684
  }
304
685
 
305
- @transaction.atomic
306
686
  def delete_object(
307
687
  self,
308
688
  app_name: str,
@@ -310,11 +690,58 @@ class CRUDService:
310
690
  object_id: int
311
691
  ) -> Dict:
312
692
  """
313
- Supprime un objet.
693
+ Supprime un objet en utilisant la vue du projet si disponible.
314
694
 
315
695
  Returns:
316
- Dict avec 'success', 'errors'
696
+ Dict avec 'success', 'errors', 'messages'
317
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."""
318
745
  # Vérifier les permissions
319
746
  if not self.can_perform_action(app_name, model_name, 'delete'):
320
747
  return {
@@ -337,19 +764,22 @@ class CRUDService:
337
764
 
338
765
  return {
339
766
  'success': True,
340
- '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
341
769
  }
342
770
 
343
771
  except model.DoesNotExist:
344
772
  return {
345
773
  'success': False,
346
- 'errors': [f'{model_name} #{object_id} non trouvé.']
774
+ 'errors': [f'{model_name} #{object_id} non trouvé.'],
775
+ 'via_view': False
347
776
  }
348
777
  except Exception as e:
349
- LogUtils.error(f"Erreur lors de la suppression de {model_name}")
778
+ LogUtils.error(f"Erreur lors de la suppression de {model_name}: {e}")
350
779
  return {
351
780
  'success': False,
352
- 'errors': [str(e)]
781
+ 'errors': [str(e)],
782
+ 'via_view': False
353
783
  }
354
784
 
355
785
  def get_object(
@@ -364,16 +794,36 @@ class CRUDService:
364
794
  Returns:
365
795
  Dict avec les données de l'objet ou None
366
796
  """
797
+ LogUtils.info(f"[CRUD] get_object: {app_name}.{model_name} #{object_id}")
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."""
367
814
  # Vérifier les permissions
368
815
  if not self.can_perform_action(app_name, model_name, 'view'):
816
+ LogUtils.info(f"[CRUD] get_object: permission refusée pour {model_name}")
369
817
  return None
370
818
 
371
819
  model = self.get_model(app_name, model_name)
372
820
  if not model:
821
+ LogUtils.warning(f"[CRUD] get_object: modèle {model_name} non trouvé")
373
822
  return None
374
823
 
375
824
  try:
376
- obj = model.objects.get(pk=object_id)
825
+ # Utiliser objects.all() pour éviter les problèmes avec les managers customs
826
+ obj = model.objects.all().get(pk=object_id)
377
827
 
378
828
  # Construire un dict avec les données
379
829
  data = {'id': obj.pk, 'str': str(obj)}
@@ -382,15 +832,282 @@ class CRUDService:
382
832
  if hasattr(field, 'verbose_name'):
383
833
  try:
384
834
  value = getattr(obj, field.name)
385
- # Gérer les FK
386
- if hasattr(value, 'pk'):
387
- data[field.name] = {'id': value.pk, 'str': str(value)}
388
- else:
389
- data[field.name] = value
835
+ data[field.name] = self._serialize_value(value)
390
836
  except Exception:
391
837
  pass
392
838
 
839
+ LogUtils.info(f"[CRUD] get_object: {model_name} #{object_id} récupéré avec succès")
393
840
  return data
394
841
 
395
842
  except model.DoesNotExist:
843
+ LogUtils.info(f"[CRUD] get_object: {model_name} #{object_id} non trouvé")
844
+ return None
845
+ except Exception as e:
846
+ LogUtils.error(f"[CRUD] get_object: erreur pour {model_name} #{object_id}: {e}")
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:
396
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