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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-lucy-assist
3
- Version: 1.0.5
3
+ Version: 1.0.7
4
4
  Summary: Assistant IA intelligent Revolucy pour outil métier
5
5
  Author-email: Revolucy <hello@revolucy.fr>
6
6
  Maintainer-email: Maxence <hello@revolucy.fr>
@@ -176,7 +176,7 @@ Lucy Assist expose plusieurs endpoints API :
176
176
  - `POST /lucy-assist/api/conversations/` - Créer une conversation
177
177
  - `GET /lucy-assist/api/conversations/<id>/` - Détail d'une conversation
178
178
  - `POST /lucy-assist/api/conversations/<id>/messages/` - Ajouter un message
179
- - `POST /lucy-assist/api/conversations/<id>/completion/` - Générer une réponse (streaming)
179
+ - `POST /lucy-assist/api/convers2ations/<id>/completion/` - Générer une réponse (streaming)
180
180
  - `GET /lucy-assist/api/tokens/status/` - Statut des tokens
181
181
 
182
182
  ## Licence
@@ -1,29 +1,31 @@
1
- lucy_assist/__init__.py,sha256=kJG9Bgp37TXe9Ph2DsiuyDMalCvaJkAXRDPGYqASiyk,335
2
- lucy_assist/admin.py,sha256=-hNfuwuMfxgZVFQc_ODy6WcyZPxrM_8TfKsRMd0fj38,694
1
+ lucy_assist/__init__.py,sha256=RVSIAsuh02Z3-F9fcCAeJdoXu3J4bvWnJ2BllIevECY,335
2
+ lucy_assist/admin.py,sha256=DHdcvkCYNkVBZoWrZ58AQxOEN2EmsN4E09qRnqYyARI,2919
3
3
  lucy_assist/apps.py,sha256=zHZtlBXs5ML4CKtGg7xDyptSWzLfB1ks2VvbXF50hdo,264
4
- lucy_assist/conf.py,sha256=sWcAdJTSE3Hn_guTifZaRCLKIuJMvR9RwJk2GEKCFOI,3520
5
- lucy_assist/constantes.py,sha256=YppDWi1DQueMwJk3jmeGPDi-UTmSUUDSwR6sW7QzBn4,4083
4
+ lucy_assist/conf.py,sha256=WeglOdS6DlnbORViLN9uVqUbUqgAxQQhyHxLADkv93U,3855
5
+ lucy_assist/constantes.py,sha256=vKIEuNnK23CqX046l5rFLKeSjHl-kwor3lq8Boj8_IU,5005
6
6
  lucy_assist/context_processors.py,sha256=mDrr9G5XztDfJLGq_75X1rkJbVI5De08ys_pW3y12Dw,2210
7
7
  lucy_assist/signals.py,sha256=aQA84oe9JNL72eeV5kURTTV-9CcQpqakDle1Lv3dnFY,861
8
8
  lucy_assist/urls.py,sha256=Qr8jJjEyC_EFGAeiZnjhgTc-9P4Y-TqKDaYicWRp_GQ,1451
9
9
  lucy_assist/migrations/0001_initial.py,sha256=Z4chOo4Ok_5VqQyHvfPS00ikyuefdwHF3LaH4SKpzoQ,4977
10
10
  lucy_assist/migrations/0002_configurationlucyassist_prompt_complementaire.py,sha256=yWE4-RPHQtPAqYHlAhr4EsXgk35kI8zRQArlAP_JwDc,418
11
+ lucy_assist/migrations/0003_configurationlucyassist_crud_views_mapping.py,sha256=1vnu1VwVBouskhQBgLz-Lp71p4a9Cb9PjrT-RLVGY7s,429
11
12
  lucy_assist/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
13
  lucy_assist/models/__init__.py,sha256=JQMr50P94sBrKwwcNArRE_qnk7dLnknMKCabeRLskJc,415
13
14
  lucy_assist/models/base.py,sha256=Ql2AY7bzvcxZhxsPdCpbVEKvtWD-NBkHTLYZX4TjT3s,1532
14
- lucy_assist/models/configuration.py,sha256=pvHtI2kWnKgJjvz5SyC3ObpnCW5kHVt0fRq5E_dd4nc,6420
15
+ lucy_assist/models/configuration.py,sha256=9MjEI2gG2lx1H2UyHo9zCnDOoUC_1hVOKOYwzWuYIHs,8596
15
16
  lucy_assist/models/conversation.py,sha256=psx2AQtQ5SFP-AJD7wbGabpXXLIGYngdVZfeoTlgKtQ,1849
