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.
- django_app_logs-2.2.1/PKG-INFO +58 -0
- django_app_logs-2.2.1/README.md +21 -0
- django_app_logs-2.2.1/django_app_logs/__init__.py +0 -0
- django_app_logs-2.2.1/django_app_logs/admin.py +188 -0
- django_app_logs-2.2.1/django_app_logs/apps.py +15 -0
- django_app_logs-2.2.1/django_app_logs/middleware.py +51 -0
- django_app_logs-2.2.1/django_app_logs/migrations/0001_initial.py +39 -0
- django_app_logs-2.2.1/django_app_logs/migrations/__init__.py +0 -0
- django_app_logs-2.2.1/django_app_logs/models.py +89 -0
- django_app_logs-2.2.1/django_app_logs/registry.py +43 -0
- django_app_logs-2.2.1/django_app_logs/serializers.py +121 -0
- django_app_logs-2.2.1/django_app_logs/signals.py +175 -0
- django_app_logs-2.2.1/django_app_logs/utils.py +140 -0
- django_app_logs-2.2.1/django_app_logs.egg-info/PKG-INFO +58 -0
- django_app_logs-2.2.1/django_app_logs.egg-info/SOURCES.txt +18 -0
- django_app_logs-2.2.1/django_app_logs.egg-info/dependency_links.txt +1 -0
- django_app_logs-2.2.1/django_app_logs.egg-info/requires.txt +7 -0
- django_app_logs-2.2.1/django_app_logs.egg-info/top_level.txt +1 -0
- django_app_logs-2.2.1/pyproject.toml +57 -0
- django_app_logs-2.2.1/setup.cfg +4 -0
|
@@ -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
|
+
```
|
|
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
|
+
]
|
|
File without changes
|
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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.*"]
|