django-lucy-assist 0.1.0__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.
Files changed (44) hide show
  1. django_lucy_assist-0.1.0.dist-info/METADATA +206 -0
  2. django_lucy_assist-0.1.0.dist-info/RECORD +44 -0
  3. django_lucy_assist-0.1.0.dist-info/WHEEL +5 -0
  4. django_lucy_assist-0.1.0.dist-info/top_level.txt +1 -0
  5. lucy_assist/__init__.py +11 -0
  6. lucy_assist/admin.py +22 -0
  7. lucy_assist/apps.py +10 -0
  8. lucy_assist/conf.py +103 -0
  9. lucy_assist/constantes.py +120 -0
  10. lucy_assist/context_processors.py +65 -0
  11. lucy_assist/migrations/0001_initial.py +92 -0
  12. lucy_assist/migrations/__init__.py +0 -0
  13. lucy_assist/models/__init__.py +14 -0
  14. lucy_assist/models/base.py +54 -0
  15. lucy_assist/models/configuration.py +175 -0
  16. lucy_assist/models/conversation.py +54 -0
  17. lucy_assist/models/message.py +45 -0
  18. lucy_assist/models/project_context_cache.py +213 -0
  19. lucy_assist/services/__init__.py +21 -0
  20. lucy_assist/services/bug_notification_service.py +183 -0
  21. lucy_assist/services/claude_service.py +417 -0
  22. lucy_assist/services/context_service.py +350 -0
  23. lucy_assist/services/crud_service.py +364 -0
  24. lucy_assist/services/gitlab_service.py +248 -0
  25. lucy_assist/services/project_context_service.py +412 -0
  26. lucy_assist/services/tool_executor_service.py +343 -0
  27. lucy_assist/services/tools_definition.py +229 -0
  28. lucy_assist/signals.py +25 -0
  29. lucy_assist/static/lucy_assist/css/lucy-assist.css +160 -0
  30. lucy_assist/static/lucy_assist/image/icon-lucy.png +0 -0
  31. lucy_assist/static/lucy_assist/js/lucy-assist.js +824 -0
  32. lucy_assist/templates/lucy_assist/chatbot_sidebar.html +419 -0
  33. lucy_assist/templates/lucy_assist/partials/documentation_content.html +107 -0
  34. lucy_assist/tests/__init__.py +0 -0
  35. lucy_assist/tests/factories/__init__.py +15 -0
  36. lucy_assist/tests/factories/lucy_assist_factories.py +109 -0
  37. lucy_assist/tests/test_lucy_assist.py +186 -0
  38. lucy_assist/urls.py +36 -0
  39. lucy_assist/utils/__init__.py +7 -0
  40. lucy_assist/utils/log_utils.py +59 -0
  41. lucy_assist/utils/message_utils.py +130 -0
  42. lucy_assist/utils/token_utils.py +87 -0
  43. lucy_assist/views/__init__.py +13 -0
  44. lucy_assist/views/api_views.py +595 -0
