django-app-logs 2.2.1__tar.gz

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.
@@ -0,0 +1,58 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-app-logs
3
+ Version: 2.2.1
4
+ Summary: Logging django's actions
5
+ Author-email: Cissé Anzoumana <anzcisse1@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/mosco23/django-app-logs
8
+ Project-URL: Documentation, https://github.com/mosco23/django-app-logs#readme
9
+ Project-URL: Bug Tracker, https://github.com/mosco23/django-app-logs/issues
10
+ Keywords: django,logging,logger,access,django access log
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Framework :: Django
13
+ Classifier: Framework :: Django :: 3.2
14
+ Classifier: Framework :: Django :: 4.0
15
+ Classifier: Framework :: Django :: 4.1
16
+ Classifier: Framework :: Django :: 4.2
17
+ Classifier: Framework :: Django :: 5.0
18
+ Classifier: Intended Audience :: Developers
19
+ Classifier: Operating System :: OS Independent
20
+ Classifier: Programming Language :: Python :: 3
21
+ Classifier: Programming Language :: Python :: 3.8
22
+ Classifier: Programming Language :: Python :: 3.9
23
+ Classifier: Programming Language :: Python :: 3.10
24
+ Classifier: Programming Language :: Python :: 3.11
25
+ Classifier: Programming Language :: Python :: 3.12
26
+ Classifier: Programming Language :: Python :: 3.13
27
+ Classifier: Topic :: Internet :: WWW/HTTP
28
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
29
+ Requires-Python: >=3.8
30
+ Description-Content-Type: text/markdown
31
+ Requires-Dist: Django>=3.2
32
+ Provides-Extra: dev
33
+ Requires-Dist: black>=23.0.0; extra == "dev"
34
+ Requires-Dist: flake8>=6.0.0; extra == "dev"
35
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
36
+ Requires-Dist: pytest-django>=4.5.0; extra == "dev"
37
+
38
+ # Django Access Log
39
+
40
+
41
+ ## Add packge to INSTALLED_APPS
42
+
43
+ ```python
44
+
45
+ INSTALLED_APPS = [
46
+ ...
47
+ 'django_app_logs',
48
+ ]
49
+
50
+ ```
51
+
52
+ ## Make migrations
53
+
54
+ ```python
55
+
56
+ python manage.py migrate
57
+
58
+ ```
@@ -0,0 +1,21 @@
1
+ # Django Access Log
2
+
3
+
4
+ ## Add packge to INSTALLED_APPS
5
+
6
+ ```python
7
+
8
+ INSTALLED_APPS = [
9
+ ...
10
+ 'django_app_logs',
11
+ ]
12
+
13
+ ```
14
+
15
+ ## Make migrations
16
+
17
+ ```python
18
+
19
+ python manage.py migrate
20
+
21
+ ```
File without changes
@@ -0,0 +1,188 @@
1
+ from django.contrib import admin
2
+ from django.contrib.contenttypes.models import ContentType
3
+ from django.urls import path, reverse
4
+ from django.template.loader import render_to_string
5
+ from django.shortcuts import render
6
+ from django.utils.html import format_html
7
+ from django.http import Http404
8
+ import json
9
+
10
+ from .models import ActionLog
11
+
12
+
13
+ @admin.register(ActionLog)
14
+ class ActionLogAdmin(admin.ModelAdmin):
15
+ list_display = (
16
+ 'timestamp',
17
+ 'user_display',
18
+ 'action_display',
19
+ 'content_type',
20
+ 'object_repr',
21
+ 'object_history_link',
22
+ 'ip_address',
23
+ )
24
+ list_filter = ('action', 'content_type', 'timestamp')
25
+ search_fields = ('object_repr', 'user__username', 'ip_address')
26
+ date_hierarchy = 'timestamp'
27
+ readonly_fields = (
28
+ 'content_type',
29
+ 'object_id',
30
+ 'object_repr',
31
+ 'action',
32
+ 'user',
33
+ 'ip_address',
34
+ 'user_agent',
35
+ 'changes_display',
36
+ 'timestamp',
37
+ )
38
+
39
+ fieldsets = (
40
+ ('Objet', {
41
+ 'fields': ('content_type', 'object_id', 'object_repr')
42
+ }),
43
+ ('Action', {
44
+ 'fields': ('action', 'user', 'timestamp')
45
+ }),
46
+ ('Contexte', {
47
+ 'fields': ('ip_address', 'user_agent'),
48
+ 'classes': ('collapse',)
49
+ }),
50
+ ('Modifications', {
51
+ 'fields': ('changes_display',)
52
+ }),
53
+ )
54
+
55
+ def has_add_permission(self, request):
56
+ return False
57
+
58
+ def has_change_permission(self, request, obj=None):
59
+ return False
60
+
61
+ def has_delete_permission(self, request, obj=None):
62
+ return request.user.is_superuser
63
+
64
+ def get_urls(self):
65
+ urls = super().get_urls()
66
+ custom_urls = [
67
+ path(
68
+ 'object-history/<int:content_type_id>/<int:object_id>/',
69
+ self.admin_site.admin_view(self.object_history_view),
70
+ name='history_object_history'
71
+ ),
72
+ ]
73
+ return custom_urls + urls
74
+
75
+ def object_history_view(self, request, content_type_id, object_id):
76
+ """Vue pour afficher l'historique complet d'un objet."""
77
+ try:
78
+ content_type = ContentType.objects.get(pk=content_type_id)
79
+ except ContentType.DoesNotExist:
80
+ raise Http404("Type de contenu non trouvé")
81
+
82
+ logs = ActionLog.objects.filter(
83
+ content_type=content_type,
84
+ object_id=object_id
85
+ ).order_by('-timestamp')
86
+
87
+ # Récupérer la représentation de l'objet
88
+ if logs.exists():
89
+ object_repr = logs.first().object_repr
90
+ else:
91
+ # Essayer de récupérer l'objet directement
92
+ model_class = content_type.model_class()
93
+ try:
94
+ obj = model_class.objects.get(pk=object_id)
95
+ object_repr = str(obj)
96
+ except model_class.DoesNotExist:
97
+ object_repr = f"{content_type.model} #{object_id} (supprimé)"
98
+
99
+ # URL vers l'objet s'il existe encore
100
+ object_url = None
101
+ model_class = content_type.model_class()
102
+ try:
103
+ obj = model_class.objects.get(pk=object_id)
104
+ object_url = reverse(
105
+ f'admin:{content_type.app_label}_{content_type.model}_change',
106
+ args=[object_id]
107
+ )
108
+ except (model_class.DoesNotExist, Exception):
109
+ pass
110
+
111
+ context = {
112
+ **self.admin_site.each_context(request),
113
+ 'title': f'Historique de {object_repr}',
114
+ 'logs': logs,
115
+ 'content_type': content_type,
116
+ 'object_id': object_id,
117
+ 'object_repr': object_repr,
118
+ 'object_url': object_url,
119
+ }
120
+
121
+ return render(request, 'admin/history/object_history.html', context)
122
+
123
+ def user_display(self, obj):
124
+ if obj.user:
125
+ return f"{obj.user.last_name} {obj.user.first_name}".strip() or obj.user.username
126
+ return "Système"
127
+ user_display.short_description = "Utilisateur"
128
+
129
+ def action_display(self, obj):
130
+ colors = {
131
+ 'CREATE': '#28a745',
132
+ 'UPDATE': '#ffc107',
133
+ 'DELETE': '#dc3545',
134
+ }
135
+ color = colors.get(obj.action, '#6c757d')
136
+ return format_html(
137
+ '<span style="color: {}; font-weight: bold;">{}</span>',
138
+ color,
139
+ obj.get_action_display()
140
+ )
141
+ action_display.short_description = "Action"
142
+
143
+ def object_history_link(self, obj):
144
+ """Lien vers l'historique complet de l'objet."""
145
+ url = reverse(
146
+ 'admin:django_app_logs_object_history',
147
+ args=[obj.content_type_id, obj.object_id]
148
+ )
149
+ count = ActionLog.objects.filter(
150
+ content_type=obj.content_type,
151
+ object_id=obj.object_id
152
+ ).count()
153
+ return format_html(
154
+ '<a href="{}" title="Voir tout l\'historique de cet objet">'
155
+ '<i class="fas fa-history"></i> {} action(s)</a>',
156
+ url,
157
+ count
158
+ )
159
+ object_history_link.short_description = "Historique"
160
+
161
+
162
+ def changes_display(self, obj):
163
+ if not obj.changes:
164
+ return "-"
165
+
166
+ history = []
167
+ for field, values in obj.changes.items():
168
+ old_val = values.get('old', '-')
169
+ new_val = values.get('new', '-')
170
+
171
+ if isinstance(old_val, dict):
172
+ old_val = old_val.get('repr', json.dumps(old_val, ensure_ascii=False))
173
+ if isinstance(new_val, dict):
174
+ new_val = new_val.get('repr', json.dumps(new_val, ensure_ascii=False))
175
+
176
+ data = {
177
+ "field": field,
178
+ "old_val": old_val,
179
+ "new_val": new_val,
180
+ }
181
+ history.append(data)
182
+
183
+ context = {
184
+ 'history': history
185
+ }
186
+ return render_to_string('admin/partials/action_view.html', context)
187
+ changes_display.short_description = "Détails des modifications"
188
+
@@ -0,0 +1,15 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class DjangoAppLogsConfig(AppConfig):
5
+ default_auto_field = 'django.db.models.BigAutoField'
6
+ name = 'django_app_logs'
7
+ verbose_name = 'Journal des actions'
8
+
9
+ def ready(self):
10
+ # Enregistrer les exclusions par défaut
11
+ from .registry import ActionLogRegistry
12
+ ActionLogRegistry.register_default_exclusions()
13
+
14
+ # Importer les signaux pour les connecter
15
+ from . import signals # noqa
@@ -0,0 +1,51 @@
1
+ from threading import local
2
+
3
+ _request_context = local()
4
+
5
+
6
+ class RequestContextMiddleware:
7
+ """
8
+ Middleware qui capture et stocke les informations de contexte de la requête
9
+ (user, IP, User-Agent) dans un thread-local pour utilisation par les signaux.
10
+ """
11
+
12
+ def __init__(self, get_response):
13
+ self.get_response = get_response
14
+
15
+ def __call__(self, request):
16
+ # Stocker les informations dans le thread-local
17
+ _request_context.user = getattr(request, 'user', None)
18
+ _request_context.ip_address = self.get_client_ip(request)
19
+ _request_context.user_agent = request.META.get('HTTP_USER_AGENT', '')[:500]
20
+
21
+ response = self.get_response(request)
22
+
23
+ return response
24
+
25
+ @staticmethod
26
+ def get_client_ip(request):
27
+ """Récupère l'IP client, gérant les proxies."""
28
+ x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
29
+ if x_forwarded_for:
30
+ ip = x_forwarded_for.split(',')[0].strip()
31
+ else:
32
+ ip = request.META.get('REMOTE_ADDR')
33
+ return ip
34
+
35
+ @staticmethod
36
+ def get_current_user():
37
+ """Récupère l'utilisateur courant."""
38
+ user = getattr(_request_context, 'user', None)
39
+ if user and hasattr(user, 'is_authenticated') and user.is_authenticated:
40
+ return user
41
+ return None
42
+
43
+ @staticmethod
44
+ def get_ip_address():
45
+ """Récupère l'adresse IP."""
46
+ return getattr(_request_context, 'ip_address', None)
47
+
48
+ @staticmethod
49
+ def get_user_agent():
50
+ """Récupère le User-Agent."""
51
+ return getattr(_request_context, 'user_agent', '')
@@ -0,0 +1,39 @@
1
+ # Generated by Django 5.0.7 on 2025-12-14 09:11
2
+
3
+ import django.db.models.deletion
4
+ from django.conf import settings
5
+ from django.db import migrations, models
6
+
7
+
8
+ class Migration(migrations.Migration):
9
+
10
+ initial = True
11
+
12
+ dependencies = [
13
+ ('contenttypes', '0002_remove_content_type_name'),
14
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
15
+ ]
16
+
17
+ operations = [
18
+ migrations.CreateModel(
19
+ name='ActionLog',
20
+ fields=[
21
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
22
+ ('object_id', models.PositiveIntegerField(verbose_name='ID objet')),
23
+ ('object_repr', models.CharField(blank=True, max_length=255, verbose_name='Représentation objet')),
24
+ ('action', models.CharField(choices=[('CREATE', 'Création'), ('UPDATE', 'Modification'), ('DELETE', 'Suppression')], max_length=10, verbose_name='Action')),
25
+ ('ip_address', models.GenericIPAddressField(blank=True, null=True, verbose_name='Adresse IP')),
26
+ ('user_agent', models.TextField(blank=True, verbose_name='User-Agent')),
27
+ ('changes', models.JSONField(default=dict, verbose_name='Modifications')),
28
+ ('timestamp', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='Date/Heure')),
29
+ ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name='Type de contenu')),
30
+ ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='action_logs', to=settings.AUTH_USER_MODEL, verbose_name='Utilisateur')),
31
+ ],
32
+ options={
33
+ 'verbose_name': "Journal d'action",
34
+ 'verbose_name_plural': 'Journal des actions',
35
+ 'ordering': ['-timestamp'],
36
+ 'indexes': [models.Index(fields=['content_type', 'object_id'], name='history_act_content_14194b_idx'), models.Index(fields=['user', 'timestamp'], name='history_act_user_id_ca8939_idx'), models.Index(fields=['action', 'timestamp'], name='history_act_action_a84aa4_idx')],
37
+ },
38
+ ),
39
+ ]
@@ -0,0 +1,89 @@
1
+ from django.db import models
2
+ from django.conf import settings
3
+ from django.contrib.contenttypes.models import ContentType
4
+ from django.contrib.contenttypes.fields import GenericForeignKey
5
+
6
+
7
+ class ActionType(models.TextChoices):
8
+ CREATE = 'CREATE', 'Création'
9
+ UPDATE = 'UPDATE', 'Modification'
10
+ DELETE = 'DELETE', 'Suppression'
11
+
12
+
13
+ class ActionLog(models.Model):
14
+ """
15
+ Modèle de traçabilité des actions CRUD sur tous les modèles du projet.
16
+ """
17
+ # Relation générique vers l'objet concerné
18
+ content_type = models.ForeignKey(
19
+ ContentType,
20
+ on_delete=models.CASCADE,
21
+ verbose_name="Type de contenu"
22
+ )
23
+ object_id = models.PositiveIntegerField(verbose_name="ID objet")
24
+ content_object = GenericForeignKey('content_type', 'object_id')
25
+
26
+ # Représentation textuelle de l'objet (utile après suppression)
27
+ object_repr = models.CharField(
28
+ max_length=255,
29
+ verbose_name="Représentation objet",
30
+ blank=True
31
+ )
32
+
33
+ # Type d'action
34
+ action = models.CharField(
35
+ max_length=10,
36
+ choices=ActionType.choices,
37
+ verbose_name="Action"
38
+ )
39
+
40
+ # Utilisateur ayant effectué l'action
41
+ user = models.ForeignKey(
42
+ settings.AUTH_USER_MODEL,
43
+ on_delete=models.SET_NULL,
44
+ null=True,
45
+ blank=True,
46
+ related_name='action_logs',
47
+ verbose_name="Utilisateur"
48
+ )
49
+
50
+ # Informations de contexte HTTP
51
+ ip_address = models.GenericIPAddressField(
52
+ null=True,
53
+ blank=True,
54
+ verbose_name="Adresse IP"
55
+ )
56
+ user_agent = models.TextField(
57
+ blank=True,
58
+ verbose_name="User-Agent"
59
+ )
60
+
61
+ # Données avant/après modification
62
+ # Pour CREATE: {"field1": {"new": value}, ...}
63
+ # Pour UPDATE: {"field1": {"old": x, "new": y}, ...} (champs modifiés uniquement)
64
+ # Pour DELETE: {"field1": {"old": value}, ...}
65
+ changes = models.JSONField(
66
+ default=dict,
67
+ verbose_name="Modifications"
68
+ )
69
+
70
+ # Timestamp de l'action
71
+ timestamp = models.DateTimeField(
72
+ auto_now_add=True,
73
+ db_index=True,
74
+ verbose_name="Date/Heure"
75
+ )
76
+
77
+ class Meta:
78
+ verbose_name = "Journal d'action"
79
+ verbose_name_plural = "Journal des actions"
80
+ ordering = ['-timestamp']
81
+ indexes = [
82
+ models.Index(fields=['content_type', 'object_id']),
83
+ models.Index(fields=['user', 'timestamp']),
84
+ models.Index(fields=['action', 'timestamp']),
85
+ ]
86
+
87
+ def __str__(self):
88
+ user_str = self.user.username if self.user else 'Système'
89
+ return f"[{self.timestamp:%Y-%m-%d %H:%M}] {user_str} - {self.action} - {self.object_repr}"
@@ -0,0 +1,43 @@
1
+ class ActionLogRegistry:
2
+ """
3
+ Registre des modèles à exclure du tracking automatique.
4
+ """
5
+
6
+ _excluded_models = set()
7
+
8
+ @classmethod
9
+ def exclude(cls, model_or_label):
10
+ """
11
+ Exclut un modèle du tracking.
12
+
13
+ Usage:
14
+ ActionLogRegistry.exclude('operation_app.Transfert')
15
+ ActionLogRegistry.exclude(Transfert)
16
+ """
17
+ if isinstance(model_or_label, str):
18
+ cls._excluded_models.add(model_or_label.lower())
19
+ else:
20
+ label = f"{model_or_label._meta.app_label}.{model_or_label._meta.model_name}"
21
+ cls._excluded_models.add(label.lower())
22
+
23
+ @classmethod
24
+ def is_excluded(cls, model):
25
+ """Vérifie si un modèle est exclu."""
26
+ label = f"{model._meta.app_label}.{model._meta.model_name}".lower()
27
+ return label in cls._excluded_models
28
+
29
+ @classmethod
30
+ def register_default_exclusions(cls):
31
+ """Enregistre les exclusions par défaut."""
32
+ # Modèles explicitement exclus (ont leur propre système d'archive)
33
+ #cls.exclude('operation_app.transfert')
34
+ #cls.exclude('operation_app.recouvrement')
35
+
36
+ # Le modèle ActionLog lui-même (éviter récursion)
37
+ cls.exclude('django_app_logs.actionlog')
38
+
39
+ # Modèles Django internes à exclure
40
+ cls.exclude('contenttypes.contenttype')
41
+ cls.exclude('sessions.session')
42
+ cls.exclude('admin.logentry')
43
+ cls.exclude('auth.permission')
@@ -0,0 +1,121 @@
1
+ import json
2
+ from datetime import datetime, date, time
3
+ from decimal import Decimal
4
+ from uuid import UUID
5
+ from django.db.models import Model
6
+ from django.db.models.fields.files import FieldFile
7
+
8
+
9
+ class ActionLogEncoder(json.JSONEncoder):
10
+ """
11
+ Encodeur JSON personnalisé pour les valeurs de modèles Django.
12
+ """
13
+
14
+ def default(self, obj):
15
+ if isinstance(obj, datetime):
16
+ return obj.isoformat()
17
+ elif isinstance(obj, date):
18
+ return obj.isoformat()
19
+ elif isinstance(obj, time):
20
+ return obj.isoformat()
21
+ elif isinstance(obj, Decimal):
22
+ return str(obj)
23
+ elif isinstance(obj, UUID):
24
+ return str(obj)
25
+ elif isinstance(obj, FieldFile):
26
+ return obj.name if obj else None
27
+ elif isinstance(obj, Model):
28
+ return {
29
+ 'pk': obj.pk,
30
+ 'repr': str(obj),
31
+ 'model': f"{obj._meta.app_label}.{obj._meta.model_name}"
32
+ }
33
+ elif hasattr(obj, '__iter__') and not isinstance(obj, (str, bytes)):
34
+ return list(obj)
35
+ return super().default(obj)
36
+
37
+
38
+ def serialize_value(value):
39
+ """Sérialise une valeur pour stockage JSON."""
40
+ return json.loads(json.dumps(value, cls=ActionLogEncoder))
41
+
42
+
43
+ def get_model_field_value(instance, field_name):
44
+ """
45
+ Récupère la valeur d'un champ de manière sécurisée.
46
+ Gère les FK en récupérant le PK et la représentation.
47
+ """
48
+ try:
49
+ field = instance._meta.get_field(field_name)
50
+ value = getattr(instance, field_name, None)
51
+
52
+ # Pour les ForeignKey, on stocke le PK + représentation
53
+ if hasattr(field, 'related_model') and field.related_model:
54
+ if value is not None:
55
+ return {
56
+ 'pk': value.pk,
57
+ 'repr': str(value)
58
+ }
59
+ return None
60
+
61
+ return serialize_value(value)
62
+ except Exception:
63
+ return None
64
+
65
+
66
+ def compute_changes(old_instance, new_instance, fields_to_track=None):
67
+ """
68
+ Calcule les différences entre deux instances d'un modèle.
69
+
70
+ Args:
71
+ old_instance: Instance avant modification (ou None pour CREATE)
72
+ new_instance: Instance après modification
73
+ fields_to_track: Liste de noms de champs à tracker (None = tous)
74
+
75
+ Returns:
76
+ dict: {field_name: {'old': value, 'new': value}, ...}
77
+ """
78
+ changes = {}
79
+
80
+ # Champs à ignorer
81
+ ignored_fields = {'id', 'pk', 'created_at', 'updated_at'}
82
+
83
+ # Déterminer les champs à tracker
84
+ if fields_to_track is None:
85
+ fields = [
86
+ f.name for f in new_instance._meta.get_fields()
87
+ if hasattr(f, 'column') and f.name not in ignored_fields
88
+ ]
89
+ else:
90
+ fields = [f for f in fields_to_track if f not in ignored_fields]
91
+
92
+ for field_name in fields:
93
+ new_value = get_model_field_value(new_instance, field_name)
94
+
95
+ if old_instance is None:
96
+ # CREATE: toutes les nouvelles valeurs
97
+ if new_value is not None:
98
+ changes[field_name] = {'new': new_value}
99
+ else:
100
+ # UPDATE: seulement les différences
101
+ old_value = get_model_field_value(old_instance, field_name)
102
+ if old_value != new_value:
103
+ changes[field_name] = {'old': old_value, 'new': new_value}
104
+
105
+ return changes
106
+
107
+
108
+ def get_instance_snapshot(instance):
109
+ """
110
+ Capture un snapshot complet d'une instance (pour DELETE).
111
+ """
112
+ snapshot = {}
113
+ ignored_fields = {'id', 'pk'}
114
+
115
+ for field in instance._meta.get_fields():
116
+ if hasattr(field, 'column') and field.name not in ignored_fields:
117
+ value = get_model_field_value(instance, field.name)
118
+ if value is not None:
119
+ snapshot[field.name] = {'old': value}
120
+
121
+ return snapshot
@@ -0,0 +1,175 @@
1
+ import logging
2
+ from django.db.models.signals import post_save, pre_save, pre_delete, post_delete
3
+ from django.dispatch import receiver
4
+ from django.contrib.contenttypes.models import ContentType
5
+ from django.db import connection
6
+ from django.apps import apps
7
+
8
+ from .models import ActionLog, ActionType
9
+ from .registry import ActionLogRegistry
10
+ from .middleware import RequestContextMiddleware
11
+ from .serializers import compute_changes, get_instance_snapshot
12
+
13
+ logger = logging.getLogger('history')
14
+
15
+ # Cache pour stocker l'état pré-modification
16
+ _pre_save_states = {}
17
+ _pre_delete_states = {}
18
+
19
+
20
+ def get_context():
21
+ """Récupère le contexte de la requête courante."""
22
+ return {
23
+ 'user': RequestContextMiddleware.get_current_user(),
24
+ 'ip_address': RequestContextMiddleware.get_ip_address(),
25
+ 'user_agent': RequestContextMiddleware.get_user_agent(),
26
+ }
27
+
28
+ def _skip_signal():
29
+ # App pas complètement prête (startup, migrate, shell, etc.)
30
+ if not apps.ready:
31
+ return True
32
+
33
+ # Migration / transaction en cours
34
+ if connection.in_atomic_block:
35
+ return True
36
+
37
+ # Table pas encore créée
38
+ return "django_app_logs_actionlog" not in connection.introspection.table_names()
39
+
40
+
41
+ @receiver(pre_save)
42
+ def on_pre_save(sender, instance, raw, using, update_fields, **kwargs):
43
+ """
44
+ Signal pre_save: capture l'état avant modification pour UPDATE.
45
+ """
46
+ if raw:
47
+ return
48
+
49
+ if ActionLogRegistry.is_excluded(sender):
50
+ return
51
+
52
+ if sender._meta.proxy or sender._meta.abstract:
53
+ return
54
+
55
+ # Seulement pour les updates (instance avec pk existant en base)
56
+ if instance.pk:
57
+ try:
58
+ cache_key = (sender, instance.pk)
59
+ old_instance = sender.objects.get(pk=instance.pk)
60
+ _pre_save_states[cache_key] = old_instance
61
+ except sender.DoesNotExist:
62
+ pass
63
+
64
+
65
+ @receiver(post_save)
66
+ def on_post_save(sender, instance, created, raw, using, update_fields, **kwargs):
67
+ """
68
+ Signal post_save: capture CREATE et UPDATE.
69
+ """
70
+ # Ignorer les fixtures (raw=True)
71
+ if raw or _skip_signal():
72
+ return
73
+
74
+ # Vérifier si le modèle est exclu
75
+ if ActionLogRegistry.is_excluded(sender):
76
+ return
77
+
78
+ # Ignorer les modèles sans table (proxy, abstract)
79
+ if sender._meta.proxy or sender._meta.abstract:
80
+ return
81
+
82
+ try:
83
+ content_type = ContentType.objects.get_for_model(sender)
84
+ context = get_context()
85
+
86
+ if created:
87
+ # CREATE
88
+ action = ActionType.CREATE
89
+ changes = compute_changes(None, instance)
90
+ else:
91
+ # UPDATE
92
+ action = ActionType.UPDATE
93
+
94
+ # Récupérer l'ancien état depuis le cache
95
+ cache_key = (sender, instance.pk)
96
+ old_instance = _pre_save_states.pop(cache_key, None)
97
+
98
+ changes = compute_changes(old_instance, instance)
99
+
100
+ # Si aucun changement significatif, ne pas logger
101
+ if not changes:
102
+ return
103
+
104
+ ActionLog.objects.create(
105
+ content_type=content_type,
106
+ object_id=instance.pk,
107
+ object_repr=str(instance)[:255],
108
+ action=action,
109
+ user=context['user'],
110
+ ip_address=context['ip_address'],
111
+ user_agent=context['user_agent'] or '',
112
+ changes=changes,
113
+ )
114
+
115
+ except Exception as e:
116
+ # En production, logger l'erreur sans faire échouer la transaction
117
+ logger.exception(f"Erreur lors du tracking de {sender}: {e}")
118
+
119
+
120
+ @receiver(pre_delete)
121
+ def on_pre_delete(sender, instance, using, **kwargs):
122
+ """
123
+ Signal pre_delete: capture l'état avant suppression.
124
+ """
125
+ if ActionLogRegistry.is_excluded(sender):
126
+ return
127
+
128
+ if sender._meta.proxy or sender._meta.abstract:
129
+ return
130
+
131
+ cache_key = (sender, instance.pk)
132
+ _pre_delete_states[cache_key] = {
133
+ 'snapshot': get_instance_snapshot(instance),
134
+ 'repr': str(instance)[:255],
135
+ }
136
+
137
+
138
+ @receiver(post_delete)
139
+ def on_post_delete(sender, instance, using, **kwargs):
140
+ """
141
+ Signal post_delete: enregistre la suppression.
142
+ """
143
+ if ActionLogRegistry.is_excluded(sender):
144
+ return
145
+
146
+ if sender._meta.proxy or sender._meta.abstract:
147
+ return
148
+
149
+ try:
150
+ content_type = ContentType.objects.get_for_model(sender)
151
+ context = get_context()
152
+
153
+ cache_key = (sender, instance.pk)
154
+ pre_state = _pre_delete_states.pop(cache_key, None)
155
+
156
+ if pre_state:
157
+ changes = pre_state['snapshot']
158
+ object_repr = pre_state['repr']
159
+ else:
160
+ changes = get_instance_snapshot(instance)
161
+ object_repr = str(instance)[:255]
162
+
163
+ ActionLog.objects.create(
164
+ content_type=content_type,
165
+ object_id=instance.pk,
166
+ object_repr=object_repr,
167
+ action=ActionType.DELETE,
168
+ user=context['user'],
169
+ ip_address=context['ip_address'],
170
+ user_agent=context['user_agent'] or '',
171
+ changes=changes,
172
+ )
173
+
174
+ except Exception as e:
175
+ logger.exception(f"Erreur lors du tracking suppression de {sender}: {e}")
@@ -0,0 +1,140 @@
1
+ from django.contrib.contenttypes.models import ContentType
2
+
3
+ from .models import ActionLog, ActionType
4
+ from .middleware import RequestContextMiddleware
5
+ from .serializers import compute_changes, get_instance_snapshot
6
+
7
+
8
+ def bulk_create_with_history(model_class, objects, **kwargs):
9
+ """
10
+ Wrapper pour bulk_create avec tracking.
11
+
12
+ Usage:
13
+ from history.utils import bulk_create_with_history
14
+ created = bulk_create_with_history(MyModel, [obj1, obj2, obj3])
15
+ """
16
+ created = model_class.objects.bulk_create(objects, **kwargs)
17
+
18
+ content_type = ContentType.objects.get_for_model(model_class)
19
+ context = {
20
+ 'user': RequestContextMiddleware.get_current_user(),
21
+ 'ip_address': RequestContextMiddleware.get_ip_address(),
22
+ 'user_agent': RequestContextMiddleware.get_user_agent() or '',
23
+ }
24
+
25
+ logs = []
26
+ for obj in created:
27
+ changes = compute_changes(None, obj)
28
+ logs.append(ActionLog(
29
+ content_type=content_type,
30
+ object_id=obj.pk,
31
+ object_repr=str(obj)[:255],
32
+ action=ActionType.CREATE,
33
+ changes=changes,
34
+ **context
35
+ ))
36
+
37
+ ActionLog.objects.bulk_create(logs)
38
+ return created
39
+
40
+
41
+ def queryset_delete_with_history(queryset):
42
+ """
43
+ Wrapper pour QuerySet.delete() avec tracking.
44
+
45
+ Usage:
46
+ from history.utils import queryset_delete_with_history
47
+ deleted_count = queryset_delete_with_history(MyModel.objects.filter(active=False))
48
+ """
49
+ model_class = queryset.model
50
+ content_type = ContentType.objects.get_for_model(model_class)
51
+ context = {
52
+ 'user': RequestContextMiddleware.get_current_user(),
53
+ 'ip_address': RequestContextMiddleware.get_ip_address(),
54
+ 'user_agent': RequestContextMiddleware.get_user_agent() or '',
55
+ }
56
+
57
+ logs = []
58
+ for obj in queryset:
59
+ snapshot = get_instance_snapshot(obj)
60
+ logs.append(ActionLog(
61
+ content_type=content_type,
62
+ object_id=obj.pk,
63
+ object_repr=str(obj)[:255],
64
+ action=ActionType.DELETE,
65
+ changes=snapshot,
66
+ **context
67
+ ))
68
+
69
+ ActionLog.objects.bulk_create(logs)
70
+ return queryset.delete()
71
+
72
+
73
+ def get_object_history(obj):
74
+ """
75
+ Récupère l'historique complet d'un objet.
76
+
77
+ Usage:
78
+ from history.utils import get_object_history
79
+ history = get_object_history(my_intermed)
80
+ """
81
+ content_type = ContentType.objects.get_for_model(obj)
82
+ return ActionLog.objects.filter(
83
+ content_type=content_type,
84
+ object_id=obj.pk
85
+ ).order_by('-timestamp')
86
+
87
+
88
+ def get_user_actions(user, limit=50):
89
+ """
90
+ Récupère les dernières actions d'un utilisateur.
91
+
92
+ Usage:
93
+ from history.utils import get_user_actions
94
+ actions = get_user_actions(request.user)
95
+ """
96
+ return ActionLog.objects.filter(user=user).order_by('-timestamp')[:limit]
97
+
98
+
99
+ def get_object_history_url(obj):
100
+ """
101
+ Retourne l'URL de la page d'historique d'un objet.
102
+
103
+ Usage:
104
+ from history.utils import get_object_history_url
105
+ url = get_object_history_url(my_intermed)
106
+ # -> /admin/history/actionlog/object-history/15/42/
107
+ """
108
+ from django.urls import reverse
109
+ content_type = ContentType.objects.get_for_model(obj)
110
+ return reverse(
111
+ 'admin:django_app_logs_object_history',
112
+ args=[content_type.pk, obj.pk]
113
+ )
114
+
115
+
116
+ def get_history_link_html(obj):
117
+ """
118
+ Retourne un lien HTML vers l'historique d'un objet.
119
+ Utile pour l'intégration dans d'autres ModelAdmin.
120
+
121
+ Usage dans un ModelAdmin:
122
+ from history.utils import get_history_link_html
123
+
124
+ def history_link(self, obj):
125
+ return get_history_link_html(obj)
126
+ history_link.short_description = "Historique"
127
+ """
128
+ from django.utils.html import format_html
129
+ url = get_object_history_url(obj)
130
+ count = ActionLog.objects.filter(
131
+ content_type=ContentType.objects.get_for_model(obj),
132
+ object_id=obj.pk
133
+ ).count()
134
+ if count > 0:
135
+ return format_html(
136
+ '<a href="{}" title="Voir l\'historique">'
137
+ '<i class="fas fa-history"></i> {} action(s)</a>',
138
+ url, count
139
+ )
140
+ return format_html('<span class="text-muted">Aucun historique</span>')
@@ -0,0 +1,58 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-app-logs
3
+ Version: 2.2.1
4
+ Summary: Logging django's actions
5
+ Author-email: Cissé Anzoumana <anzcisse1@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/mosco23/django-app-logs
8
+ Project-URL: Documentation, https://github.com/mosco23/django-app-logs#readme
9
+ Project-URL: Bug Tracker, https://github.com/mosco23/django-app-logs/issues
10
+ Keywords: django,logging,logger,access,django access log
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Framework :: Django
13
+ Classifier: Framework :: Django :: 3.2
14
+ Classifier: Framework :: Django :: 4.0
15
+ Classifier: Framework :: Django :: 4.1
16
+ Classifier: Framework :: Django :: 4.2
17
+ Classifier: Framework :: Django :: 5.0
18
+ Classifier: Intended Audience :: Developers
19
+ Classifier: Operating System :: OS Independent
20
+ Classifier: Programming Language :: Python :: 3
21
+ Classifier: Programming Language :: Python :: 3.8
22
+ Classifier: Programming Language :: Python :: 3.9
23
+ Classifier: Programming Language :: Python :: 3.10
24
+ Classifier: Programming Language :: Python :: 3.11
25
+ Classifier: Programming Language :: Python :: 3.12
26
+ Classifier: Programming Language :: Python :: 3.13
27
+ Classifier: Topic :: Internet :: WWW/HTTP
28
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
29
+ Requires-Python: >=3.8
30
+ Description-Content-Type: text/markdown
31
+ Requires-Dist: Django>=3.2
32
+ Provides-Extra: dev
33
+ Requires-Dist: black>=23.0.0; extra == "dev"
34
+ Requires-Dist: flake8>=6.0.0; extra == "dev"
35
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
36
+ Requires-Dist: pytest-django>=4.5.0; extra == "dev"
37
+
38
+ # Django Access Log
39
+
40
+
41
+ ## Add packge to INSTALLED_APPS
42
+
43
+ ```python
44
+
45
+ INSTALLED_APPS = [
46
+ ...
47
+ 'django_app_logs',
48
+ ]
49
+
50
+ ```
51
+
52
+ ## Make migrations
53
+
54
+ ```python
55
+
56
+ python manage.py migrate
57
+
58
+ ```
@@ -0,0 +1,18 @@
1
+ README.md
2
+ pyproject.toml
3
+ django_app_logs/__init__.py
4
+ django_app_logs/admin.py
5
+ django_app_logs/apps.py
6
+ django_app_logs/middleware.py
7
+ django_app_logs/models.py
8
+ django_app_logs/registry.py
9
+ django_app_logs/serializers.py
10
+ django_app_logs/signals.py
11
+ django_app_logs/utils.py
12
+ django_app_logs.egg-info/PKG-INFO
13
+ django_app_logs.egg-info/SOURCES.txt
14
+ django_app_logs.egg-info/dependency_links.txt
15
+ django_app_logs.egg-info/requires.txt
16
+ django_app_logs.egg-info/top_level.txt
17
+ django_app_logs/migrations/0001_initial.py
18
+ django_app_logs/migrations/__init__.py
@@ -0,0 +1,7 @@
1
+ Django>=3.2
2
+
3
+ [dev]
4
+ black>=23.0.0
5
+ flake8>=6.0.0
6
+ pytest>=7.0.0
7
+ pytest-django>=4.5.0
@@ -0,0 +1 @@
1
+ django_app_logs
@@ -0,0 +1,57 @@
1
+ [build-system]
2
+ requires = ["setuptools>=45", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "django-app-logs"
7
+ version = "2.2.1"
8
+ description = "Logging django's actions"
9
+ authors = [{name = "Cissé Anzoumana", email = "anzcisse1@gmail.com"}]
10
+ readme = "README.md"
11
+ license = "MIT"
12
+ requires-python = ">=3.8"
13
+ keywords = ["django", "logging", "logger", "access", "django access log"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Framework :: Django",
17
+ "Framework :: Django :: 3.2",
18
+ "Framework :: Django :: 4.0",
19
+ "Framework :: Django :: 4.1",
20
+ "Framework :: Django :: 4.2",
21
+ "Framework :: Django :: 5.0",
22
+ "Intended Audience :: Developers",
23
+ "Operating System :: OS Independent",
24
+ "Programming Language :: Python :: 3",
25
+ "Programming Language :: Python :: 3.8",
26
+ "Programming Language :: Python :: 3.9",
27
+ "Programming Language :: Python :: 3.10",
28
+ "Programming Language :: Python :: 3.11",
29
+ "Programming Language :: Python :: 3.12",
30
+ "Programming Language :: Python :: 3.13",
31
+ "Topic :: Internet :: WWW/HTTP",
32
+ "Topic :: Software Development :: Libraries :: Python Modules",
33
+ ]
34
+
35
+ dependencies = [
36
+ "Django>=3.2"
37
+ ]
38
+
39
+ [project.optional-dependencies]
40
+ dev = [
41
+ "black>=23.0.0",
42
+ "flake8>=6.0.0",
43
+ "pytest>=7.0.0",
44
+ "pytest-django>=4.5.0",
45
+ ]
46
+
47
+ [project.urls]
48
+ Homepage = "https://github.com/mosco23/django-app-logs"
49
+ Documentation = "https://github.com/mosco23/django-app-logs#readme"
50
+ "Bug Tracker" = "https://github.com/mosco23/django-app-logs/issues"
51
+
52
+ [tool.setuptools]
53
+ include-package-data = true
54
+
55
+ [tool.setuptools.packages.find]
56
+ where = ["."]
57
+ include = ["django_app_logs", "django_app_logs.*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+