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.
- {django_lucy_assist-1.0.6.dist-info → django_lucy_assist-1.0.7.dist-info}/METADATA +2 -2
- {django_lucy_assist-1.0.6.dist-info → django_lucy_assist-1.0.7.dist-info}/RECORD +15 -13
- lucy_assist/__init__.py +1 -1
- lucy_assist/admin.py +50 -1
- lucy_assist/conf.py +8 -0
- lucy_assist/constantes.py +21 -1
- lucy_assist/migrations/0003_configurationlucyassist_crud_views_mapping.py +18 -0
- lucy_assist/models/configuration.py +54 -0
- lucy_assist/services/context_service.py +17 -6
- lucy_assist/services/crud_service.py +695 -22
- lucy_assist/services/tool_executor_service.py +41 -1
- lucy_assist/services/tools_definition.py +36 -6
- lucy_assist/services/view_discovery_service.py +339 -0
- {django_lucy_assist-1.0.6.dist-info → django_lucy_assist-1.0.7.dist-info}/WHEEL +0 -0
- {django_lucy_assist-1.0.6.dist-info → django_lucy_assist-1.0.7.dist-info}/top_level.txt +0 -0
|
@@ -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
|
-
"""
|
|
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
|
|
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
|