@@ -0,0 +1,364 @@
1
+ """
2
+ Service pour exécuter des opérations CRUD via Lucy Assist.
3
+ """
4
+ import logging
5
+ from typing import Dict, List, Optional, Any
6
+
7
+ from django.apps import apps
8
+ from django.db import transaction
9
+ from django.forms import modelform_factory
10
+
11
+ from lucy_assist.utils.log_utils import LogUtils
12
+
13
+
14
+ class CRUDService:
15
+ """Service pour les opérations CRUD pilotées par Lucy Assist."""
16
+
17
+ def __init__(self, user):
18
+ self.user = user
19
+
20
+ def can_perform_action(self, app_name: str, model_name: str, action: str) -> bool:
21
+ """
22
+ Vérifie si l'utilisateur peut effectuer l'action.
23
+
24
+ Args:
25
+ app_name: Nom de l'app Django
26
+ model_name: Nom du modèle
27
+ action: 'add', 'change', 'delete', 'view'
28
+
29
+ Returns:
30
+ True si autorisé
31
+ """
32
+ if self.user.is_superuser:
33
+ return True
34
+
35
+ permission = f"{app_name}.{action}_{model_name.lower()}"
36
+ return self.user.has_perm(permission)
37
+
38
+ def get_model(self, app_name: str, model_name: str):
39
+ """Récupère une classe de modèle Django."""
40
+ try:
41
+ return apps.get_model(app_name, model_name)
42
+ except LookupError:
43
+ # Essayer de trouver dans toutes les apps
44
+ for app_config in apps.get_app_configs():
45
+ try:
46
+ return app_config.get_model(model_name)
47
+ except LookupError:
48
+ continue
49
+ return None
50
+
51
+ def get_form_class(self, model):
52
+ """Récupère ou crée une classe de formulaire pour le modèle."""
53
+ # Essayer de trouver un formulaire existant
54
+ app_label = model._meta.app_label
55
+
56
+ # Chercher le formulaire dans l'app
57
+ try:
58
+ forms_module = __import__(
59
+ f'apps.{app_label}.forms',
60
+ fromlist=[f'{model.__name__}Form']
61
+ )
62
+ form_class = getattr(forms_module, f'{model.__name__}Form', None)
63
+ if form_class:
64
+ return form_class
65
+ except (ImportError, AttributeError):
66
+ pass
67
+
68
+ # Créer un formulaire automatique
69
+ return modelform_factory(model, fields='__all__')
70
+
71
+ def get_required_fields(self, model) -> List[Dict]:
72
+ """
73
+ Retourne les champs requis pour créer un objet.
74
+
75
+ Returns:
76
+ Liste de dicts avec 'name', 'verbose_name', 'type', 'choices'
77
+ """
78
+ required = []
79
+
80
+ for field in model._meta.get_fields():
81
+ # Ignorer les relations inverses et les champs auto
82
+ if not hasattr(field, 'blank'):
83
+ continue
84
+
85
+ if field.name in ['id', 'pk', 'created_date', 'updated_date', 'created_user', 'updated_user']:
86
+ continue
87
+
88
+ if not field.blank or (hasattr(field, 'null') and not field.null and not field.has_default()):
89
+ field_info = {
90
+ 'name': field.name,
91
+ 'verbose_name': str(field.verbose_name) if hasattr(field, 'verbose_name') else field.name,
92
+ 'type': field.get_internal_type() if hasattr(field, 'get_internal_type') else 'text',
93
+ 'required': True
94
+ }
95
+
96
+ # Ajouter les choix si c'est un champ avec choices
97
+ if hasattr(field, 'choices') and field.choices:
98
+ field_info['choices'] = [
99
+ {'value': c[0], 'label': c[1]} for c in field.choices
100
+ ]
101
+
102
+ required.append(field_info)
103
+
104
+ return required
105
+
106
+ def get_optional_fields(self, model) -> List[Dict]:
107
+ """Retourne les champs optionnels."""
108
+ optional = []
109
+
110
+ for field in model._meta.get_fields():
111
+ if not hasattr(field, 'blank'):
112
+ continue
113
+
114
+ if field.name in ['id', 'pk', 'created_date', 'updated_date', 'created_user', 'updated_user']:
115
+ continue
116
+
117
+ if field.blank:
118
+ field_info = {
119
+ 'name': field.name,
120
+ 'verbose_name': str(field.verbose_name) if hasattr(field, 'verbose_name') else field.name,
121
+ 'type': field.get_internal_type() if hasattr(field, 'get_internal_type') else 'text',
122
+ 'required': False
123
+ }
124
+
125
+ if hasattr(field, 'choices') and field.choices:
126
+ field_info['choices'] = [
127
+ {'value': c[0], 'label': c[1]} for c in field.choices
128
+ ]
129
+
130
+ optional.append(field_info)
131
+
132
+ return optional
133
+
134
+ @transaction.atomic
135
+ def create_object(
136
+ self,
137
+ app_name: str,
138
+ model_name: str,
139
+ data: Dict[str, Any]
140
+ ) -> Dict:
141
+ """
142
+ Crée un nouvel objet.
143
+
144
+ Args:
145
+ app_name: Nom de l'app
146
+ model_name: Nom du modèle
147
+ data: Données du formulaire
148
+
149
+ Returns:
150
+ Dict avec 'success', 'object_id', 'errors'
151
+ """
152
+ # Vérifier les permissions
153
+ if not self.can_perform_action(app_name, model_name, 'add'):
154
+ return {
155
+ 'success': False,
156
+ 'errors': ['Vous n\'avez pas les droits pour créer cet objet.']
157
+ }
158
+
159
+ # Récupérer le modèle
160
+ model = self.get_model(app_name, model_name)
161
+ if not model:
162
+ return {
163
+ 'success': False,
164
+ 'errors': [f'Modèle {model_name} non trouvé.']
165
+ }
166
+
167
+ # Récupérer le formulaire
168
+ form_class = self.get_form_class(model)
169
+
170
+ try:
171
+ form = form_class(data=data)
172
+
173
+ if form.is_valid():
174
+ obj = form.save(commit=False)
175
+
176
+ # Ajouter l'utilisateur créateur si le champ existe
177
+ if hasattr(obj, 'created_user'):
178
+ obj.created_user = self.user
179
+
180
+ obj.save()
181
+ form.save_m2m() # Sauvegarder les relations many-to-many
182
+
183
+ return {
184
+ 'success': True,
185
+ 'object_id': obj.pk,
186
+ 'object_str': str(obj),
187
+ 'message': f'{model_name} créé avec succès.'
188
+ }
189
+ else:
190
+ return {
191
+ 'success': False,
192
+ 'errors': [f"{field}: {', '.join(errors)}" for field, errors in form.errors.items()]
193
+ }
194
+
195
+ except Exception as e:
196
+ LogUtils.error(f"Erreur lors de la création de {model_name}")
197
+ return {
198
+ 'success': False,
199
+ 'errors': [str(e)]
200
+ }
201
+
202
+ @transaction.atomic
203
+ def update_object(
204
+ self,
205
+ app_name: str,
206
+ model_name: str,
207
+ object_id: int,
208
+ data: Dict[str, Any]
209
+ ) -> Dict:
210
+ """
211
+ Met à jour un objet existant.
212
+
213
+ Returns:
214
+ Dict avec 'success', 'errors'
215
+ """
216
+ # Vérifier les permissions
217
+ if not self.can_perform_action(app_name, model_name, 'change'):
218
+ return {
219
+ 'success': False,
220
+ 'errors': ['Vous n\'avez pas les droits pour modifier cet objet.']
221
+ }
222
+
223
+ # Récupérer le modèle et l'objet
224
+ model = self.get_model(app_name, model_name)
225
+ if not model:
226
+ return {
227
+ 'success': False,
228
+ 'errors': [f'Modèle {model_name} non trouvé.']
229
+ }
230
+
231
+ try:
232
+ obj = model.objects.get(pk=object_id)
233
+ except model.DoesNotExist:
234
+ return {
235
+ 'success': False,
236
+ 'errors': [f'{model_name} #{object_id} non trouvé.']
237
+ }
238
+
239
+ # Récupérer le formulaire
240
+ form_class = self.get_form_class(model)
241
+
242
+ try:
243
+ form = form_class(data=data, instance=obj)
244
+
245
+ if form.is_valid():
246
+ obj = form.save(commit=False)
247
+
248
+ # Mettre à jour l'utilisateur modificateur si le champ existe
249
+ if hasattr(obj, 'updated_user'):
250
+ obj.updated_user = self.user
251
+
252
+ obj.save()
253
+ form.save_m2m()
254
+
255
+ return {
256
+ 'success': True,
257
+ 'object_id': obj.pk,
258
+ 'message': f'{model_name} mis à jour avec succès.'
259
+ }
260
+ else:
261
+ return {
262
+ 'success': False,
263
+ 'errors': [f"{field}: {', '.join(errors)}" for field, errors in form.errors.items()]
264
+ }
265
+
266
+ except Exception as e:
267
+ LogUtils.error(f"Erreur lors de la mise à jour de {model_name}")
268
+ return {
269
+ 'success': False,
270
+ 'errors': [str(e)]
271
+ }
272
+
273
+ @transaction.atomic
274
+ def delete_object(
275
+ self,
276
+ app_name: str,
277
+ model_name: str,
278
+ object_id: int
279
+ ) -> Dict:
280
+ """
281
+ Supprime un objet.
282
+
283
+ Returns:
284
+ Dict avec 'success', 'errors'
285
+ """
286
+ # Vérifier les permissions
287
+ if not self.can_perform_action(app_name, model_name, 'delete'):
288
+ return {
289
+ 'success': False,
290
+ 'errors': ['Vous n\'avez pas les droits pour supprimer cet objet.']
291
+ }
292
+
293
+ # Récupérer le modèle
294
+ model = self.get_model(app_name, model_name)
295
+ if not model:
296
+ return {
297
+ 'success': False,
298
+ 'errors': [f'Modèle {model_name} non trouvé.']
299
+ }
300
+
301
+ try:
302
+ obj = model.objects.get(pk=object_id)
303
+ obj_str = str(obj)
304
+ obj.delete()
305
+
306
+ return {
307
+ 'success': True,
308
+ 'message': f'{model_name} "{obj_str}" supprimé avec succès.'
309
+ }
310
+
311
+ except model.DoesNotExist:
312
+ return {
313
+ 'success': False,
314
+ 'errors': [f'{model_name} #{object_id} non trouvé.']
315
+ }
316
+ except Exception as e:
317
+ LogUtils.error(f"Erreur lors de la suppression de {model_name}")
318
+ return {
319
+ 'success': False,
320
+ 'errors': [str(e)]
321
+ }
322
+
323
+ def get_object(
324
+ self,
325
+ app_name: str,
326
+ model_name: str,
327
+ object_id: int
328
+ ) -> Optional[Dict]:
329
+ """
330
+ Récupère les détails d'un objet.
331
+
332
+ Returns:
333
+ Dict avec les données de l'objet ou None
334
+ """
335
+ # Vérifier les permissions
336
+ if not self.can_perform_action(app_name, model_name, 'view'):
337
+ return None
338
+
339
+ model = self.get_model(app_name, model_name)
340
+ if not model:
341
+ return None
342
+
343
+ try:
344
+ obj = model.objects.get(pk=object_id)
345
+
346
+ # Construire un dict avec les données
347
+ data = {'id': obj.pk, 'str': str(obj)}
348
+
349
+ for field in model._meta.get_fields():
350
+ if hasattr(field, 'verbose_name'):
351
+ try:
352
+ value = getattr(obj, field.name)
353
+ # Gérer les FK
354
+ if hasattr(value, 'pk'):
355
+ data[field.name] = {'id': value.pk, 'str': str(value)}
356
+ else:
357
+ data[field.name] = value
358
+ except Exception:
359
+ pass
360
+
361
+ return data
362
+
363
+ except model.DoesNotExist:
364
+ return None
@@ -0,0 +1,248 @@
1
+ """
2
+ Service d'intégration avec GitLab pour l'analyse de code.
3
+ """
4
+ import re
5
+ from typing import Dict, List, Optional
6
+ from urllib.parse import quote
7
+
8
+ from django.conf import settings
9
+
10
+ import requests
11
+
12
+ from lucy_assist.utils.log_utils import LogUtils
13
+
14
+
15
+ class GitLabService:
16
+ """Service pour interagir avec l'API GitLab."""
17
+
18
+ def __init__(self):
19
+ self.token = getattr(settings, 'GITLAB_TOKEN', None)
20
+ self.base_url = getattr(settings, 'GITLAB_URL', 'https://gitlab.com')
21
+ self.project_id = getattr(settings, 'GITLAB_PROJECT_ID', None)
22
+
23
+ if not self.token:
24
+ LogUtils.error("GITLAB_TOKEN non configuré")
25
+
26
+ @property
27
+ def headers(self) -> Dict[str, str]:
28
+ """Headers pour les requêtes API."""
29
+ return {
30
+ 'PRIVATE-TOKEN': self.token,
31
+ 'Content-Type': 'application/json'
32
+ }
33
+
34
+ def _api_url(self, endpoint: str) -> str:
35
+ """Construit l'URL de l'API."""
36
+ return f"{self.base_url}/api/v4{endpoint}"
37
+
38
+ def search_code(
39
+ self,
40
+ query: str,
41
+ scope: str = 'blobs',
42
+ per_page: int = 10
43
+ ) -> List[Dict]:
44
+ """
45
+ Recherche dans le code source du projet.
46
+
47
+ Args:
48
+ query: Terme de recherche
49
+ scope: 'blobs' (fichiers), 'commits', 'issues'
50
+ per_page: Nombre de résultats
51
+
52
+ Returns:
53
+ Liste des résultats de recherche
54
+ """
55
+ if not self.token or not self.project_id:
56
+ LogUtils.error("GitLab non configuré pour la recherche de code")
57
+ return []
58
+
59
+ try:
60
+ url = self._api_url(f"/projects/{self.project_id}/search")
61
+ params = {
62
+ 'scope': scope,
63
+ 'search': query,
64
+ 'per_page': per_page
65
+ }
66
+
67
+ response = requests.get(url, headers=self.headers, params=params, timeout=10)
68
+ response.raise_for_status()
69
+
70
+ return response.json()
71
+
72
+ except requests.RequestException as e:
73
+ LogUtils.error(f"Erreur lors de la recherche GitLab: {e}")
74
+ return []
75
+
76
+ def get_file_content(
77
+ self,
78
+ file_path: str,
79
+ ref: str = 'main'
80
+ ) -> Optional[str]:
81
+ """
82
+ Récupère le contenu d'un fichier.
83
+
84
+ Args:
85
+ file_path: Chemin du fichier dans le repo
86
+ ref: Branche ou tag
87
+
88
+ Returns:
89
+ Contenu du fichier ou None
90
+ """
91
+ if not self.token or not self.project_id:
92
+ return None
93
+
94
+ try:
95
+ encoded_path = quote(file_path, safe='')
96
+ url = self._api_url(
97
+ f"/projects/{self.project_id}/repository/files/{encoded_path}/raw"
98
+ )
99
+ params = {'ref': ref}
100
+
101
+ response = requests.get(url, headers=self.headers, params=params, timeout=10)
102
+ response.raise_for_status()
103
+
104
+ return response.text
105
+
106
+ except requests.RequestException as e:
107
+ LogUtils.error(f"Erreur lors de la récupération du fichier {file_path}: {e}")
108
+ return None
109
+
110
+ def get_file_blame(
111
+ self,
112
+ file_path: str,
113
+ ref: str = 'main'
114
+ ) -> List[Dict]:
115
+ """
116
+ Récupère le blame d'un fichier (qui a modifié quoi).
117
+
118
+ Returns:
119
+ Liste des blocs de blame
120
+ """
121
+ if not self.token or not self.project_id:
122
+ return []
123
+
124
+ try:
125
+ encoded_path = quote(file_path, safe='')
126
+ url = self._api_url(
127
+ f"/projects/{self.project_id}/repository/files/{encoded_path}/blame"
128
+ )
129
+ params = {'ref': ref}
130
+
131
+ response = requests.get(url, headers=self.headers, params=params, timeout=10)
132
+ response.raise_for_status()
133
+
134
+ return response.json()
135
+
136
+ except requests.RequestException as e:
137
+ LogUtils.error(f"Erreur lors du blame du fichier {file_path}: {e}")
138
+ return []
139
+
140
+ def find_view_for_url(self, url_path: str) -> Optional[Dict]:
141
+ """
142
+ Trouve la vue Django correspondant à une URL.
143
+
144
+ Args:
145
+ url_path: Chemin de l'URL (ex: /membre/list)
146
+
147
+ Returns:
148
+ Dict avec 'file_path', 'view_name', 'code' ou None
149
+ """
150
+ # Extraire le nom de l'app et la vue potentielle
151
+ parts = url_path.strip('/').split('/')
152
+ if not parts:
153
+ return None
154
+
155
+ app_name = parts[0]
156
+
157
+ # Chercher dans les fichiers urls.py et views.py
158
+ search_results = self.search_code(f"path.*{parts[-1] if len(parts) > 1 else app_name}")
159
+
160
+ for result in search_results:
161
+ if 'urls.py' in result.get('filename', ''):
162
+ # Trouver le nom de la vue associée
163
+ content = self.get_file_content(result['filename'])
164
+ if content:
165
+ # Chercher la vue correspondante
166
+ view_match = re.search(
167
+ rf'path\(["\'][^"\']*{re.escape(parts[-1] if len(parts) > 1 else "")}["\'][^)]*views\.(\w+)',
168
+ content
169
+ )
170
+ if view_match:
171
+ view_name = view_match.group(1)
172
+ # Chercher le fichier de la vue
173
+ views_path = result['filename'].replace('urls.py', f'views/{view_name.lower()}_views.py')
174
+ view_content = self.get_file_content(views_path)
175
+
176
+ if not view_content:
177
+ # Essayer le fichier views.py principal
178
+ views_path = result['filename'].replace('urls.py', 'views.py')
179
+ view_content = self.get_file_content(views_path)
180
+
181
+ return {
182
+ 'file_path': views_path,
183
+ 'view_name': view_name,
184
+ 'code': view_content
185
+ }
186
+
187
+ return None
188
+
189
+ def find_model_and_form(self, model_name: str) -> Dict:
190
+ """
191
+ Trouve le modèle et le formulaire correspondant.
192
+
193
+ Returns:
194
+ Dict avec 'model_file', 'model_code', 'form_file', 'form_code'
195
+ """
196
+ result = {
197
+ 'model_file': None,
198
+ 'model_code': None,
199
+ 'form_file': None,
200
+ 'form_code': None
201
+ }
202
+
203
+ # Chercher le modèle
204
+ model_search = self.search_code(f"class {model_name}(")
205
+ for item in model_search:
206
+ if 'models' in item.get('filename', ''):
207
+ result['model_file'] = item['filename']
208
+ result['model_code'] = self.get_file_content(item['filename'])
209
+ break
210
+
211
+ # Chercher le formulaire
212
+ form_search = self.search_code(f"class {model_name}Form(")
213
+ for item in form_search:
214
+ if 'forms' in item.get('filename', ''):
215
+ result['form_file'] = item['filename']
216
+ result['form_code'] = self.get_file_content(item['filename'])
217
+ break
218
+
219
+ return result
220
+
221
+ def get_recent_commits(self, per_page: int = 10) -> List[Dict]:
222
+ """
223
+ Récupère les commits récents.
224
+
225
+ Returns:
226
+ Liste des commits avec 'id', 'message', 'author', 'date'
227
+ """
228
+ if not self.token or not self.project_id:
229
+ return []
230
+
231
+ try:
232
+ url = self._api_url(f"/projects/{self.project_id}/repository/commits")
233
+ params = {'per_page': per_page}
234
+
235
+ response = requests.get(url, headers=self.headers, params=params, timeout=10)
236
+ response.raise_for_status()
237
+
238
+ commits = response.json()
239
+ return [{
240
+ 'id': c['short_id'],
241
+ 'message': c['title'],
242
+ 'author': c['author_name'],
243
+ 'date': c['created_at']
244
+ } for c in commits]
245
+
246
+ except requests.RequestException as e:
247
+ LogUtils.error(f"Erreur lors de la récupération des commits: {e}")
248
+ return []