16
17
  lucy_assist/models/message.py,sha256=kf-ffMtLYNFhXYUrB3QSL97KKDJUOMrKaPjKeOcJa_o,1492
17
18
  lucy_assist/models/project_context_cache.py,sha256=Bnb0VU7pv7QEvjOI6JSLEPvL4BxskCQ0ojWGxO7YDSM,6530
18
19
  lucy_assist/services/__init__.py,sha256=I0brW674WNIKkGHj2lj4sGEDD7HUAr5Z254dsbirdLk,691
19
20
  lucy_assist/services/bug_notification_service.py,sha256=OyowCvAs-QDlsGQ_WTFoc4lRe9detD7r6ZyYK0JD2Sc,7217
20
21
  lucy_assist/services/claude_service.py,sha256=vYeotZKwFghbWNmN_VM0uggnFQgtNNK0SP3e9QPQzgc,16218
21
- lucy_assist/services/context_service.py,sha256=aNPvo8b9pUjqnGpd5p6zUt4QJAYdJwjOw2V7URe1ANE,13230
22
- lucy_assist/services/crud_service.py,sha256=wpFFFpApQfycjY5I2AWYk_sZEXUsMI0HiQ69LKeRz4U,13051
22
+ lucy_assist/services/context_service.py,sha256=Vx2tR6W1jmqr06pGn924uzWTF2SV7RAO_JIScxYbCy0,17087
23
+ lucy_assist/services/crud_service.py,sha256=W50K02ZkaScyzO-yRaBd9Cjva9PGH7OjNTipSruErcA,41495
23
24
  lucy_assist/services/gitlab_service.py,sha256=uH83fwRSCwiRItznENpYQG4aPckjafYIV9z6OChUrZg,8056
24
25
  lucy_assist/services/project_context_service.py,sha256=bIuqTanc59gP_BLod3oQgWplxpiCgByg-kbUMe_57CQ,14053
25
- lucy_assist/services/tool_executor_service.py,sha256=fXLH4Aaip-HPX1nNRs8UQ1N77rGloBahSwOR9gPmjfU,13583
26
- lucy_assist/services/tools_definition.py,sha256=_3wfZOtzPtN44H8ITjEPzGn1LVxAu0ipcWLXsvzLyHE,8923
26
+ lucy_assist/services/tool_executor_service.py,sha256=cHwEA2FvxE8cIC7N-YF8Wn7NV1hureBuxYKxTf_bG7U,15151
27
+ lucy_assist/services/tools_definition.py,sha256=y4ycmKbrub2fw_6DMwSqq36r8_g93tmvJU00Gp_0XR0,10341
28
+ lucy_assist/services/view_discovery_service.py,sha256=J9LkHXUOzlGS3cyft2_jA1X27TWOd3xViN4M7GOROVw,11872
27
29
  lucy_assist/static/lucy_assist/css/lucy-assist.css,sha256=gUfj4OUTz_aFiXWau1iXtHEmfUCkUI2zGMfwkLk2nXs,18190
28
30
  lucy_assist/static/lucy_assist/image/icon-lucy.png,sha256=FOYlwXAt40Gr9jsWFmhgPivYOBFWKeYW0lxJI5Up-GM,6710
29
31
  lucy_assist/static/lucy_assist/js/lucy-assist.js,sha256=dmXtSPQ38LisoRWZd3R1Ms6OvVaEk3OrYKcVM0OMb_Q,28207
@@ -39,7 +41,7 @@ lucy_assist/utils/message_utils.py,sha256=YzcLHnl1ig4d5_utHCJwgxS7tKmd49Q-tuo78e
39
41
  lucy_assist/utils/token_utils.py,sha256=rxe9jHjcRJcaIlcw0QuVmYXOjscTsUsxnhhI6RMBzDM,2608
40
42
  lucy_assist/views/__init__.py,sha256=uUPYpuHlBC8j7zKS_DDoWjwpCpRnOIXETY-S2-Ss0cY,288
41
43
  lucy_assist/views/api_views.py,sha256=iCvdTTTJ73r3jfyZVjcEDi3Of2wP_N24G_QsXwc-Euk,23617
