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.
- {django_lucy_assist-1.0.5.dist-info → django_lucy_assist-1.0.7.dist-info}/METADATA +2 -2
- {django_lucy_assist-1.0.5.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 +94 -10
- lucy_assist/services/crud_service.py +745 -28
- 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.5.dist-info → django_lucy_assist-1.0.7.dist-info}/WHEEL +0 -0
- {django_lucy_assist-1.0.5.dist-info → django_lucy_assist-1.0.7.dist-info}/top_level.txt +0 -0
|
@@ -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
|
-
"""
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|