42
- django_lucy_assist-1.0.5.dist-info/METADATA,sha256=anJPOWh-GwZl2vhMz5er7zGGSSR9SAzw76KFbANystc,5543
43
- django_lucy_assist-1.0.5.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
44
- django_lucy_assist-1.0.5.dist-info/top_level.txt,sha256=T-UCiwpn5yF3Oem3234TUpSVnEgbkrM2rGz9Tz5N-QA,12
45
- django_lucy_assist-1.0.5.dist-info/RECORD,,
44
+ django_lucy_assist-1.0.7.dist-info/METADATA,sha256=OLG_nvmoGozZUIdaaAslZSdFgbh0g-3hHKxa-mw1Yvo,5544
45
+ django_lucy_assist-1.0.7.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
46
+ django_lucy_assist-1.0.7.dist-info/top_level.txt,sha256=T-UCiwpn5yF3Oem3234TUpSVnEgbkrM2rGz9Tz5N-QA,12
47
+ django_lucy_assist-1.0.7.dist-info/RECORD,,
lucy_assist/__init__.py CHANGED
@@ -5,7 +5,7 @@ Un chatbot IA basé sur Claude d'Anthropic, intégrable dans n'importe quelle
5
5
  application Django pour fournir une assistance contextuelle aux utilisateurs.
6
6
  """
7
7
 
8
- __version__ = '1.0.5'
8
+ __version__ = '1.0.7'
9
9
  __author__ = 'Revolucy'
10
10
 
11
11
  default_app_config = 'lucy_assist.apps.LucyAssistConfig'
lucy_assist/admin.py CHANGED
@@ -19,4 +19,53 @@ class ConversationAdmin(admin.ModelAdmin):
19
19
 
20
20
  @admin.register(ConfigurationLucyAssist)
21
21
  class ConfigurationLucyAssistAdmin(admin.ModelAdmin):
22
- list_display = ('id', 'tokens_disponibles', 'prix_par_million_tokens', 'updated_date')
22
+ list_display = ('id', 'tokens_disponibles', 'prix_par_million_tokens', 'nb_vues_crud', 'updated_date')
23
+ readonly_fields = ('crud_views_mapping_display', 'model_app_mapping_display')
24
+ fieldsets = (
25
+ ('Configuration Tokens', {
26
+ 'fields': ('tokens_disponibles', 'prix_par_million_tokens', 'actif')
27
+ }),
28
+ ('Personnalisation', {
29
+ 'fields': ('avatar', 'questions_frequentes', 'prompt_complementaire')
30
+ }),
31
+ ('Mapping Automatique (lecture seule)', {
32
+ 'fields': ('crud_views_mapping_display', 'model_app_mapping_display'),
33
+ 'classes': ('collapse',)
34
+ }),
35
+ )
36
+ actions = ['refresh_crud_views']
37
+
38
+ def nb_vues_crud(self, obj):
39
+ """Affiche le nombre de modèles avec des vues CRUD découvertes."""
40
+ if obj.crud_views_mapping:
41
+ return len(obj.crud_views_mapping)
42
+ return 0
43
+ nb_vues_crud.short_description = "Modèles CRUD"
44
+
45
+ def crud_views_mapping_display(self, obj):
46
+ """Affiche le mapping des vues CRUD de manière lisible."""
47
+ import json
48
+ if obj.crud_views_mapping:
49
+ return json.dumps(obj.crud_views_mapping, indent=2, ensure_ascii=False)
50
+ return "Aucune vue découverte. Utilisez l'action 'Rafraîchir les vues CRUD'."
51
+ crud_views_mapping_display.short_description = "Vues CRUD découvertes"
52
+
53
+ def model_app_mapping_display(self, obj):
54
+ """Affiche le mapping modèle -> app de manière lisible."""
55
+ import json
56
+ if obj.model_app_mapping:
57
+ return json.dumps(obj.model_app_mapping, indent=2, ensure_ascii=False)
58
+ return "Aucun mapping. Sera construit automatiquement."
59
+ model_app_mapping_display.short_description = "Mapping Modèle -> App"
60
+
61
+ @admin.action(description="Rafraîchir les vues CRUD découvertes")
62
+ def refresh_crud_views(self, request, queryset):
63
+ """Action admin pour rafraîchir le mapping des vues CRUD."""
64
+ for config in queryset:
65
+ mapping = config.refresh_crud_views_mapping()
66
+ nb_models = len(mapping)
67
+ nb_views = sum(len(actions) for actions in mapping.values())
68
+ self.message_user(
69
+ request,
70
+ f"Mapping rafraîchi: {nb_models} modèles, {nb_views} vues découvertes."
71
+ )
lucy_assist/conf.py CHANGED
@@ -55,6 +55,14 @@ class LucyAssistSettings:
55
55
  "Comment modifier mon profil ?",
56
56
  "Où trouver la liste des réservations ?",
57
57
  ],
58
+
59
+ # Chemin vers le module contenant set_current_user pour le ThreadLocal
60
+ # Ex: 'alyse.middleware.middleware' pour le projet Alyse
61
+ 'THREAD_LOCAL_MODULE': None,
62
+
63
+ # Attributs de l'utilisateur à copier vers la requête
64
+ # Ex: ['franchise', 'tenant', 'organization']
65
+ 'REQUEST_USER_ATTRS': [],
58
66
  }
59
67
 
60
68
  def __init__(self):
lucy_assist/constantes.py CHANGED
@@ -79,7 +79,27 @@ Utilisateur : "Modifie l'adresse du membre 42"
79
79
 
80
80
  - Si l'utilisateur demande explicitement "comment faire" ou "explique-moi"
81
81
  - Si tu n'as pas assez d'informations (dans ce cas, demande les infos manquantes)
82
- - Pour les suppressions : demande une confirmation avant d'exécuter
82
+
83
+ ## IMPORTANT - Procédure de suppression
84
+
85
+ Pour TOUTE demande de suppression, tu DOIS suivre cette procédure :
86
+
87
+ 1. D'abord, utilise le tool `get_deletion_impact` pour analyser les conséquences
88
+ 2. Affiche à l'utilisateur le résultat complet :
89
+ - L'objet qui sera supprimé
90
+ - TOUS les objets supprimés en cascade (avec leur type et nombre)
91
+ - Les champs qui seront mis à NULL
92
+ - Les éventuels blocages (objets protégés)
93
+ 3. Demande une confirmation EXPLICITE à l'utilisateur ("Confirmez-vous la suppression ?")
94
+ 4. SEULEMENT si l'utilisateur confirme explicitement (oui, ok, confirme, etc.), utilise `delete_object` avec `confirmed: true`
95
+
96
+ Exemple de réponse après get_deletion_impact :
97
+ "Voici les conséquences de la suppression du Client #42 :
98
+ - 5 Réservations seront supprimées en cascade
99
+ - 3 Paiements seront supprimés en cascade
100
+ - 2 Documents auront leur champ 'client' mis à NULL
101
+
102
+ **Confirmez-vous vouloir supprimer ce client et tous les éléments associés ?**"
83
103
 
84
104
  ## Contexte de la page actuelle
85
105
  {page_context}
@@ -0,0 +1,18 @@
1
+ # Generated by Django
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('lucy_assist', '0002_configurationlucyassist_prompt_complementaire'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AddField(
14
+ model_name='configurationlucyassist',
15
+ name='crud_views_mapping',
16
+ field=models.JSONField(blank=True, default=dict),
17
+ ),
18
+ ]
@@ -26,6 +26,17 @@ class ConfigurationLucyAssist(LucyAssistBaseModel):
26
26
  # Mapping des modèles vers leurs applications Django
27
27
  model_app_mapping = models.JSONField(blank=True, default=dict)
28
28
 
29
+ # Mapping des vues CRUD découvertes automatiquement
30
+ # Structure: {
31
+ # "model_name": {
32
+ # "list": {"url_name": "app:model-list", "url": "/app/model/"},
33
+ # "create": {"url_name": "app:model-formulaire", "url": "/app/model/formulaire/"},
34
+ # "detail": {"url_name": "app:model-detail", "url": "/app/model/detail/<pk>/"},
35
+ # "delete": {"url_name": "app:model-suppression", "url": "/app/model/suppression/<pk>/"}
36
+ # }
37
+ # }
38
+ crud_views_mapping = models.JSONField(blank=True, default=dict)
39
+
29
40
  class Meta:
30
41
  verbose_name = "Configuration Lucy Assist"
31
42
  verbose_name_plural = "Configuration Lucy Assist"
@@ -51,10 +62,53 @@ class ConfigurationLucyAssist(LucyAssistBaseModel):
51
62
  def save(self, *args, **kwargs):
52
63
  # Forcer l'ID à 1 pour le singleton
53
64
  self.pk = 1
65
+
66
+ # Auto-découverte des vues CRUD si le mapping est vide
67
+ if not self.crud_views_mapping:
68
+ self.crud_views_mapping = self._discover_crud_views()
69
+
54
70
  super().save(*args, **kwargs)
55
71
  # Invalider le cache
56
72
  cache.delete('lucy_assist_config')
57
73
 
74
+ def refresh_crud_views_mapping(self):
75
+ """
76
+ Force la redécouverte des vues CRUD.
77
+ Utile après l'ajout de nouvelles vues dans le projet.
78
+ """
79
+ self.crud_views_mapping = self._discover_crud_views()
80
+ self.save(update_fields=['crud_views_mapping'])
81
+ cache.delete('lucy_assist_config')
82
+ return self.crud_views_mapping
83
+
84
+ def _discover_crud_views(self) -> dict:
85
+ """
86
+ Découvre automatiquement les vues CRUD du projet
87
+ en parcourant les URL patterns.
88
+ """
89
+ from lucy_assist.services.view_discovery_service import ViewDiscoveryService
90
+ service = ViewDiscoveryService()
91
+ return service.discover_crud_views()
92
+
93
+ def get_crud_view_for_model(self, model_name: str, action: str) -> dict:
94
+ """
95
+ Retourne les infos de la vue CRUD pour un modèle et une action donnés.
96
+
97
+ Args:
98
+ model_name: Nom du modèle (ex: 'Client', 'Reservation')
99
+ action: Type d'action ('list', 'create', 'detail', 'update', 'delete')
100
+
101
+ Returns:
102
+ Dict avec 'url_name', 'url', 'method' ou None si non trouvé
103
+ """
104
+ if not self.crud_views_mapping:
105
+ self.crud_views_mapping = self._discover_crud_views()
106
+ self.save(update_fields=['crud_views_mapping'])
107
+
108
+ model_lower = model_name.lower()
109
+ model_views = self.crud_views_mapping.get(model_lower, {})
110
+ return model_views.get(action)
111
+
58
112
  @property
59
113
  def tokens_restants_en_euros(self):
60
114
  """Retourne la valeur en euros des tokens restants."""
@@ -1,8 +1,8 @@
1
1
  """
2
2
  Service de détection et construction du contexte de page.
3
3
  """
4
- import logging
5
4
  import re
5
+ from datetime import datetime
6
6
  from typing import Dict, List, Optional
7
7
  from urllib.parse import urlparse
8
8
 
@@ -16,9 +16,38 @@ from lucy_assist.conf import lucy_assist_settings
16
16
  class ContextService:
17
17
  """Service pour construire le contexte de la page courante."""
18
18
 
19
+ # Patterns pour détecter les dates dans les requêtes
20
+ DATE_PATTERNS = [
21
+ (r'(\d{1,2})/(\d{1,2})/(\d{4})', '%d/%m/%Y'), # 18/10/2025
22
+ (r'(\d{1,2})-(\d{1,2})-(\d{4})', '%d-%m-%Y'), # 18-10-2025
23
+ (r'(\d{4})/(\d{1,2})/(\d{1,2})', '%Y/%m/%d'), # 2025/10/18
24
+ (r'(\d{4})-(\d{1,2})-(\d{1,2})', '%Y-%m-%d'), # 2025-10-18
25
+ ]
26
+
19
27
  def __init__(self, user):
20
28
  self.user = user
21
29
 
30
+ def _extract_date_from_query(self, query: str) -> Optional[datetime]:
31
+ """
32
+ Extrait une date d'une requête texte.
33
+ Supporte les formats: 18/10/2025, 18-10-2025, 2025/10/18, 2025-10-18
34
+ """
35
+ for pattern, date_format in self.DATE_PATTERNS:
36
+ match = re.search(pattern, query)
37
+ if match:
38
+ try:
39
+ date_str = match.group(0)
40
+ return datetime.strptime(date_str, date_format)
41
+ except ValueError:
42
+ continue
43
+ return None
44
+
45
+ def _remove_date_from_query(self, query: str) -> str:
46
+ """Supprime la date de la requête pour garder les autres mots-clés."""
47
+ for pattern, _ in self.DATE_PATTERNS:
48
+ query = re.sub(pattern, '', query)
49
+ return query.strip()
50
+
22
51
  def get_page_context(self, url_path: str) -> Dict:
23
52
  """
24
53
  Construit le contexte complet d'une page.
@@ -293,25 +322,80 @@ class ContextService:
293
322
  # Log des modèles découverts pour debug
294
323
  LogUtils.info(f"[search_objects] Recherche '{query}' dans {len(models_to_search)} modèles: {[m.__name__ for m in models_to_search]}")
295
324
 
325
+ # Extraire une date de la requête si présente
326
+ search_date = self._extract_date_from_query(query)
327
+ text_query = self._remove_date_from_query(query) if search_date else query
328
+
329
+ if search_date:
330
+ LogUtils.info(f"[search_objects] Date détectée: {search_date.date()}")
331
+
296
332
  # Rechercher dans chaque modèle
297
333
  for model in models_to_search:
298
334
  try:
299
- # Trouver les champs texte pour la recherche
335
+ # Trouver les champs texte et date pour la recherche
300
336
  search_fields = []
337
+ date_fields = [] # DateField (comparaison directe)
338
+ datetime_fields = [] # DateTimeField (utilise __date)
301
339
  for field in model._meta.get_fields():
302
340
  if hasattr(field, 'get_internal_type'):
303
- if field.get_internal_type() in ['CharField', 'TextField']:
341
+ field_type = field.get_internal_type()
342
+ if field_type in ['CharField', 'TextField']:
304
343
  search_fields.append(field.name)
305
-
306
- if not search_fields:
307
- LogUtils.info(f"[search_objects] {model.__name__}: aucun champ texte trouvé")
308
- continue
344
+ elif field_type == 'DateField':
345
+ date_fields.append(field.name)
346
+ elif field_type == 'DateTimeField':
347
+ datetime_fields.append(field.name)
309
348
 
310
349
  # Construire la requête
311
350
  from django.db.models import Q
312
- q_objects = Q()
313
- for field_name in search_fields[:5]: # Limiter à 5 champs
314
- q_objects |= Q(**{f'{field_name}__icontains': query})
351
+ q_objects = None
352
+ has_criteria = False
353
+
354
+ # Si on a une date, chercher dans les champs date
355
+ if search_date and (date_fields or datetime_fields):
356
+ date_q = Q()
357
+ search_date_only = search_date.date()
358
+
359
+ # DateField: comparaison directe
360
+ for field_name in date_fields:
361
+ date_q |= Q(**{field_name: search_date_only})
362
+
363
+ # DateTimeField: utiliser __date
364
+ for field_name in datetime_fields:
365
+ date_q |= Q(**{f'{field_name}__date': search_date_only})
366
+
367
+ q_objects = date_q
368
+ has_criteria = True
369
+ LogUtils.info(f"[search_objects] {model.__name__}: recherche date dans DateField={date_fields}, DateTimeField={datetime_fields}")
370
+
371
+ # Si on a du texte, chercher dans les champs texte
372
+ if text_query.strip() and search_fields:
373
+ query_words = text_query.strip().split()
374
+ text_q = Q()
375
+
376
+ if len(query_words) > 1:
377
+ # Recherche multi-mots: chaque mot doit matcher au moins un champ
378
+ for word in query_words:
379
+ word_q = Q()
380
+ for field_name in search_fields[:5]:
381
+ word_q |= Q(**{f'{field_name}__icontains': word})
382
+ text_q &= word_q
383
+ else:
384
+ # Recherche simple
385
+ for field_name in search_fields[:5]:
386
+ text_q |= Q(**{f'{field_name}__icontains': text_query})
387
+
388
+ if q_objects is not None:
389
+ q_objects &= text_q
390
+ else:
391
+ q_objects = text_q
392
+ has_criteria = True
393
+
394
+ # Si pas de critère de recherche valide, skip
395
+ if not has_criteria:
396
+ if not search_fields and not date_fields and not datetime_fields:
397
+ LogUtils.info(f"[search_objects] {model.__name__}: aucun champ recherchable")
398
+ continue
315
399
 
316
400
  # Filtrer par permissions si possible
317
401
  # Note: certains modèles utilisent des managers customs qui