├── .coveragerc
├── .github
└── workflows
│ └── python-test.yml
├── .gitignore
├── CHANGELOG.md
├── LICENSE
├── MANIFEST.in
├── README.md
├── automated_logging
├── __init__.py
├── admin
│ ├── __init__.py
│ ├── base.py
│ ├── model_entry.py
│ ├── model_event.py
│ ├── model_mirror.py
│ ├── request_event.py
│ └── unspecified_event.py
├── apps.py
├── decorators.py
├── handlers.py
├── helpers
│ ├── __init__.py
│ ├── enums.py
│ ├── exceptions.py
│ └── schemas.py
├── middleware.py
├── migrations
│ ├── 0001_initial.py
│ ├── 0002_auto_20180215_1540.py
│ ├── 0003_auto_20180216_0900.py
│ ├── 0004_auto_20180216_0935.py
│ ├── 0005_auto_20180216_0941.py
│ ├── 0006_auto_20180216_1004.py
│ ├── 0007_auto_20180216_1005.py
│ ├── 0008_auto_20180216_1005.py
│ ├── 0009_auto_20180216_1006.py
│ ├── 0010_auto_20180216_1430.py
│ ├── 0011_auto_20180216_1545.py
│ ├── 0012_auto_20180218_1101.py
│ ├── 0013_auto_20180218_1106.py
│ ├── 0014_auto_20180219_0859.py
│ ├── 0015_auto_20181229_2323.py
│ ├── 0016_auto_20200803_1917.py
│ ├── 0017_auto_20200819_1004.py
│ ├── 0018_decoratoroverrideexclusiontest_foreignkeytest_fullclassbasedexclusiontest_fulldecoratorbasedexclusio.py
│ └── __init__.py
├── models.py
├── settings.py
├── signals
│ ├── __init__.py
│ ├── m2m.py
│ ├── request.py
│ └── save.py
├── templates
│ └── dal
│ │ └── admin
│ │ └── view.html
├── tests
│ ├── __init__.py
│ ├── base.py
│ ├── helpers.py
│ ├── models.py
│ ├── test_exclusion.py
│ ├── test_handler.py
│ ├── test_m2m.py
│ ├── test_misc.py
│ ├── test_request.py
│ └── test_save.py
├── urls.py
└── views.py
├── package-lock.json
├── poetry.toml
├── pyproject.toml
└── tests
├── __init__.py
├── run.py
├── settings.py
└── urls.py
/.coveragerc:
--------------------------------------------------------------------------------
1 | [run]
2 | source = automated_logging
3 | branch = 1
4 |
5 | [xml]
6 | output = coverage.xml
7 |
8 | [report]
9 | omit = *tests*,*migrations*,*admin*,*models.py,*apps.py
10 |
--------------------------------------------------------------------------------
/.github/workflows/python-test.yml:
--------------------------------------------------------------------------------
1 | name: Python Test
2 | on: [push, pull_request]
3 |
4 | jobs:
5 | ci:
6 | runs-on: ubuntu-latest
7 | strategy:
8 | fail-fast: false
9 | matrix:
10 | python-version: ["3.10", "3.11", "3.12"]
11 | django-version: ["3.2", "4.0", "4.1", "4.2", "5.0"]
12 |
13 | steps:
14 | - uses: actions/checkout@v4
15 | - name: Install Python
16 | uses: actions/setup-python@v4
17 | with:
18 | python-version: ${{ matrix.python-version }}
19 | - name: Install poetry
20 | uses: abatilo/actions-poetry@v2
21 | - name: Setup a local virtual environment (if no poetry.toml file)
22 | run: |
23 | poetry config virtualenvs.create true --local
24 | poetry config virtualenvs.in-project true --local
25 | - uses: actions/cache@v3
26 | name: Define a cache for the virtual environment based on the dependencies lock file
27 | with:
28 | path: ./.venv
29 | key: venv-${{ hashFiles('poetry.lock') }}
30 | - name: Install the project dependencies
31 | run: poetry install
32 | - name: Overwrite the required Django version
33 | run: poetry add "django~${{ matrix.django-version }}"
34 | - name: Run the automated tests
35 | run: poetry run python tests/run.py
36 | - name: Upload coverage to coveralls
37 | run: poetry run coveralls
38 | env:
39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
40 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | __pycache__/
2 | logtest/
3 | playground/
4 | *.pyc
5 | *.DS_Store
6 | .idea/
7 | frontenddev/
8 | *~
9 | build
10 | .coverage
11 | dist
12 | django_automated_logging.egg-info
13 | docs/_build
14 | logtest/db.sqlite3
15 | htmlcov
16 | .tox
17 | node_modules
18 | .venv/
19 | todo
20 | tmp/
21 | poetry.lock
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # 6.2.2
2 |
3 | * **Fixed:** `DAL_SKIP_CONVERION` would crash the migration if not set.
4 |
5 | # 6.2.1
6 |
7 | * **Added:** By setting `DAL_SKIP_CONVERSION=true` during migration you are now able to skip the conversion of logs
8 | between 5.x.x and 6.x.x.
9 | This will remove any previous logs.
10 |
11 | # 6.2.0
12 |
13 | * **Changed:** Dependencies have been upgraded and newer django versions have been added
14 | * **CI:** Travis CI has been replaced by GitHub Actions
15 |
16 | # 6.1.3
17 |
18 | * **Added** ip is now record (fixes #12)
19 | * **Fixed:** #9 and #11. Previously we would try to register everytime it was initialized, which would break admin (which
20 | does an isinstance check). The decorator now registers on a module - not thread - level. The hidden `__dal_register__`
21 | method is now attached, which will re-register if needed.
22 |
23 | # 6.1.2
24 |
25 | > Release was an error, did not do anything
26 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Bilal Mahmoud
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include LICENSE
2 | include README.rst
3 | include CONTRIBUTING.md
4 | recursive-include automated_logging/templates *
5 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Django Database Based Automated Logging
2 |
3 | [](https://pypi.python.org/pypi?name=django-automated-logging)
4 | [](https://pypi.python.org/pypi?name=django-automated-logging)
5 | [](https://pypi.python.org/pypi?name=django-automated-logging)
6 | [](https://pypi.python.org/pypi?name=django-automated-logging)
7 | [](https://www.travis-ci.com/indietyp/django-automated-logging)
8 | [](https://coveralls.io/github/indietyp/django-automated-logging?branch=master)
9 | [](https://www.codacy.com/app/bilalmahmoud/django-automated-logging?utm_source=github.com&utm_medium=referral&utm_content=indietyp/django-automated-logging&utm_campaign=Badge_Grade)
10 | [](https://paypal.me/indietyp/5)
11 |
12 | **Notice:** Most of this will be moved into a wiki.
13 |
14 | ## What is Django-Automated-Logging (DAL)?
15 |
16 | TL;DR: DAL is a package to **automatically** track changes in your project, ranging
17 | from simple logging messages, to model changes or requests done by users.
18 |
19 | You can decide what you want to do and how.
20 | DAL allows fine-grained customization and filtering with various methods.
21 |
22 | ### Introduction
23 |
24 | Django Fully Automated Logging - **finally** solved and done properly.
25 |
26 | How to install?
27 | `pip install django-automated-logging` or `poetry add django-automated-logging`
28 |
29 | ### What is the purpose?
30 |
31 | The goal of DAL is to provide an easy, accessible and DRY way to log the inner working of you applications.
32 | Ultimately giving you the chance to easily see what is happening without excessive manual print/logging statements.
33 |
34 | The application uses minimal requirements and is performant.
35 |
36 | ### How does it work?
37 |
38 | The application facilitates the built-in logging mechanic
39 | by providing a custom handler, that just needs to be added to the `LOGGING` configuration.
40 |
41 | DAL uses native Django signals to know what is happening behind the scenes without injecting custom code.
42 |
43 | ### Minimal Setup
44 |
45 | You can also configure DAL to only log to a file and not to a database.
46 | You just need to enable DAL and not include the custom logging handler.
47 |
48 | ## Detailed Information
49 |
50 | ### Features
51 |
52 | 1. Easy Setup
53 | 2. Extensible
54 | 3. Feature-Rich
55 | 4. Completely Automated
56 | 5. Built-In Database Logger
57 | 6. No custom code needs to be inserted into your codebase
58 | 7. Can capture logging messages unrelated to the package itself
59 | 8. Only does what it needs to do, no extra bells and whistles.
60 |
61 | ### Setup
62 |
63 | Initial Configuration is via your projects `settings.py`
64 |
65 | 1. `INSTALLED_APPS` append: `'automated_logging'`
66 | 2. `MIDDLEWARE` append: `'automated_logging.middleware.AutomatedLoggingMiddleware'`
67 | 3. `LOGGING` section `handlers` add:
68 | ```python
69 | 'db': {
70 | 'level': 'INFO',
71 | 'class': 'automated_logging.handlers.DatabaseHandler',
72 | }
73 | ```
74 | 4. `LOGGING` section `loggers` add: (only required if database logging desired)
75 | ```python
76 | 'automated_logging': {
77 | 'level': 'INFO',
78 | 'handlers': ['db'],
79 | 'propagate': True,
80 | },
81 | 'django': {
82 | 'level': 'INFO',
83 | 'handlers': ['db'],
84 | 'propagate': True,
85 | },
86 | ```
87 | 5. execute: `python manage.py migrate automated_logging`
88 |
89 | `LOGGING` configuration details are just recommendations.
90 |
91 | ### Migrations
92 |
93 | When migrating from 5.x.x to 6.x.x the logs are converted between the two versions.
94 | This can take a while, and depending on the size of your database might lead to `'Server has gone away’` errors on
95 | MySQL.
96 | You can increase the `max_allowed_packet` variable in your MySQL configuration to fix this, or
97 | set `DAL_SKIP_CONVERSION = true` as an environment variable to skip the conversion.
98 |
99 | ### Configuration
100 |
101 | Further configuration can be done via the variable `AUTOMATED_LOGGING`. The defaults are:
102 |
103 | ```python
104 | AUTOMATED_LOGGING = {
105 | "globals": {
106 | "exclude": {
107 | "applications": [
108 | "plain:contenttypes",
109 | "plain:admin",
110 | "plain:basehttp",
111 | "glob:session*",
112 | "plain:migrations",
113 | ]
114 | }
115 | },
116 | "model": {
117 | "detailed_message": True,
118 | "exclude": {"applications": [], "fields": [], "models": [], "unknown": False},
119 | "loglevel": 20,
120 | "mask": [],
121 | "max_age": None,
122 | "performance": False,
123 | "snapshot": False,
124 | "user_mirror": False,
125 | },
126 | "modules": ["request", "unspecified", "model"],
127 | "request": {
128 | "data": {
129 | "content_types": ["application/json"],
130 | "enabled": [],
131 | "ignore": [],
132 | "mask": ["password"],
133 | "query": False,
134 | },
135 | "exclude": {
136 | "applications": [],
137 | "methods": ["GET"],
138 | "status": [200],
139 | "unknown": False,
140 | },
141 | "ip": True,
142 | "loglevel": 20,
143 | "max_age": None,
144 | },
145 | "unspecified": {
146 | "exclude": {"applications": [], "files": [], "unknown": False},
147 | "loglevel": 20,
148 | "max_age": None,
149 | },
150 | }
151 | ```
152 |
153 | You can always inspect the current default configuration by doing:
154 |
155 | ```python
156 | from pprint import pprint
157 | from automated_logging.settings import default
158 | from automated_logging.helpers import namedtuple2dict
159 |
160 | pprint(namedtuple2dict(default))
161 | ```
162 |
163 | **Recommendation:** include the `globals` application defaults as those modules can be particularly verbose or be
164 | duplicates.
165 |
166 | There are *three* different independent modules available `request` (for request logging), `unspecified` (for general
167 | logging messages), and `models` (for model changes).
168 | They can be enabled and disabled by including them in the `modules` configuration.
169 |
170 | The `loglevel` setting indicates the severity for the logging messages sent from the module.
171 | `INFO (20)` or `DEBUG (10)` is the right call for most cases.
172 |
173 | *New in 6.x.x:* Saving can be batched via the `batch` setting for the handler.
174 |
175 | *New in 6.x.x:* Saving can be threaded by `thread: True` for the handler settings. **This is highly experimental**
176 |
177 | *New in 6.x.x:* every field in `exclude` can be either be a `glob` (prefixing the string with `gl:`), a `regex` (
178 | prefixing the string with `re:`) or plain (prefixing the string with `pl:`). The default is `glob`.
179 |
180 | ### Decorators
181 |
182 | You can explicitly exclude or include views/models, by using the new decorators.
183 |
184 | ```python
185 | from automated_logging.decorators import include_view, include_model, exclude_view, exclude_model
186 |
187 |
188 | @include_view(methods=None)
189 | @exclude_view(methods=[])
190 | def view(request):
191 | pass
192 |
193 |
194 | @include_model(operations=None, fields=None)
195 | @exclude_model(operations=[], fields=[])
196 | class ExampleModel:
197 | pass
198 | ```
199 |
200 | `include` *always* takes precedence over `exclude`, if you use multiple `include` or `exclude` instead of overwriting
201 | they will *update/extend* the previous definition.
202 |
203 | `operations` can be either `create`, `modify`, `delete`. `fields` is a list model specific fields to be
204 | included/excluded.
205 | `methods` is a list methods to be included/excluded.
206 |
207 | ### Class-Based Configuration
208 |
209 | Class-Based Configuration is done over a specific meta class `LoggingIgnore`. Decorators take precedence over
210 | class-based configuration, but class-based configuration takes precedence over AUTOMATED_LOGGING configuration.
211 |
212 | ```python
213 | class ExampleModel:
214 | class LoggingIgnore:
215 | complete = False
216 | fields = []
217 | operations = []
218 | ```
219 |
220 | as described above `operations` and `fields` work the same way. `complete = True` means that a model is excluded no
221 | matter what.
222 |
223 | ## Changelog
224 |
225 | ### Version 6.0.0
226 |
227 | - **Added:** ``batch`` settings to the handler
228 | - **Added:** decorators
229 | - **Added:** class-based configuration
230 | - **Added:** request and response bodies can now be saved
231 | - **Added:** regex, glob matching for settings
232 | - **Updated:** settings
233 | - **Updated:** models
234 | - **Updated:** to current django version (2.2, 3.0, 3.1)
235 | - **Updated:** DAL no longer stores internal information directly, but now has a custom _meta object injected.
236 | - **Updated:** project now uses black for formatting
237 | - **Updated:** internals were completely rewritten for greater maintainability and speed.
238 | - **Fixed:** https://github.com/indietyp/django-automated-logging/issues/1
239 | - **Fixed:** https://github.com/indietyp/django-automated-logging/issues/2
240 | - **Moved:** `max_age` is now part of the `settings.py` configuration.
241 |
242 | ### Version 5.0.0
243 |
244 | - **Added:** ``maxage`` handler setting to automatically remove database entries after a certain amount of time.
245 | - **Added:** query string in requests can now be enabled/disabled (are now disabled by default)
246 | - **Fixed:** Value and URI could be longer than 255 characters. DAL would throw an exception. This is fixed.
247 |
248 | ## Roadmap
249 |
250 | ### Version 6.1.x
251 |
252 | - [ ] archive options
253 | - [ ] decorators greater flexibility
254 | - [ ] wiki -> documentation
255 | - [ ] make django-ipware optional via extras
256 | - [ ] and more!
257 |
258 | ### Version 7.x.x
259 |
260 | - [ ] implementation of a git like versioning interface
261 |
262 | ### Version 8.x.x
263 |
264 | - [ ] temporary world domination
265 |
--------------------------------------------------------------------------------
/automated_logging/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | Django Automated Logging makes logging easy.
3 |
4 | Django Automated Logging (DAL) is a package with the purpose of
5 | making logging in django automated and easy.
6 | """
7 |
8 |
--------------------------------------------------------------------------------
/automated_logging/admin/__init__.py:
--------------------------------------------------------------------------------
1 | """ This is just a file that imports all admin interfaces defined in this package """
2 |
3 | from automated_logging.admin.model_entry import *
4 | from automated_logging.admin.model_event import *
5 | from automated_logging.admin.model_mirror import *
6 |
7 | from automated_logging.admin.request_event import *
8 |
9 | from automated_logging.admin.unspecified_event import *
10 |
--------------------------------------------------------------------------------
/automated_logging/admin/base.py:
--------------------------------------------------------------------------------
1 | from django.contrib.admin.options import BaseModelAdmin, ModelAdmin, TabularInline
2 | from django.contrib.admin.templatetags.admin_urls import admin_urlname
3 | from django.shortcuts import resolve_url
4 | from django.utils.html import format_html
5 | from django.utils.safestring import SafeText
6 |
7 | from automated_logging.models import BaseModel
8 |
9 |
10 | class MixinBase(BaseModelAdmin):
11 | """
12 | TabularInline and ModelAdmin readonly mixin have both the same methods and
13 | return the same, because of that fact we have a mixin base
14 | """
15 |
16 | def __init__(self, *args, **kwargs):
17 | super().__init__(*args, **kwargs)
18 |
19 | self.readonly_fields = [f.name for f in self.model._meta.get_fields()]
20 |
21 | def get_actions(self, request):
22 | """get_actions from ModelAdmin, but remove all write operations."""
23 | actions = super().get_actions(request)
24 | actions.pop("delete_selected", None)
25 |
26 | return actions
27 |
28 | def has_add_permission(self, request, instance=None):
29 | """no-one should have the ability to add something => r/o"""
30 | return False
31 |
32 | def has_delete_permission(self, request, instance=None):
33 | """no-one should have the ability to delete something => r/o"""
34 | return False
35 |
36 | def has_change_permission(self, request, instance=None):
37 | """no-one should have the ability to edit something => r/o"""
38 | return False
39 |
40 | def save_model(self, request, instance, form, change):
41 | """disable saving by doing nothing"""
42 | pass
43 |
44 | def delete_model(self, request, instance):
45 | """disable deleting by doing nothing"""
46 | pass
47 |
48 | def save_related(self, request, form, formsets, change):
49 | """we don't need to save related, because save_model does nothing"""
50 | pass
51 |
52 | # helpers
53 | def model_admin_url(self, instance: BaseModel, name: str = None) -> str:
54 | """Helper to return a URL to another object"""
55 | url = resolve_url(
56 | admin_urlname(instance._meta, SafeText("change")), instance.pk
57 | )
58 | return format_html('{}', url, name or str(instance))
59 |
60 |
61 | class ReadOnlyAdminMixin(MixinBase, ModelAdmin):
62 | """Disables all editing capabilities for the model admin"""
63 |
64 | change_form_template = "dal/admin/view.html"
65 |
66 |
67 | class ReadOnlyTabularInlineMixin(MixinBase, TabularInline):
68 | """Disables all editing capabilities for inline"""
69 |
70 | model = None
71 |
--------------------------------------------------------------------------------
/automated_logging/admin/model_entry.py:
--------------------------------------------------------------------------------
1 | """
2 | Everything related to the admin interface of ModelEntry is located in here
3 | """
4 |
5 | from django.contrib.admin import register
6 | from django.shortcuts import redirect
7 |
8 | from automated_logging.admin.model_event import ModelEventAdmin
9 | from automated_logging.admin.base import ReadOnlyAdminMixin, ReadOnlyTabularInlineMixin
10 | from automated_logging.models import ModelEntry, ModelEvent
11 |
12 |
13 | class ModelEventInline(ReadOnlyTabularInlineMixin):
14 | """inline for all attached events"""
15 |
16 | model = ModelEvent
17 |
18 | def __init__(self, *args, **kwargs):
19 | super(ModelEventInline, self).__init__(*args, **kwargs)
20 |
21 | self.readonly_fields = [*self.readonly_fields, "get_uuid", "get_modifications"]
22 |
23 | def get_uuid(self, instance):
24 | """make the uuid small"""
25 | return self.model_admin_url(instance, str(instance.id).split("-")[0])
26 |
27 | get_uuid.short_description = "UUID"
28 |
29 | def get_modifications(self, instance):
30 | """ModelEventAdmin already implements this functions, we just refer to it"""
31 | return ModelEventAdmin.get_modifications(self, instance)
32 |
33 | get_modifications.short_description = "Modifications"
34 |
35 | fields = ("get_uuid", "updated_at", "user", "get_modifications")
36 |
37 | ordering = ("-updated_at",)
38 |
39 | verbose_name = "Event"
40 | verbose_name_plural = "Events"
41 |
42 |
43 | @register(ModelEntry)
44 | class ModelEntryAdmin(ReadOnlyAdminMixin):
45 | """admin page specification for ModelEntry"""
46 |
47 | def __init__(self, *args, **kwargs):
48 | super().__init__(*args, **kwargs)
49 |
50 | self.readonly_fields = [*self.readonly_fields, "get_model", "get_application"]
51 |
52 | def changelist_view(self, request, **kwargs):
53 | """instead of showing the changelist view redirect to the parent app_list"""
54 | return redirect("admin:app_list", self.model._meta.app_label)
55 |
56 | def has_module_permission(self, request):
57 | """remove model entries from the index.html list"""
58 | return False
59 |
60 | def get_model(self, instance):
61 | """get the model mirror"""
62 | return self.model_admin_url(instance.mirror)
63 |
64 | get_model.short_description = "Model"
65 |
66 | def get_application(self, instance):
67 | """get the application"""
68 | return instance.mirror.application
69 |
70 | get_application.short_description = "Application"
71 |
72 | fieldsets = (
73 | (
74 | "Information",
75 | {"fields": ("id", "get_model", "get_application", "primary_key", "value")},
76 | ),
77 | )
78 |
79 | inlines = [ModelEventInline]
80 |
--------------------------------------------------------------------------------
/automated_logging/admin/model_event.py:
--------------------------------------------------------------------------------
1 | """
2 | Everything related to the admin interface of ModelEvent is located in here
3 | """
4 |
5 | from django.contrib.admin import register, RelatedOnlyFieldListFilter
6 | from django.utils.html import format_html
7 |
8 | from automated_logging.admin.base import ReadOnlyTabularInlineMixin, ReadOnlyAdminMixin
9 | from automated_logging.helpers import Operation
10 | from automated_logging.models import (
11 | ModelValueModification,
12 | ModelRelationshipModification,
13 | ModelEvent,
14 | )
15 |
16 |
17 | class ModelValueModificationInline(ReadOnlyTabularInlineMixin):
18 | """inline for all modifications"""
19 |
20 | model = ModelValueModification
21 |
22 | def __init__(self, *args, **kwargs):
23 | super().__init__(*args, **kwargs)
24 |
25 | self.readonly_fields = [*self.readonly_fields, "get_uuid", "get_field"]
26 |
27 | def get_uuid(self, instance):
28 | """make the uuid small"""
29 | return str(instance.id).split("-")[0]
30 |
31 | get_uuid.short_description = "UUID"
32 |
33 | def get_field(self, instance):
34 | """show the field name"""
35 | return instance.field.name
36 |
37 | get_field.short_description = "Field"
38 |
39 | fields = ("get_uuid", "operation", "get_field", "previous", "current")
40 | can_delete = False
41 |
42 | verbose_name = "Modification"
43 | verbose_name_plural = "Modifications"
44 |
45 |
46 | class ModelRelationshipModificationInline(ReadOnlyTabularInlineMixin):
47 | """inline for all relationship modifications"""
48 |
49 | model = ModelRelationshipModification
50 |
51 | def __init__(self, *args, **kwargs):
52 | super().__init__(*args, **kwargs)
53 |
54 | self.readonly_fields = [*self.readonly_fields, "get_uuid", "get_field"]
55 |
56 | def get_uuid(self, instance):
57 | """make the uuid small"""
58 | return str(instance.id).split("-")[0]
59 |
60 | get_uuid.short_description = "UUID"
61 |
62 | def get_field(self, instance):
63 | """show the field name"""
64 | return instance.field.name
65 |
66 | get_field.short_description = "Field"
67 |
68 | fields = ("get_uuid", "operation", "get_field", "entry")
69 | can_delete = False
70 |
71 | verbose_name = "Relationship"
72 | verbose_name_plural = "Relationships"
73 |
74 |
75 | @register(ModelEvent)
76 | class ModelEventAdmin(ReadOnlyAdminMixin):
77 | """admin page specification for ModelEvent"""
78 |
79 | def __init__(self, *args, **kwargs):
80 | super().__init__(*args, **kwargs)
81 |
82 | self.readonly_fields = [
83 | *self.readonly_fields,
84 | "get_application",
85 | "get_user",
86 | "get_model_link",
87 | ]
88 |
89 | def get_modifications(self, instance):
90 | """
91 | Modifications in short form, are colored for better readability.
92 |
93 | Colors taken from
94 | https://github.com/django/django/tree/master/django/contrib/admin/static/admin/img
95 | """
96 | colors = {
97 | Operation.CREATE: "#70bf2b",
98 | Operation.MODIFY: "#efb80b",
99 | Operation.DELETE: "#dd4646",
100 | }
101 | return format_html(
102 | ", ".join(
103 | [
104 | *[
105 | f''
106 | f"{m.short()}"
107 | f""
108 | for m in instance.modifications.all()
109 | ],
110 | *[
111 | f''
112 | f"{r.medium()[0]}"
113 | f"[{r.medium()[1]}]"
114 | for r in instance.relationships.all()
115 | ],
116 | ],
117 | )
118 | )
119 |
120 | get_modifications.short_description = "Modifications"
121 |
122 | def get_model(self, instance):
123 | """
124 | get the model
125 | TODO: consider splitting this up to model/pk/value
126 | """
127 | return instance.entry.short()
128 |
129 | get_model.short_description = "Model"
130 |
131 | def get_model_link(self, instance):
132 | """get the model with a link to the entry"""
133 | return self.model_admin_url(instance.entry)
134 |
135 | get_model_link.short_description = "Model"
136 |
137 | def get_application(self, instance):
138 | """
139 | helper to get the application from the child ModelMirror
140 | :param instance:
141 | :return:
142 | """
143 | return instance.entry.mirror.application
144 |
145 | get_application.short_description = "Application"
146 |
147 | def get_id(self, instance):
148 | """shorten the id to the first 8 digits"""
149 | return str(instance.id).split("-")[0]
150 |
151 | get_id.short_description = "UUID"
152 |
153 | def get_user(self, instance):
154 | """return the user with a link"""
155 | return self.model_admin_url(instance.user) if instance.user else None
156 |
157 | get_user.short_description = "User"
158 |
159 | list_display = (
160 | "get_id",
161 | "updated_at",
162 | "user",
163 | "get_application",
164 | "get_model",
165 | "get_modifications",
166 | )
167 |
168 | list_filter = (
169 | "updated_at",
170 | ("user", RelatedOnlyFieldListFilter),
171 | ("entry__mirror__application", RelatedOnlyFieldListFilter),
172 | ("entry__mirror", RelatedOnlyFieldListFilter),
173 | )
174 |
175 | date_hierarchy = "updated_at"
176 | ordering = ("-updated_at",)
177 |
178 | fieldsets = (
179 | (
180 | "Information",
181 | {
182 | "fields": (
183 | "id",
184 | "get_user",
185 | "updated_at",
186 | "get_application",
187 | "get_model_link",
188 | )
189 | },
190 | ),
191 | ("Introspection", {"fields": ("performance", "snapshot")}),
192 | )
193 | inlines = [ModelValueModificationInline, ModelRelationshipModificationInline]
194 |
195 | show_change_link = True
196 |
--------------------------------------------------------------------------------
/automated_logging/admin/model_mirror.py:
--------------------------------------------------------------------------------
1 | """
2 | Everything related to the admin interface of ModelMirror is located in here
3 | """
4 |
5 | from django.contrib.admin import register
6 | from django.shortcuts import redirect
7 |
8 | from automated_logging.admin.base import ReadOnlyAdminMixin, ReadOnlyTabularInlineMixin
9 | from automated_logging.models import ModelMirror, ModelField
10 |
11 |
12 | class ModelFieldInline(ReadOnlyTabularInlineMixin):
13 | """list all recorded fields"""
14 |
15 | model = ModelField
16 |
17 | fields = ["name", "type"]
18 |
19 | verbose_name = "Recorded Field"
20 | verbose_name_plural = "Recorded Fields"
21 |
22 |
23 | @register(ModelMirror)
24 | class ModelMirrorAdmin(ReadOnlyAdminMixin):
25 | """admin page specification for ModelMirror"""
26 |
27 | def has_module_permission(self, request):
28 | """prevents this from showing up index.html"""
29 | return False
30 |
31 | def changelist_view(self, request, **kwargs):
32 | """instead of showing the changelist view redirect to the parent app_list"""
33 | return redirect("admin:app_list", self.model._meta.app_label)
34 |
35 | date_hierarchy = "updated_at"
36 | ordering = ("-updated_at",)
37 |
38 | fieldsets = (
39 | (
40 | "Information",
41 | {"fields": ("id", "application", "name")},
42 | ),
43 | )
44 |
45 | inlines = [ModelFieldInline]
46 |
--------------------------------------------------------------------------------
/automated_logging/admin/request_event.py:
--------------------------------------------------------------------------------
1 | """
2 | Everything related to the admin interface of RequestEvent is located in here
3 | """
4 |
5 | from django.contrib.admin import register, RelatedOnlyFieldListFilter
6 |
7 | from automated_logging.admin.base import ReadOnlyAdminMixin, ReadOnlyTabularInlineMixin
8 | from automated_logging.models import RequestEvent
9 |
10 |
11 | @register(RequestEvent)
12 | class RequestEventAdmin(ReadOnlyAdminMixin):
13 | """admin page specification for the RequestEvent"""
14 |
15 | def __init__(self, *args, **kwargs):
16 | super().__init__(*args, **kwargs)
17 |
18 | self.readonly_fields = [*self.readonly_fields, "get_uri", "get_user"]
19 |
20 | def get_id(self, instance):
21 | """shorten the id to the first 8 digits"""
22 | return str(instance.id).split("-")[0]
23 |
24 | get_id.short_description = "UUID"
25 |
26 | def get_uri(self, instance):
27 | """get the uri. just a redirect to set the short description."""
28 | return instance.uri
29 |
30 | get_uri.short_description = "URI"
31 |
32 | def get_user(self, instance):
33 | """get the user with a URL"""
34 | return self.model_admin_url(instance.user)
35 |
36 | get_user.short_description = "User"
37 |
38 | list_display = ("get_id", "updated_at", "user", "method", "status", "uri")
39 |
40 | date_hierarchy = "updated_at"
41 | ordering = ("-updated_at",)
42 |
43 | list_filter = ("updated_at", ("user", RelatedOnlyFieldListFilter))
44 |
45 | fieldsets = (
46 | (
47 | "Information",
48 | {
49 | "fields": (
50 | "id",
51 | "get_user",
52 | "updated_at",
53 | "application",
54 | )
55 | },
56 | ),
57 | ("HTTP", {"fields": ("method", "status", "get_uri")}),
58 | )
59 | # TODO: Context
60 |
--------------------------------------------------------------------------------
/automated_logging/admin/unspecified_event.py:
--------------------------------------------------------------------------------
1 | """
2 | Everything related to the admin interface of UnspecifiedEvent is located in here
3 | """
4 |
5 | from django.contrib.admin import register, RelatedOnlyFieldListFilter
6 |
7 | from automated_logging.admin.base import ReadOnlyAdminMixin, ReadOnlyTabularInlineMixin
8 | from automated_logging.models import UnspecifiedEvent
9 |
10 |
11 | @register(UnspecifiedEvent)
12 | class UnspecifiedEventAdmin(ReadOnlyAdminMixin):
13 | """admin page specification for the UnspecifiedEvent"""
14 |
15 | def __init__(self, *args, **kwargs):
16 | super().__init__(*args, **kwargs)
17 |
18 | def get_id(self, instance):
19 | """shorten the id to the first 8 digits"""
20 | return str(instance.id).split("-")[0]
21 |
22 | get_id.short_description = "UUID"
23 |
24 | list_display = ("get_id", "updated_at", "level", "message")
25 |
26 | date_hierarchy = "updated_at"
27 | ordering = ("-updated_at",)
28 |
29 | list_filter = ("updated_at",)
30 |
31 | fieldsets = (
32 | (
33 | "Information",
34 | {"fields": ("id", "updated_at", "application", "level", "message")},
35 | ),
36 | ("Location", {"fields": ("file", "line")}),
37 | )
38 |
--------------------------------------------------------------------------------
/automated_logging/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 | from .settings import settings
3 |
4 |
5 | class AutomatedloggingConfig(AppConfig):
6 | name = "automated_logging"
7 | verbose_name = "Django Automated Logging (DAL)"
8 |
9 | def ready(self):
10 | if "request" in settings.modules:
11 | from .signals import request
12 | if "model" in settings.modules:
13 | from .signals import save
14 | from .signals import m2m
15 |
16 | from .handlers import DatabaseHandler
17 |
--------------------------------------------------------------------------------
/automated_logging/decorators.py:
--------------------------------------------------------------------------------
1 | from functools import wraps, partial
2 | from typing import List, NamedTuple, Set, Optional, Type, Dict, Callable, Union
3 |
4 |
5 | from automated_logging.helpers import (
6 | Operation,
7 | get_or_create_thread,
8 | function2path,
9 | )
10 | from automated_logging.helpers.enums import VerbOperationMap
11 |
12 |
13 | def _normalize_view_args(methods: List[str]) -> Set[str]:
14 | if methods is not None:
15 | methods = {m.upper() for m in methods}
16 |
17 | return methods
18 |
19 |
20 | # TODO: consider adding status_codes
21 | def exclude_view(func=None, *, methods: Optional[List[str]] = ()):
22 | """
23 | Decorator used for ignoring specific views, without adding them
24 | to the AUTOMATED_LOGGING configuration.
25 |
26 | This is done via the local threading object. This is done via the function
27 | name and module location.
28 |
29 | :param func: function to be decorated
30 | :param methods: methods to be ignored (case-insensitive),
31 | None => No method will be ignored,
32 | [] => All methods will be ignored
33 |
34 | :return: function
35 | """
36 | if func is None:
37 | return partial(exclude_view, methods=methods)
38 |
39 | methods = _normalize_view_args(methods)
40 |
41 | @wraps(func)
42 | def wrapper(*args, **kwargs):
43 | """simple wrapper"""
44 | thread, _ = get_or_create_thread()
45 |
46 | path = function2path(func)
47 | if (
48 | path in thread.dal["ignore.views"]
49 | and thread.dal["ignore.views"][path] is not None
50 | and methods is not None
51 | ):
52 | methods.update(thread.dal["ignore.views"][path])
53 |
54 | thread.dal["ignore.views"][path] = methods
55 |
56 | return func(*args, **kwargs)
57 |
58 | return wrapper
59 |
60 |
61 | def include_view(func=None, *, methods: List[str] = None):
62 | """
63 | Decorator used for including specific views **regardless** if they
64 | are included in one of the exclusion patterns, this can be selectively done
65 | via methods. Non matching methods will still go through the exclusion pattern
66 | matching.
67 |
68 | :param func: function to be decorated
69 | :param methods: methods to be included (case-insensitive)
70 | None => All methods will be explicitly included
71 | [] => No method will be explicitly included
72 | :return: function
73 | """
74 | if func is None:
75 | return partial(include_view, methods=methods)
76 |
77 | methods = _normalize_view_args(methods)
78 |
79 | @wraps(func)
80 | def wrapper(*args, **kwargs):
81 | """simple wrapper"""
82 | thread, _ = get_or_create_thread()
83 |
84 | path = function2path(func)
85 | if (
86 | path in thread.dal["include.views"]
87 | and thread.dal["include.views"][path] is not None
88 | and methods is not None
89 | ):
90 | methods.update(thread.dal["include.views"][path])
91 |
92 | thread.dal["include.views"][path] = methods
93 | return func(*args, **kwargs)
94 |
95 | return wrapper
96 |
97 |
98 | def _normalize_model_args(
99 | operations: List[str], fields: List[str]
100 | ) -> [Set[Operation], Set[str]]:
101 | if operations is not None and not all(
102 | isinstance(op, Operation) for op in operations
103 | ):
104 | operations = {
105 | VerbOperationMap[o.lower()]
106 | for o in operations
107 | if o.lower() in VerbOperationMap.keys()
108 | }
109 |
110 | if fields is not None:
111 | fields = set(fields)
112 |
113 | return operations, fields
114 |
115 |
116 | _include_models = {}
117 | _exclude_models = {}
118 |
119 | IgnoreModel = NamedTuple(
120 | "IgnoreModel", (("operations", Set[Operation]), ("fields", Set[str]))
121 | )
122 |
123 |
124 | def _register_model(
125 | registry: Dict[str, Union["IgnoreModel", "IncludeModel"]],
126 | container: Type[Union["IgnoreModel", "IncludeModel"]],
127 | decorator: Callable,
128 | model=None,
129 | operations: List[str] = None,
130 | fields: List[str] = None,
131 | ):
132 | if model is None:
133 | return partial(decorator, operations=operations, fields=fields)
134 |
135 | operations, fields = _normalize_model_args(operations, fields)
136 | path = function2path(model)
137 |
138 | if (
139 | path in registry
140 | and registry[path].operations is not None
141 | and operations is not None
142 | ):
143 | operations.update(registry[path].operations)
144 |
145 | if path in registry and registry[path].fields is not None and fields is not None:
146 | fields.update(registry[path].fields)
147 |
148 | registry[path] = container(operations, fields)
149 |
150 | # this makes it so that we have a method we can call to re apply dal.
151 | model.__dal_register__ = lambda: _register_model(
152 | registry, container, decorator, model, operations, fields
153 | )
154 | return model
155 |
156 |
157 | def exclude_model(
158 | model=None, *, operations: Optional[List[str]] = (), fields: List[str] = ()
159 | ):
160 | """
161 | Decorator used for ignoring specific models, without using the
162 | class or AUTOMATED_LOGGING configuration
163 |
164 | This is done via the local threading object. __module__ and __name__ are used
165 | to determine the right model.
166 |
167 | :param model: function to be decorated
168 | :param operations: operations to be ignored can be a list of:
169 | modify, create, delete (case-insensitive)
170 | [] => All operations will be ignored
171 | None => No operation will be ignored
172 | :param fields: fields to be ignored in not ignored operations
173 | [] => All fields will be ignored
174 | None => No field will be ignored
175 | :return: function
176 | """
177 | global _exclude_models
178 |
179 | return _register_model(
180 | _exclude_models, IgnoreModel, exclude_model, model, operations, fields
181 | )
182 |
183 |
184 | IncludeModel = NamedTuple(
185 | "IncludeModel", (("operations", Set[Operation]), ("fields", Set[str]))
186 | )
187 |
188 |
189 | def include_model(
190 | model=None, *, operations: List[str] = None, fields: List[str] = None
191 | ):
192 | """
193 | Decorator used for including specific models, despite potentially being ignored
194 | by the exclusion preferences set in the configuration.
195 |
196 | :param model: function to be decorated
197 | :param operations: operations to be ignored can be a list of:
198 | modify, create, delete (case-insensitive)
199 | [] => No operation will be explicitly included
200 | None => All operations will be explicitly included
201 | :param fields: fields to be explicitly included
202 | [] => No fields will be explicitly included
203 | None => All fields will be explicitly included.
204 |
205 | :return: function
206 | """
207 |
208 | global _include_models
209 |
210 | return _register_model(
211 | _include_models, IncludeModel, include_model, model, operations, fields
212 | )
213 |
--------------------------------------------------------------------------------
/automated_logging/handlers.py:
--------------------------------------------------------------------------------
1 | import re
2 | from collections import OrderedDict
3 | from datetime import timedelta
4 | from logging import Handler, LogRecord
5 | from pathlib import Path
6 | from threading import Thread
7 | from typing import Dict, Any, TYPE_CHECKING, List, Optional, Union, Type, Tuple
8 |
9 | from django.core.exceptions import ObjectDoesNotExist
10 | from django.utils import timezone
11 | from django.db.models import ForeignObject, Model
12 |
13 |
14 | if TYPE_CHECKING:
15 | # we need to do this, to avoid circular imports
16 | from automated_logging.models import (
17 | RequestEvent,
18 | ModelEvent,
19 | ModelValueModification,
20 | ModelRelationshipModification,
21 | )
22 |
23 |
24 | class DatabaseHandler(Handler):
25 | def __init__(
26 | self, *args, batch: Optional[int] = 1, threading: bool = False, **kwargs
27 | ):
28 | self.limit = batch or 1
29 | self.threading = threading
30 | self.instances = OrderedDict()
31 | super(DatabaseHandler, self).__init__(*args, **kwargs)
32 |
33 | @staticmethod
34 | def _clear(config):
35 | from automated_logging.models import ModelEvent, RequestEvent, UnspecifiedEvent
36 | from django.db import transaction
37 |
38 | current = timezone.now()
39 | with transaction.atomic():
40 | if config.model.max_age:
41 | ModelEvent.objects.filter(
42 | created_at__lte=current - config.model.max_age
43 | ).delete()
44 | if config.unspecified.max_age:
45 | UnspecifiedEvent.objects.filter(
46 | created_at__lte=current - config.unspecified.max_age
47 | ).delete()
48 |
49 | if config.request.max_age:
50 | RequestEvent.objects.filter(
51 | created_at__lte=current - config.request.max_age
52 | ).delete()
53 |
54 | def save(self, instance=None, commit=True, clear=True):
55 | """
56 | Internal save procedure.
57 | Handles deletion when an event exceeds max_age
58 | and batch saving via atomic transactions.
59 |
60 | :return: None
61 | """
62 | from django.db import transaction
63 | from automated_logging.settings import settings
64 |
65 | if instance:
66 | self.instances[instance.pk] = instance
67 | if len(self.instances) < self.limit:
68 | if clear:
69 | self._clear(settings)
70 | return instance
71 |
72 | if not commit:
73 | return instance
74 |
75 | def database(instances, config):
76 | """wrapper so that we can actually use threading"""
77 | with transaction.atomic():
78 | [i.save() for k, i in instances.items()]
79 |
80 | if clear:
81 | self._clear(config)
82 | instances.clear()
83 |
84 | if self.threading:
85 | thread = Thread(
86 | group=None, target=database, args=(self.instances, settings)
87 | )
88 | thread.start()
89 | else:
90 | database(self.instances, settings)
91 |
92 | return instance
93 |
94 | def get_or_create(self, target: Type[Model], **kwargs) -> Tuple[Model, bool]:
95 | """
96 | proxy for "get_or_create" from django,
97 | instead of creating it immediately we
98 | dd it to the list of objects to be created in a single swoop
99 |
100 | :type target: Model to be get_or_create
101 | :type kwargs: properties to be used to find and create the new object
102 | """
103 | created = False
104 | try:
105 | instance = target.objects.get(**kwargs)
106 | except ObjectDoesNotExist:
107 | instance = target(**kwargs)
108 | self.save(instance, commit=False, clear=False)
109 | created = True
110 |
111 | return instance, created
112 |
113 | def prepare_save(self, instance: Model):
114 | """
115 | Due to the nature of all modifications and such there are some models
116 | that are in nature get_or_create and not creations
117 | (we don't want so much additional data)
118 |
119 | This is a recursive function that looks for relationships and
120 | replaces specific values with their get_or_create counterparts.
121 |
122 | :param instance: model
123 | :return: instance that is suitable for saving
124 | """
125 | from automated_logging.models import (
126 | Application,
127 | ModelMirror,
128 | ModelField,
129 | ModelEntry,
130 | )
131 |
132 | if isinstance(instance, Application):
133 | return Application.objects.get_or_create(name=instance.name)[0]
134 | elif isinstance(instance, ModelMirror):
135 | return self.get_or_create(
136 | ModelMirror,
137 | name=instance.name,
138 | application=self.prepare_save(instance.application),
139 | )[0]
140 | elif isinstance(instance, ModelField):
141 | entry, _ = self.get_or_create(
142 | ModelField,
143 | name=instance.name,
144 | mirror=self.prepare_save(instance.mirror),
145 | )
146 | if entry.type != instance.type:
147 | entry.type = instance.type
148 | self.save(entry, commit=False, clear=False)
149 | return entry
150 |
151 | elif isinstance(instance, ModelEntry):
152 | entry, _ = self.get_or_create(
153 | ModelEntry,
154 | mirror=self.prepare_save(instance.mirror),
155 | primary_key=instance.primary_key,
156 | )
157 | if entry.value != instance.value:
158 | entry.value = instance.value
159 | self.save(entry, commit=False, clear=False)
160 | return entry
161 |
162 | # ForeignObjectRel is untouched rn
163 | for field in [
164 | f
165 | for f in instance._meta.get_fields()
166 | if isinstance(f, ForeignObject)
167 | and getattr(instance, f.name, None) is not None
168 | # check the attribute module really being automated_logging
169 | # to make sure that we do not follow down a rabbit hole
170 | and getattr(instance, f.name).__class__.__module__.split(".", 1)[0]
171 | == "automated_logging"
172 | ]:
173 | setattr(
174 | instance, field.name, self.prepare_save(getattr(instance, field.name))
175 | )
176 |
177 | self.save(instance, commit=False, clear=False)
178 | return instance
179 |
180 | def unspecified(self, record: LogRecord) -> None:
181 | """
182 | This is for messages that are not sent from django-automated-logging.
183 | The option to still save these log messages is there. We create
184 | the event in the handler and then save them.
185 |
186 | :param record:
187 | :return:
188 | """
189 | from automated_logging.models import UnspecifiedEvent, Application
190 | from automated_logging.signals import unspecified_exclusion
191 | from django.apps import apps
192 |
193 | event = UnspecifiedEvent()
194 | if hasattr(record, "message"):
195 | event.message = record.message
196 | event.level = record.levelno
197 | event.line = record.lineno
198 | event.file = Path(record.pathname)
199 |
200 | # this is semi-reliable, but I am unsure of a better way to do this.
201 | applications = apps.app_configs.keys()
202 | path = Path(record.pathname)
203 | candidates = [p for p in path.parts if p in applications]
204 | if candidates:
205 | # use the last candidate (closest to file)
206 | event.application = Application(name=candidates[-1])
207 | elif record.module in applications:
208 | # if we cannot find the application, we use the module as application
209 | event.application = Application(name=record.module)
210 | else:
211 | # if we cannot determine the application from the application
212 | # or from the module we presume that the application is unknown
213 | event.application = Application(name=None)
214 |
215 | if not unspecified_exclusion(event):
216 | self.prepare_save(event)
217 | self.save(event)
218 |
219 | def model(
220 | self,
221 | record: LogRecord,
222 | event: "ModelEvent",
223 | modifications: List["ModelValueModification"],
224 | data: Dict[str, Any],
225 | ) -> None:
226 | """
227 | This is for model specific logging events.
228 | Compiles the information into an event and saves that event
229 | and all modifications done.
230 |
231 | :param event:
232 | :param modifications:
233 | :param record:
234 | :param data:
235 | :return:
236 | """
237 | self.prepare_save(event)
238 | self.save(event)
239 |
240 | for modification in modifications:
241 | modification.event = event
242 | self.prepare_save(modification)
243 | self.save()
244 |
245 | def m2m(
246 | self,
247 | record: LogRecord,
248 | event: "ModelEvent",
249 | relationships: List["ModelRelationshipModification"],
250 | data: Dict[str, Any],
251 | ) -> None:
252 | self.prepare_save(event)
253 | self.save(event)
254 |
255 | for relationship in relationships:
256 | relationship.event = event
257 | self.prepare_save(relationship)
258 | self.save(relationship)
259 |
260 | def request(self, record: LogRecord, event: "RequestEvent") -> None:
261 | """
262 | The request event already has a model prepared that we just
263 | need to prepare and save.
264 |
265 | :param record: LogRecord
266 | :param event: Event supplied via the LogRecord
267 | :return: nothing
268 | """
269 |
270 | self.prepare_save(event)
271 | self.save(event)
272 |
273 | def emit(self, record: LogRecord) -> None:
274 | """
275 | Emit function that gets triggered for every log message in scope.
276 |
277 | The record will be processed according to the action set.
278 | :param record:
279 | :return:
280 | """
281 | if not hasattr(record, "action"):
282 | return self.unspecified(record)
283 |
284 | if record.action == "model":
285 | return self.model(record, record.event, record.modifications, record.data)
286 | elif record.action == "model[m2m]":
287 | return self.m2m(record, record.event, record.relationships, record.data)
288 | elif record.action == "request":
289 | return self.request(record, record.event)
290 |
--------------------------------------------------------------------------------
/automated_logging/helpers/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | Helpers that are used throughout django-automated-logging
3 | """
4 |
5 | from datetime import datetime
6 | from typing import Any, Union, Dict, NamedTuple
7 |
8 | from automated_logging.helpers.enums import Operation
9 | from automated_logging.middleware import AutomatedLoggingMiddleware
10 |
11 |
12 | def namedtuple2dict(root: Union[NamedTuple, Dict]) -> dict:
13 | """
14 | transforms nested namedtuple into a dict
15 |
16 | :param root: namedtuple to convert
17 | :return: dictionary from namedtuple
18 | """
19 |
20 | output = {}
21 | if (
22 | isinstance(root, tuple)
23 | and hasattr(root, "_serialize")
24 | and callable(root._serialize)
25 | ):
26 | return root._serialize()
27 |
28 | root = root if isinstance(root, dict) else root._asdict()
29 |
30 | def eligible(x):
31 | """check if value x is eligible for recursion"""
32 | return isinstance(x, tuple) or isinstance(x, dict)
33 |
34 | for k, v in root.items():
35 | if isinstance(v, set) or isinstance(v, list):
36 | output[k] = [namedtuple2dict(i) if eligible(i) else i for i in v]
37 | else:
38 | output[k] = namedtuple2dict(v) if eligible(v) else v
39 |
40 | return output
41 |
42 |
43 | def get_or_create_meta(instance) -> [Any, bool]:
44 | """
45 | Simple helper function that creates the dal object
46 | in _meta.
47 |
48 | :param instance:
49 | :return:
50 | """
51 | return instance, get_or_create_local(instance._meta)
52 |
53 |
54 | def get_or_create_thread() -> [Any, bool]:
55 | """
56 | Get or create the local thread, will always return False as the thread
57 | won't be created, but the local dal object will.
58 |
59 | get_or_create to conform with the other functions.
60 |
61 | :return: thread, created dal object?
62 | """
63 | thread = AutomatedLoggingMiddleware.thread
64 |
65 | return (
66 | thread,
67 | get_or_create_local(
68 | thread,
69 | {
70 | "ignore.views": dict,
71 | "ignore.models": dict,
72 | "include.views": dict,
73 | "include.models": dict,
74 | },
75 | ),
76 | )
77 |
78 |
79 | def get_or_create_local(target: Any, defaults={}, key="dal") -> bool:
80 | """
81 | Get or create local storage DAL metadata container,
82 | where dal specific data is.
83 |
84 | :return: created?
85 | """
86 |
87 | if not hasattr(target, key):
88 | setattr(target, key, MetaDataContainer(defaults))
89 | return True
90 |
91 | return False
92 |
93 |
94 | def get_or_create_model_event(
95 | instance, operation: Operation, force=False, extra=False
96 | ) -> [Any, bool]:
97 | """
98 | Get or create the ModelEvent of an instance.
99 | This function will also populate the event with the current information.
100 |
101 | :param instance: instance to derive an event from
102 | :param operation: specified operation that is done
103 | :param force: force creation of new event?
104 | :param extra: extra information inserted?
105 | :return: [event, created?]
106 | """
107 | from automated_logging.models import (
108 | ModelEvent,
109 | ModelEntry,
110 | ModelMirror,
111 | Application,
112 | )
113 | from automated_logging.settings import settings
114 |
115 | get_or_create_meta(instance)
116 |
117 | if hasattr(instance._meta.dal, "event") and not force:
118 | return instance._meta.dal.event, False
119 |
120 | instance._meta.dal.event = None
121 |
122 | event = ModelEvent()
123 | event.user = AutomatedLoggingMiddleware.get_current_user()
124 |
125 | if settings.model.snapshot and extra:
126 | event.snapshot = instance
127 |
128 | if (
129 | settings.model.performance
130 | and hasattr(instance._meta.dal, "performance")
131 | and extra
132 | ):
133 | event.performance = datetime.now() - instance._meta.dal.performance
134 | instance._meta.dal.performance = None
135 |
136 | event.operation = operation
137 | event.entry = ModelEntry()
138 | event.entry.mirror = ModelMirror()
139 | event.entry.mirror.name = instance.__class__.__name__
140 | event.entry.mirror.application = Application(name=instance._meta.app_label)
141 | event.entry.value = repr(instance) or str(instance)
142 | event.entry.primary_key = instance.pk
143 |
144 | instance._meta.dal.event = event
145 |
146 | return instance._meta.dal.event, True
147 |
148 |
149 | def function2path(func):
150 | """simple helper function to return the module path of a function"""
151 | return f"{func.__module__}.{func.__name__}"
152 |
153 |
154 | class MetaDataContainer(dict):
155 | """
156 | MetaDataContainer is used to store DAL specific metadata
157 | in various places.
158 |
159 | Values can be retrieved via attribute or key retrieval.
160 |
161 | A dictionary with key attributes can be provided when __init__.
162 | The key should be the name of the item, the value should be a function
163 | that gets called when an item with that key does
164 | not exist gets accessed, to auto-initialize that key.
165 | """
166 |
167 | def __init__(self, defaults={}):
168 | super().__init__()
169 |
170 | self.auto = defaults
171 |
172 | def __getitem__(self, item):
173 | try:
174 | return super().__getitem__(item)
175 | except KeyError:
176 | if item in self.auto:
177 | self[item] = self.auto[item]()
178 | return self[item]
179 | else:
180 | raise KeyError
181 |
182 | def __getattr__(self, item):
183 | try:
184 | return self[item]
185 | except KeyError:
186 | raise AttributeError
187 |
188 | def __setattr__(self, key, value):
189 | self[key] = value
190 |
--------------------------------------------------------------------------------
/automated_logging/helpers/enums.py:
--------------------------------------------------------------------------------
1 | """ Various enums used in DAL """
2 |
3 | from enum import Enum
4 |
5 |
6 | class Operation(int, Enum):
7 | """
8 | Simple Enum that will be used across the code to
9 | indicate the current operation that happened.
10 |
11 | Due to the fact that enum support for django was
12 | only added in 3.0 we have DjangoOperations to convert
13 | it to the old django format.
14 | """
15 |
16 | CREATE = 1
17 | MODIFY = 0
18 | DELETE = -1
19 |
20 |
21 | DjangoOperations = [(e.value, o.lower()) for o, e in Operation.__members__.items()]
22 | VerbOperationMap = {
23 | "create": Operation.CREATE,
24 | "modify": Operation.MODIFY,
25 | "delete": Operation.DELETE,
26 | "add": Operation.CREATE,
27 | "remove": Operation.DELETE,
28 | }
29 | VerbM2MOperationMap = {
30 | "add": Operation.CREATE,
31 | "modify": Operation.MODIFY,
32 | "remove": Operation.DELETE,
33 | }
34 | PastOperationMap = {
35 | "created": Operation.CREATE,
36 | "modified": Operation.MODIFY,
37 | "deleted": Operation.DELETE,
38 | }
39 | PastM2MOperationMap = {
40 | "added": Operation.CREATE,
41 | "modified": Operation.MODIFY,
42 | "removed": Operation.DELETE,
43 | }
44 | ShortOperationMap = {
45 | "+": Operation.CREATE,
46 | "~": Operation.MODIFY,
47 | "-": Operation.DELETE,
48 | }
49 | TranslationOperationMap = {**VerbOperationMap, **PastOperationMap, **ShortOperationMap}
50 |
--------------------------------------------------------------------------------
/automated_logging/helpers/exceptions.py:
--------------------------------------------------------------------------------
1 | """ custom exceptions """
2 |
3 |
4 | class NoMatchFound(Exception):
5 | """error that indicates that no match has been found for a regex"""
6 |
7 | pass
8 |
9 |
10 | class CouldNotConvertError(Exception):
11 | """error that is thrown when no conversion could be done"""
12 |
13 | pass
14 |
--------------------------------------------------------------------------------
/automated_logging/helpers/schemas.py:
--------------------------------------------------------------------------------
1 | import re
2 | import typing
3 | from collections import namedtuple
4 | from datetime import timedelta
5 | from typing import NamedTuple
6 |
7 | from marshmallow import Schema, EXCLUDE, post_load
8 | from marshmallow.fields import List, String, Nested, TimeDelta
9 |
10 | from automated_logging.helpers.exceptions import NoMatchFound, CouldNotConvertError
11 |
12 | Search = NamedTuple("Search", (("type", str), ("value", str)))
13 | Search._serialize = lambda self: f"{self.type}:{self.value}"
14 |
15 |
16 | class Set(List):
17 | """
18 | This is like a list, just compiles down to a set when serializing.
19 | """
20 |
21 | def _serialize(
22 | self, value, attr, obj, **kwargs
23 | ) -> typing.Optional[typing.Set[typing.Any]]:
24 | return set(super(Set, self)._serialize(value, attr, obj, **kwargs))
25 |
26 | def _deserialize(self, value, attr, data, **kwargs) -> typing.Set[typing.Any]:
27 | return set(super(Set, self)._deserialize(value, attr, data, **kwargs))
28 |
29 |
30 | class LowerCaseString(String):
31 | """
32 | String that is always going to be serialized to a lowercase string,
33 | using `str.lower()`
34 | """
35 |
36 | def _deserialize(self, value, attr, data, **kwargs) -> str:
37 | output = super()._deserialize(value, attr, data, **kwargs)
38 |
39 | return output.lower()
40 |
41 |
42 | class Duration(TimeDelta):
43 | """TimeDelta derivative, with more input methods"""
44 |
45 | def _convert(
46 | self, target: typing.Union[int, str, timedelta, None]
47 | ) -> typing.Optional[timedelta]:
48 | if target is None:
49 | return None
50 |
51 | if isinstance(target, timedelta):
52 | return target
53 |
54 | if isinstance(target, int) or isinstance(target, float):
55 | return timedelta(seconds=target)
56 |
57 | if isinstance(target, str):
58 | REGEX = (
59 | r"^P(?!$)(\d+Y)?(\d+M)?(\d+W)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+S)?)?$"
60 | )
61 | match = re.match(REGEX, target, re.IGNORECASE)
62 | if not match:
63 | raise self.make_error("invalid") from NoMatchFound
64 |
65 | components = list(match.groups())
66 | # remove leading T capture - isn't used, by removing the 5th capture group
67 | components.pop(4)
68 |
69 | adjusted = {"days": 0, "seconds": 0}
70 | conversion = [
71 | ["days", 365], # year
72 | ["days", 30], # month
73 | ["days", 7], # week
74 | ["days", 1], # day
75 | ["seconds", 3600], # hour
76 | ["seconds", 60], # minute
77 | ["seconds", 1], # second
78 | ]
79 |
80 | for pointer in range(len(components)):
81 | if not components[pointer]:
82 | continue
83 | rate = conversion[pointer]
84 | native = int(re.findall(r"(\d+)", components[pointer])[0])
85 |
86 | adjusted[rate[0]] += native * rate[1]
87 |
88 | return timedelta(**adjusted)
89 |
90 | raise self.make_error("invalid") from CouldNotConvertError
91 |
92 | def _deserialize(self, value, attr, data, **kwargs) -> typing.Optional[timedelta]:
93 | try:
94 | output = self._convert(value)
95 | except OverflowError as error:
96 | raise self.make_error("invalid") from error
97 |
98 | return output
99 |
100 |
101 | class SearchString(String):
102 | """
103 | Used for:
104 | - ModelString
105 | - FieldString
106 | - ApplicationString
107 | - FileString
108 |
109 | SearchStrings are used for models, fields and applications.
110 | They can be either a glob (prefixed with either glob or gl),
111 | regex (prefixed with either regex or re)
112 | or plain (prefixed with plain or pl).
113 |
114 | All SearchStrings ignore the case of the raw string.
115 |
116 | format: :
117 | examples:
118 | - gl:app* (glob matching)
119 | - glob:app* (glob matching)
120 | - pl:app (exact matching)
121 | - plain:app (exact matching)
122 | - re:^app.*$ (regex matching)
123 | - regex:^app.*$ (regex matching)
124 | - :app* (glob matching)
125 | - app (glob matching)
126 | """
127 |
128 | def _deserialize(self, value, attr, data, **kwargs) -> Search:
129 | if isinstance(value, dict) and "type" in value and "value" in value:
130 | value = f'{value["type"]}:{value["value"]}'
131 |
132 | output = super()._deserialize(value, attr, data, **kwargs)
133 |
134 | match = re.match(r"^(\w*):(.*)$", output, re.IGNORECASE)
135 | if match:
136 | module = match.groups()[0].lower()
137 | match = match.groups()[1]
138 |
139 | if module.startswith("gl"):
140 | return Search("glob", match.lower())
141 | elif module.startswith("pl"):
142 | return Search("plain", match.lower())
143 | elif module.startswith("re"):
144 | # regex shouldn't be lowercase
145 | # we just ignore the case =
146 | return Search("regex", match)
147 |
148 | raise self.make_error("invalid") from NotImplementedError
149 |
150 | return Search("glob", output)
151 |
152 |
153 | class MissingNested(Nested):
154 | """
155 | Modified marshmallow Nested, that is defaulting missing to loading an empty
156 | schema, to populate it with data.
157 | """
158 |
159 | def __init__(self, *args, **kwargs):
160 | if "missing" not in kwargs:
161 | kwargs["missing"] = lambda: args[0]().load({})
162 |
163 | super().__init__(*args, **kwargs)
164 |
165 |
166 | class BaseSchema(Schema):
167 | """
168 | Modified marshmallow Schema, that is defaulting the unknown keyword to EXCLUDE,
169 | not RAISE (marshmallow default) and when loading converts the dict into a namedtuple.
170 | """
171 |
172 | def __init__(self, *args, **kwargs):
173 | if "unknown" not in kwargs:
174 | kwargs["unknown"] = EXCLUDE
175 |
176 | super().__init__(*args, **kwargs)
177 |
178 | @staticmethod
179 | def namedtuple_or(left: NamedTuple, right: NamedTuple):
180 | """
181 | __or__ implementation for the namedtuple
182 | """
183 | values = {}
184 |
185 | if not isinstance(left, tuple) or not isinstance(right, tuple):
186 | raise NotImplementedError
187 |
188 | for name in left._fields:
189 | field = getattr(left, name)
190 | values[name] = field
191 |
192 | if not hasattr(right, name):
193 | continue
194 |
195 | if isinstance(field, tuple) or isinstance(field, set):
196 | values[name] = field | getattr(right, name)
197 |
198 | return left._replace(**values)
199 |
200 | @staticmethod
201 | def namedtuple_factory(name, keys):
202 | """
203 | create the namedtuple from the name and keys to attach functions that are needed.
204 |
205 | Attaches:
206 | binary **or** operation to support globals propagation
207 | """
208 | Object = namedtuple(name, keys)
209 | Object.__or__ = BaseSchema.namedtuple_or
210 | return Object
211 |
212 | @post_load
213 | def make_namedtuple(self, data: typing.Dict, **kwargs):
214 | """
215 | converts the loaded data dict into a namedtuple
216 |
217 | :param data: loaded data
218 | :param kwargs: marshmallow kwargs
219 | :return: namedtuple
220 | """
221 | name = self.__class__.__name__.replace("Schema", "")
222 |
223 | Object = BaseSchema.namedtuple_factory(name, data.keys())
224 | return Object(**data)
225 |
--------------------------------------------------------------------------------
/automated_logging/middleware.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import threading
3 | from typing import NamedTuple, Optional, TYPE_CHECKING
4 |
5 | from django.http import HttpRequest, HttpResponse
6 |
7 | if TYPE_CHECKING:
8 | from django.contrib.auth.models import AbstractUser
9 |
10 | RequestInformation = NamedTuple(
11 | "RequestInformation",
12 | [
13 | ("request", HttpRequest),
14 | ("response", Optional[HttpResponse]),
15 | ("exception", Optional[Exception]),
16 | ],
17 | )
18 |
19 |
20 | class AutomatedLoggingMiddleware:
21 | """
22 | Middleware used by django-automated-logging
23 | to provide request specific data to the request signals via
24 | the local thread.
25 | """
26 |
27 | thread = threading.local()
28 |
29 | def __init__(self, get_response):
30 | self.get_response = get_response
31 |
32 | AutomatedLoggingMiddleware.thread.__dal__ = None
33 |
34 | @staticmethod
35 | def save(request, response=None, exception=None):
36 | """
37 | Helper middleware, that sadly needs to be present.
38 | the request_finished and request_started signals only
39 | expose the class, not the actual request and response.
40 |
41 | We save the request and response specific data in the thread.
42 |
43 | :param request: Django Request
44 | :param response: Optional Django Response
45 | :param exception: Optional Exception
46 | :return:
47 | """
48 |
49 | AutomatedLoggingMiddleware.thread.__dal__ = RequestInformation(
50 | request, response, exception
51 | )
52 |
53 | def __call__(self, request):
54 | """
55 | TODO: fix staticfiles has no environment?!
56 | it seems like middleware isn't getting called for serving the static files,
57 | this seems very odd.
58 |
59 | There are 2 different states, request object will be stored when available
60 | and response will only be available post get_response.
61 |
62 | :param request:
63 | :return:
64 | """
65 | self.save(request)
66 |
67 | response = self.get_response(request)
68 |
69 | self.save(request, response)
70 |
71 | return response
72 |
73 | def process_exception(self, request, exception):
74 | """
75 | Exception proceeds the same as __call__ and therefore should
76 | also save things in the local thread.
77 |
78 | :param request: Django Request
79 | :param exception: Thrown Exception
80 | :return: -
81 | """
82 | self.save(request, exception=exception)
83 |
84 | @staticmethod
85 | def cleanup():
86 | """
87 | Cleanup function, that should be called last. Overwrites the
88 | custom __dal__ object with None, to make sure the next request
89 | does not use the same object.
90 |
91 | :return: -
92 | """
93 | AutomatedLoggingMiddleware.thread.__dal__ = None
94 |
95 | @staticmethod
96 | def get_current_environ() -> Optional[RequestInformation]:
97 | """
98 | Helper staticmethod that looks if the __dal__ custom attribute
99 | is present and returns either the attribute or None
100 |
101 | :return: Optional[RequestInformation]
102 | """
103 |
104 | if getattr(AutomatedLoggingMiddleware.thread, "__dal__", None):
105 | return RequestInformation(*AutomatedLoggingMiddleware.thread.__dal__)
106 |
107 | return None
108 |
109 | @staticmethod
110 | def get_current_user(
111 | environ: RequestInformation = None,
112 | ) -> Optional["AbstractUser"]:
113 | """
114 | Helper staticmethod that returns the current user, taken from
115 | the current environment.
116 |
117 | :return: Optional[User]
118 | """
119 | from django.contrib.auth.models import AnonymousUser
120 |
121 | if not environ:
122 | environ = AutomatedLoggingMiddleware.get_current_environ()
123 |
124 | if not environ:
125 | return None
126 |
127 | if isinstance(environ.request.user, AnonymousUser):
128 | return None
129 |
130 | return environ.request.user
131 |
--------------------------------------------------------------------------------
/automated_logging/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Generated by Django 1.10.2 on 2018-02-15 14:22
3 | from __future__ import unicode_literals
4 |
5 | from django.conf import settings
6 | from django.db import migrations, models
7 | import django.db.models.deletion
8 | import uuid
9 |
10 |
11 | class Migration(migrations.Migration):
12 | initial = True
13 |
14 | dependencies = [
15 | migrations.swappable_dependency(settings.AUTH_USER_MODEL),
16 | ("contenttypes", "0002_remove_content_type_name"),
17 | ]
18 |
19 | operations = [
20 | migrations.CreateModel(
21 | name="LDAP",
22 | fields=[
23 | (
24 | "id",
25 | models.UUIDField(
26 | default=uuid.uuid4, primary_key=True, serialize=False
27 | ),
28 | ),
29 | ("created_at", models.DateTimeField(auto_now_add=True)),
30 | ("updated_at", models.DateTimeField(auto_now=True)),
31 | ("action", models.TextField()),
32 | ("succeeded", models.NullBooleanField()),
33 | ("errorMessage", models.TextField(blank=True, null=True)),
34 | ("basedn", models.TextField(blank=True, null=True)),
35 | ("entry", models.TextField(blank=True, null=True)),
36 | ("objectClass", models.TextField(blank=True, null=True)),
37 | ("cn", models.TextField(blank=True, null=True)),
38 | ("existing_members", models.TextField(blank=True, null=True)),
39 | ("data_members", models.TextField(blank=True, null=True)),
40 | ("diff_members", models.TextField(blank=True, null=True)),
41 | ],
42 | options={
43 | "verbose_name": "LDAP event log entry",
44 | "verbose_name_plural": "LDAP event log entries",
45 | },
46 | ),
47 | migrations.CreateModel(
48 | name="Model",
49 | fields=[
50 | (
51 | "id",
52 | models.UUIDField(
53 | default=uuid.uuid4, primary_key=True, serialize=False
54 | ),
55 | ),
56 | ("created_at", models.DateTimeField(auto_now_add=True)),
57 | ("updated_at", models.DateTimeField(auto_now=True)),
58 | ("message", models.TextField(null=True)),
59 | (
60 | "action",
61 | models.PositiveSmallIntegerField(
62 | choices=[(0, "n/a"), (1, "add"), (2, "change"), (3, "delete")],
63 | default=0,
64 | ),
65 | ),
66 | (
67 | "application",
68 | models.ForeignKey(
69 | on_delete=django.db.models.deletion.CASCADE,
70 | related_name="atl_model_application",
71 | to="contenttypes.ContentType",
72 | ),
73 | ),
74 | ],
75 | options={
76 | "verbose_name": "model log entry",
77 | "verbose_name_plural": "model log entries",
78 | },
79 | ),
80 | migrations.CreateModel(
81 | name="ModelChangelog",
82 | fields=[
83 | (
84 | "id",
85 | models.UUIDField(
86 | default=uuid.uuid4, primary_key=True, serialize=False
87 | ),
88 | ),
89 | ("created_at", models.DateTimeField(auto_now_add=True)),
90 | ("updated_at", models.DateTimeField(auto_now=True)),
91 | ],
92 | options={
93 | "verbose_name": "Model entry change",
94 | "verbose_name_plural": "Model entry changes",
95 | },
96 | ),
97 | migrations.CreateModel(
98 | name="ModelModification",
99 | fields=[
100 | (
101 | "id",
102 | models.UUIDField(
103 | default=uuid.uuid4, primary_key=True, serialize=False
104 | ),
105 | ),
106 | ("created_at", models.DateTimeField(auto_now_add=True)),
107 | ("updated_at", models.DateTimeField(auto_now=True)),
108 | ],
109 | options={
110 | "abstract": False,
111 | },
112 | ),
113 | migrations.CreateModel(
114 | name="ModelObject",
115 | fields=[
116 | (
117 | "id",
118 | models.UUIDField(
119 | default=uuid.uuid4, primary_key=True, serialize=False
120 | ),
121 | ),
122 | ("created_at", models.DateTimeField(auto_now_add=True)),
123 | ("updated_at", models.DateTimeField(auto_now=True)),
124 | ("value", models.CharField(max_length=255)),
125 | ("model", models.CharField(max_length=255)),
126 | (
127 | "application",
128 | models.ForeignKey(
129 | on_delete=django.db.models.deletion.CASCADE,
130 | related_name="atl_modelobject_application",
131 | to="contenttypes.ContentType",
132 | ),
133 | ),
134 | ],
135 | options={
136 | "abstract": False,
137 | },
138 | ),
139 | migrations.CreateModel(
140 | name="Request",
141 | fields=[
142 | (
143 | "id",
144 | models.UUIDField(
145 | default=uuid.uuid4, primary_key=True, serialize=False
146 | ),
147 | ),
148 | ("created_at", models.DateTimeField(auto_now_add=True)),
149 | ("updated_at", models.DateTimeField(auto_now=True)),
150 | ("url", models.URLField()),
151 | (
152 | "application",
153 | models.ForeignKey(
154 | on_delete=django.db.models.deletion.CASCADE,
155 | to="contenttypes.ContentType",
156 | ),
157 | ),
158 | (
159 | "user",
160 | models.ForeignKey(
161 | null=True,
162 | on_delete=django.db.models.deletion.CASCADE,
163 | to=settings.AUTH_USER_MODEL,
164 | ),
165 | ),
166 | ],
167 | options={
168 | "verbose_name": "request event entry",
169 | "verbose_name_plural": "request event entries",
170 | },
171 | ),
172 | migrations.CreateModel(
173 | name="Unspecified",
174 | fields=[
175 | (
176 | "id",
177 | models.UUIDField(
178 | default=uuid.uuid4, primary_key=True, serialize=False
179 | ),
180 | ),
181 | ("created_at", models.DateTimeField(auto_now_add=True)),
182 | ("updated_at", models.DateTimeField(auto_now=True)),
183 | ("message", models.TextField(null=True)),
184 | ("level", models.PositiveSmallIntegerField(default=20)),
185 | ("file", models.CharField(max_length=255, null=True)),
186 | ("line", models.PositiveIntegerField(null=True)),
187 | ],
188 | options={
189 | "verbose_name": " logging entry (Errors, Warnings, Info)",
190 | "verbose_name_plural": " logging entries (Errors, Warnings, Info)",
191 | },
192 | ),
193 | migrations.AddField(
194 | model_name="modelmodification",
195 | name="currently",
196 | field=models.ManyToManyField(
197 | related_name="changelog_current", to="automated_logging.ModelObject"
198 | ),
199 | ),
200 | migrations.AddField(
201 | model_name="modelmodification",
202 | name="previously",
203 | field=models.ManyToManyField(
204 | related_name="changelog_previous", to="automated_logging.ModelObject"
205 | ),
206 | ),
207 | migrations.AddField(
208 | model_name="modelchangelog",
209 | name="information",
210 | field=models.OneToOneField(
211 | on_delete=django.db.models.deletion.CASCADE,
212 | to="automated_logging.ModelObject",
213 | ),
214 | ),
215 | migrations.AddField(
216 | model_name="modelchangelog",
217 | name="inserted",
218 | field=models.ManyToManyField(
219 | related_name="changelog_inserted", to="automated_logging.ModelObject"
220 | ),
221 | ),
222 | migrations.AddField(
223 | model_name="modelchangelog",
224 | name="modification",
225 | field=models.OneToOneField(
226 | null=True,
227 | on_delete=django.db.models.deletion.CASCADE,
228 | to="automated_logging.ModelModification",
229 | ),
230 | ),
231 | migrations.AddField(
232 | model_name="modelchangelog",
233 | name="removed",
234 | field=models.ManyToManyField(
235 | related_name="changelog_removed", to="automated_logging.ModelObject"
236 | ),
237 | ),
238 | migrations.AddField(
239 | model_name="model",
240 | name="information",
241 | field=models.OneToOneField(
242 | on_delete=django.db.models.deletion.CASCADE,
243 | related_name="atl_model_information",
244 | to="automated_logging.ModelObject",
245 | ),
246 | ),
247 | migrations.AddField(
248 | model_name="model",
249 | name="modification",
250 | field=models.ForeignKey(
251 | null=True,
252 | on_delete=django.db.models.deletion.CASCADE,
253 | to="automated_logging.ModelChangelog",
254 | ),
255 | ),
256 | migrations.AddField(
257 | model_name="model",
258 | name="user",
259 | field=models.ForeignKey(
260 | null=True,
261 | on_delete=django.db.models.deletion.CASCADE,
262 | to=settings.AUTH_USER_MODEL,
263 | ),
264 | ),
265 | ]
266 |
--------------------------------------------------------------------------------
/automated_logging/migrations/0002_auto_20180215_1540.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Generated by Django 1.10.2 on 2018-02-15 15:40
3 | from __future__ import unicode_literals
4 |
5 | from django.db import migrations, models
6 | import django.db.models.deletion
7 | import uuid
8 |
9 |
10 | class Migration(migrations.Migration):
11 | dependencies = [
12 | ("automated_logging", "0001_initial"),
13 | ]
14 |
15 | operations = [
16 | migrations.CreateModel(
17 | name="Application",
18 | fields=[
19 | (
20 | "id",
21 | models.UUIDField(
22 | default=uuid.uuid4, primary_key=True, serialize=False
23 | ),
24 | ),
25 | ("created_at", models.DateTimeField(auto_now_add=True)),
26 | ("updated_at", models.DateTimeField(auto_now=True)),
27 | ("name", models.CharField(max_length=255)),
28 | ],
29 | options={
30 | "abstract": False,
31 | },
32 | ),
33 | migrations.RenameField(
34 | model_name="modelobject",
35 | old_name="application",
36 | new_name="type",
37 | ),
38 | migrations.RemoveField(
39 | model_name="modelobject",
40 | name="model",
41 | ),
42 | migrations.RemoveField(model_name="model", name="application"),
43 | migrations.AddField(
44 | model_name="model",
45 | name="application",
46 | field=models.ForeignKey(
47 | on_delete=django.db.models.deletion.CASCADE,
48 | related_name="atl_model_application",
49 | to="automated_logging.Application",
50 | ),
51 | ),
52 | migrations.RemoveField(model_name="request", name="application"),
53 | migrations.AddField(
54 | model_name="request",
55 | name="application",
56 | field=models.ForeignKey(
57 | on_delete=django.db.models.deletion.CASCADE,
58 | to="automated_logging.Application",
59 | ),
60 | ),
61 | ]
62 |
--------------------------------------------------------------------------------
/automated_logging/migrations/0003_auto_20180216_0900.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Generated by Django 1.10.2 on 2018-02-16 09:00
3 | from __future__ import unicode_literals
4 |
5 | from django.db import migrations, models
6 |
7 |
8 | class Migration(migrations.Migration):
9 | dependencies = [
10 | ("automated_logging", "0002_auto_20180215_1540"),
11 | ]
12 |
13 | operations = [
14 | migrations.AlterField(
15 | model_name="modelobject",
16 | name="value",
17 | field=models.CharField(max_length=255, null=True),
18 | ),
19 | ]
20 |
--------------------------------------------------------------------------------
/automated_logging/migrations/0004_auto_20180216_0935.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Generated by Django 1.10.2 on 2018-02-16 09:35
3 | from __future__ import unicode_literals
4 |
5 | from django.db import migrations, models
6 | import django.db.models.deletion
7 |
8 |
9 | class Migration(migrations.Migration):
10 | dependencies = [
11 | ("automated_logging", "0003_auto_20180216_0900"),
12 | ]
13 |
14 | operations = [
15 | migrations.AlterField(
16 | model_name="modelobject",
17 | name="type",
18 | field=models.ForeignKey(
19 | blank=True,
20 | null=True,
21 | on_delete=django.db.models.deletion.CASCADE,
22 | related_name="atl_modelobject_application",
23 | to="contenttypes.ContentType",
24 | ),
25 | ),
26 | ]
27 |
--------------------------------------------------------------------------------
/automated_logging/migrations/0005_auto_20180216_0941.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Generated by Django 1.10.2 on 2018-02-16 09:41
3 | from __future__ import unicode_literals
4 |
5 | from django.db import migrations, models
6 | import django.db.models.deletion
7 |
8 |
9 | class Migration(migrations.Migration):
10 | dependencies = [
11 | ("automated_logging", "0004_auto_20180216_0935"),
12 | ]
13 |
14 | operations = [
15 | migrations.AlterField(
16 | model_name="modelchangelog",
17 | name="information",
18 | field=models.OneToOneField(
19 | null=True,
20 | on_delete=django.db.models.deletion.CASCADE,
21 | to="automated_logging.ModelObject",
22 | ),
23 | ),
24 | ]
25 |
--------------------------------------------------------------------------------
/automated_logging/migrations/0006_auto_20180216_1004.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Generated by Django 1.10.2 on 2018-02-16 10:04
3 | from __future__ import unicode_literals
4 |
5 | from django.db import migrations, models
6 | import django.db.models.deletion
7 |
8 |
9 | class Migration(migrations.Migration):
10 | dependencies = [
11 | ("automated_logging", "0005_auto_20180216_0941"),
12 | ]
13 |
14 | operations = [
15 | migrations.AlterField(
16 | model_name="model",
17 | name="application",
18 | field=models.ForeignKey(
19 | null=True,
20 | on_delete=django.db.models.deletion.CASCADE,
21 | related_name="atl_model_application",
22 | to="automated_logging.Application",
23 | ),
24 | ),
25 | ]
26 |
--------------------------------------------------------------------------------
/automated_logging/migrations/0007_auto_20180216_1005.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Generated by Django 1.10.2 on 2018-02-16 10:05
3 | from __future__ import unicode_literals
4 |
5 | from django.db import migrations, models
6 | import django.db.models.deletion
7 |
8 |
9 | class Migration(migrations.Migration):
10 | dependencies = [
11 | ("automated_logging", "0006_auto_20180216_1004"),
12 | ]
13 |
14 | operations = [
15 | migrations.AlterField(
16 | model_name="request",
17 | name="application",
18 | field=models.ForeignKey(
19 | null=True,
20 | on_delete=django.db.models.deletion.CASCADE,
21 | to="automated_logging.Application",
22 | ),
23 | ),
24 | ]
25 |
--------------------------------------------------------------------------------
/automated_logging/migrations/0008_auto_20180216_1005.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Generated by Django 1.10.2 on 2018-02-16 10:05
3 | from __future__ import unicode_literals
4 |
5 | from django.db import migrations, models
6 | import django.db.models.deletion
7 |
8 |
9 | class Migration(migrations.Migration):
10 | dependencies = [
11 | ("automated_logging", "0007_auto_20180216_1005"),
12 | ]
13 |
14 | operations = [
15 | migrations.AlterField(
16 | model_name="model",
17 | name="application",
18 | field=models.ForeignKey(
19 | blank=True,
20 | null=True,
21 | on_delete=django.db.models.deletion.CASCADE,
22 | related_name="atl_model_application",
23 | to="automated_logging.Application",
24 | ),
25 | ),
26 | ]
27 |
--------------------------------------------------------------------------------
/automated_logging/migrations/0009_auto_20180216_1006.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Generated by Django 1.10.2 on 2018-02-16 10:06
3 | from __future__ import unicode_literals
4 |
5 | from django.db import migrations, models
6 | import django.db.models.deletion
7 |
8 |
9 | class Migration(migrations.Migration):
10 | dependencies = [
11 | ("automated_logging", "0008_auto_20180216_1005"),
12 | ]
13 |
14 | operations = [
15 | migrations.AlterField(
16 | model_name="model",
17 | name="application",
18 | field=models.ForeignKey(
19 | null=True,
20 | on_delete=django.db.models.deletion.CASCADE,
21 | related_name="atl_model_application",
22 | to="automated_logging.Application",
23 | ),
24 | ),
25 | migrations.AlterField(
26 | model_name="model",
27 | name="information",
28 | field=models.OneToOneField(
29 | null=True,
30 | on_delete=django.db.models.deletion.CASCADE,
31 | related_name="atl_model_information",
32 | to="automated_logging.ModelObject",
33 | ),
34 | ),
35 | ]
36 |
--------------------------------------------------------------------------------
/automated_logging/migrations/0010_auto_20180216_1430.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 2.0.2 on 2018-02-16 14:30
2 |
3 | from django.db import migrations, models
4 | import django.db.models.deletion
5 | import uuid
6 |
7 |
8 | class Migration(migrations.Migration):
9 | dependencies = [
10 | ("automated_logging", "0009_auto_20180216_1006"),
11 | ]
12 |
13 | operations = [
14 | migrations.CreateModel(
15 | name="Field",
16 | fields=[
17 | (
18 | "id",
19 | models.UUIDField(
20 | default=uuid.uuid4, primary_key=True, serialize=False
21 | ),
22 | ),
23 | ("created_at", models.DateTimeField(auto_now_add=True)),
24 | ("updated_at", models.DateTimeField(auto_now=True)),
25 | ("name", models.CharField(max_length=255)),
26 | ],
27 | options={
28 | "abstract": False,
29 | },
30 | ),
31 | migrations.CreateModel(
32 | name="ModelStorage",
33 | fields=[
34 | (
35 | "id",
36 | models.UUIDField(
37 | default=uuid.uuid4, primary_key=True, serialize=False
38 | ),
39 | ),
40 | ("created_at", models.DateTimeField(auto_now_add=True)),
41 | ("updated_at", models.DateTimeField(auto_now=True)),
42 | ("name", models.CharField(max_length=255)),
43 | ],
44 | options={
45 | "abstract": False,
46 | },
47 | ),
48 | migrations.AddField(
49 | model_name="field",
50 | name="model",
51 | field=models.ForeignKey(
52 | null=True,
53 | on_delete=django.db.models.deletion.CASCADE,
54 | to="automated_logging.ModelStorage",
55 | ),
56 | ),
57 | migrations.AddField(
58 | model_name="modelobject",
59 | name="field",
60 | field=models.ForeignKey(
61 | null=True,
62 | on_delete=django.db.models.deletion.CASCADE,
63 | to="automated_logging.Field",
64 | ),
65 | ),
66 | ]
67 |
--------------------------------------------------------------------------------
/automated_logging/migrations/0011_auto_20180216_1545.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 2.0.2 on 2018-02-16 15:45
2 |
3 | from django.db import migrations
4 |
5 |
6 | class Migration(migrations.Migration):
7 | dependencies = [
8 | ("automated_logging", "0010_auto_20180216_1430"),
9 | ]
10 |
11 | operations = [
12 | migrations.AlterModelOptions(
13 | name="model",
14 | options={"verbose_name": "Changelog", "verbose_name_plural": "Changelogs"},
15 | ),
16 | migrations.AlterModelOptions(
17 | name="modelchangelog",
18 | options={},
19 | ),
20 | migrations.AlterModelOptions(
21 | name="request",
22 | options={"verbose_name": "Request", "verbose_name_plural": "Requests"},
23 | ),
24 | migrations.AlterModelOptions(
25 | name="unspecified",
26 | options={
27 | "verbose_name": "Non DJL Message",
28 | "verbose_name_plural": "Non DJL Messages",
29 | },
30 | ),
31 | ]
32 |
--------------------------------------------------------------------------------
/automated_logging/migrations/0012_auto_20180218_1101.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 2.0.2 on 2018-02-18 11:01
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 | dependencies = [
8 | ("automated_logging", "0011_auto_20180216_1545"),
9 | ]
10 |
11 | operations = [
12 | migrations.AddField(
13 | model_name="request",
14 | name="method",
15 | field=models.CharField(default="GET", max_length=64),
16 | preserve_default=False,
17 | ),
18 | migrations.AlterField(
19 | model_name="request",
20 | name="url",
21 | field=models.CharField(max_length=255),
22 | ),
23 | ]
24 |
--------------------------------------------------------------------------------
/automated_logging/migrations/0013_auto_20180218_1106.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 2.0.2 on 2018-02-18 11:06
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 | dependencies = [
8 | ("automated_logging", "0012_auto_20180218_1101"),
9 | ]
10 |
11 | operations = [
12 | migrations.RemoveField(
13 | model_name="request",
14 | name="url",
15 | ),
16 | migrations.AddField(
17 | model_name="request",
18 | name="uri",
19 | field=models.URLField(default="/"),
20 | preserve_default=False,
21 | ),
22 | ]
23 |
--------------------------------------------------------------------------------
/automated_logging/migrations/0014_auto_20180219_0859.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 2.0.2 on 2018-02-19 08:59
2 |
3 | from django.db import migrations, models
4 | import django.db.models.deletion
5 |
6 |
7 | class Migration(migrations.Migration):
8 | dependencies = [
9 | ("automated_logging", "0013_auto_20180218_1106"),
10 | ]
11 |
12 | operations = [
13 | migrations.AddField(
14 | model_name="request",
15 | name="status",
16 | field=models.PositiveSmallIntegerField(null=True),
17 | ),
18 | migrations.RemoveField(model_name="field", name="model"),
19 | migrations.AddField(
20 | model_name="field",
21 | name="model",
22 | field=models.ForeignKey(
23 | null=True,
24 | on_delete=django.db.models.deletion.CASCADE,
25 | related_name="dal_field",
26 | to="contenttypes.ContentType",
27 | ),
28 | ),
29 | migrations.DeleteModel(
30 | name="ModelStorage",
31 | ),
32 | ]
33 |
--------------------------------------------------------------------------------
/automated_logging/migrations/0015_auto_20181229_2323.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 2.1.1 on 2018-12-29 23:23
2 |
3 | from django.db import migrations, models
4 | import uuid
5 |
6 |
7 | class Migration(migrations.Migration):
8 | dependencies = [
9 | ("automated_logging", "0014_auto_20180219_0859"),
10 | ]
11 |
12 | operations = [
13 | migrations.DeleteModel(
14 | name="LDAP",
15 | ),
16 | migrations.AlterModelOptions(
17 | name="unspecified",
18 | options={
19 | "verbose_name": "Non DAL Message",
20 | "verbose_name_plural": "Non DAL Messages",
21 | },
22 | ),
23 | migrations.AlterField(
24 | model_name="application",
25 | name="id",
26 | field=models.UUIDField(
27 | db_index=True, default=uuid.uuid4, primary_key=True, serialize=False
28 | ),
29 | ),
30 | migrations.AlterField(
31 | model_name="field",
32 | name="id",
33 | field=models.UUIDField(
34 | db_index=True, default=uuid.uuid4, primary_key=True, serialize=False
35 | ),
36 | ),
37 | migrations.AlterField(
38 | model_name="model",
39 | name="id",
40 | field=models.UUIDField(
41 | db_index=True, default=uuid.uuid4, primary_key=True, serialize=False
42 | ),
43 | ),
44 | migrations.AlterField(
45 | model_name="modelchangelog",
46 | name="id",
47 | field=models.UUIDField(
48 | db_index=True, default=uuid.uuid4, primary_key=True, serialize=False
49 | ),
50 | ),
51 | migrations.AlterField(
52 | model_name="modelmodification",
53 | name="id",
54 | field=models.UUIDField(
55 | db_index=True, default=uuid.uuid4, primary_key=True, serialize=False
56 | ),
57 | ),
58 | migrations.AlterField(
59 | model_name="modelobject",
60 | name="id",
61 | field=models.UUIDField(
62 | db_index=True, default=uuid.uuid4, primary_key=True, serialize=False
63 | ),
64 | ),
65 | migrations.AlterField(
66 | model_name="request",
67 | name="id",
68 | field=models.UUIDField(
69 | db_index=True, default=uuid.uuid4, primary_key=True, serialize=False
70 | ),
71 | ),
72 | migrations.AlterField(
73 | model_name="unspecified",
74 | name="id",
75 | field=models.UUIDField(
76 | db_index=True, default=uuid.uuid4, primary_key=True, serialize=False
77 | ),
78 | ),
79 | ]
80 |
--------------------------------------------------------------------------------
/automated_logging/migrations/0017_auto_20200819_1004.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.0.7 on 2020-08-19 10:04
2 |
3 | from django.db import migrations
4 |
5 |
6 | class Migration(migrations.Migration):
7 | dependencies = [
8 | ("automated_logging", "0016_auto_20200803_1917"),
9 | ]
10 |
11 | operations = [
12 | migrations.AlterModelOptions(
13 | name="application",
14 | options={
15 | "verbose_name": "Application",
16 | "verbose_name_plural": "Applications",
17 | },
18 | ),
19 | ]
20 |
--------------------------------------------------------------------------------
/automated_logging/migrations/0018_decoratoroverrideexclusiontest_foreignkeytest_fullclassbasedexclusiontest_fulldecoratorbasedexclusio.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.0.7 on 2020-09-13 18:33
2 |
3 | from django.db import migrations, models
4 | import django.db.models.deletion
5 | import uuid
6 |
7 |
8 | class Migration(migrations.Migration):
9 | from django.conf import settings
10 |
11 | dependencies = [
12 | ("automated_logging", "0017_auto_20200819_1004"),
13 | ]
14 |
15 | if hasattr(settings, "AUTOMATED_LOGGING_DEV") and settings.AUTOMATED_LOGGING_DEV:
16 | operations = [
17 | migrations.CreateModel(
18 | name="DecoratorOverrideExclusionTest",
19 | fields=[
20 | (
21 | "id",
22 | models.UUIDField(
23 | default=uuid.uuid4, primary_key=True, serialize=False
24 | ),
25 | ),
26 | ("created_at", models.DateTimeField(auto_now_add=True)),
27 | ("updated_at", models.DateTimeField(auto_now=True)),
28 | ("random", models.CharField(max_length=255, null=True)),
29 | ("random2", models.CharField(max_length=255, null=True)),
30 | ],
31 | ),
32 | migrations.CreateModel(
33 | name="FullClassBasedExclusionTest",
34 | fields=[
35 | (
36 | "id",
37 | models.UUIDField(
38 | default=uuid.uuid4, primary_key=True, serialize=False
39 | ),
40 | ),
41 | ("created_at", models.DateTimeField(auto_now_add=True)),
42 | ("updated_at", models.DateTimeField(auto_now=True)),
43 | ("random", models.CharField(max_length=255, null=True)),
44 | ("random2", models.CharField(max_length=255, null=True)),
45 | ],
46 | ),
47 | migrations.CreateModel(
48 | name="FullDecoratorBasedExclusionTest",
49 | fields=[
50 | (
51 | "id",
52 | models.UUIDField(
53 | default=uuid.uuid4, primary_key=True, serialize=False
54 | ),
55 | ),
56 | ("created_at", models.DateTimeField(auto_now_add=True)),
57 | ("updated_at", models.DateTimeField(auto_now=True)),
58 | ("random", models.CharField(max_length=255, null=True)),
59 | ("random2", models.CharField(max_length=255, null=True)),
60 | ],
61 | ),
62 | migrations.CreateModel(
63 | name="OrdinaryTest",
64 | fields=[
65 | (
66 | "id",
67 | models.UUIDField(
68 | default=uuid.uuid4, primary_key=True, serialize=False
69 | ),
70 | ),
71 | ("created_at", models.DateTimeField(auto_now_add=True)),
72 | ("updated_at", models.DateTimeField(auto_now=True)),
73 | ("random", models.CharField(max_length=255, null=True)),
74 | ("random2", models.CharField(max_length=255, null=True)),
75 | ],
76 | ),
77 | migrations.CreateModel(
78 | name="PartialClassBasedExclusionTest",
79 | fields=[
80 | (
81 | "id",
82 | models.UUIDField(
83 | default=uuid.uuid4, primary_key=True, serialize=False
84 | ),
85 | ),
86 | ("created_at", models.DateTimeField(auto_now_add=True)),
87 | ("updated_at", models.DateTimeField(auto_now=True)),
88 | ("random", models.CharField(max_length=255, null=True)),
89 | ("random2", models.CharField(max_length=255, null=True)),
90 | ],
91 | ),
92 | migrations.CreateModel(
93 | name="PartialDecoratorBasedExclusionTest",
94 | fields=[
95 | (
96 | "id",
97 | models.UUIDField(
98 | default=uuid.uuid4, primary_key=True, serialize=False
99 | ),
100 | ),
101 | ("created_at", models.DateTimeField(auto_now_add=True)),
102 | ("updated_at", models.DateTimeField(auto_now=True)),
103 | ("random", models.CharField(max_length=255, null=True)),
104 | ("random2", models.CharField(max_length=255, null=True)),
105 | ],
106 | ),
107 | migrations.CreateModel(
108 | name="SpeedTest",
109 | fields=[
110 | (
111 | "id",
112 | models.UUIDField(
113 | default=uuid.uuid4, primary_key=True, serialize=False
114 | ),
115 | ),
116 | ("created_at", models.DateTimeField(auto_now_add=True)),
117 | ("updated_at", models.DateTimeField(auto_now=True)),
118 | ("column0", models.CharField(max_length=15, null=True)),
119 | ("column1", models.CharField(max_length=15, null=True)),
120 | ("column2", models.CharField(max_length=15, null=True)),
121 | ("column3", models.CharField(max_length=15, null=True)),
122 | ("column4", models.CharField(max_length=15, null=True)),
123 | ("column5", models.CharField(max_length=15, null=True)),
124 | ("column6", models.CharField(max_length=15, null=True)),
125 | ("column7", models.CharField(max_length=15, null=True)),
126 | ("column8", models.CharField(max_length=15, null=True)),
127 | ("column9", models.CharField(max_length=15, null=True)),
128 | ("column10", models.CharField(max_length=15, null=True)),
129 | ("column11", models.CharField(max_length=15, null=True)),
130 | ("column12", models.CharField(max_length=15, null=True)),
131 | ("column13", models.CharField(max_length=15, null=True)),
132 | ("column14", models.CharField(max_length=15, null=True)),
133 | ("column15", models.CharField(max_length=15, null=True)),
134 | ("column16", models.CharField(max_length=15, null=True)),
135 | ("column17", models.CharField(max_length=15, null=True)),
136 | ("column18", models.CharField(max_length=15, null=True)),
137 | ("column19", models.CharField(max_length=15, null=True)),
138 | ("column20", models.CharField(max_length=15, null=True)),
139 | ("column21", models.CharField(max_length=15, null=True)),
140 | ("column22", models.CharField(max_length=15, null=True)),
141 | ("column23", models.CharField(max_length=15, null=True)),
142 | ("column24", models.CharField(max_length=15, null=True)),
143 | ("column25", models.CharField(max_length=15, null=True)),
144 | ("column26", models.CharField(max_length=15, null=True)),
145 | ("column27", models.CharField(max_length=15, null=True)),
146 | ("column28", models.CharField(max_length=15, null=True)),
147 | ("column29", models.CharField(max_length=15, null=True)),
148 | ("column30", models.CharField(max_length=15, null=True)),
149 | ("column31", models.CharField(max_length=15, null=True)),
150 | ("column32", models.CharField(max_length=15, null=True)),
151 | ("column33", models.CharField(max_length=15, null=True)),
152 | ("column34", models.CharField(max_length=15, null=True)),
153 | ("column35", models.CharField(max_length=15, null=True)),
154 | ("column36", models.CharField(max_length=15, null=True)),
155 | ("column37", models.CharField(max_length=15, null=True)),
156 | ("column38", models.CharField(max_length=15, null=True)),
157 | ("column39", models.CharField(max_length=15, null=True)),
158 | ("column40", models.CharField(max_length=15, null=True)),
159 | ("column41", models.CharField(max_length=15, null=True)),
160 | ("column42", models.CharField(max_length=15, null=True)),
161 | ("column43", models.CharField(max_length=15, null=True)),
162 | ("column44", models.CharField(max_length=15, null=True)),
163 | ("column45", models.CharField(max_length=15, null=True)),
164 | ("column46", models.CharField(max_length=15, null=True)),
165 | ("column47", models.CharField(max_length=15, null=True)),
166 | ("column48", models.CharField(max_length=15, null=True)),
167 | ("column49", models.CharField(max_length=15, null=True)),
168 | ("column50", models.CharField(max_length=15, null=True)),
169 | ("column51", models.CharField(max_length=15, null=True)),
170 | ("column52", models.CharField(max_length=15, null=True)),
171 | ("column53", models.CharField(max_length=15, null=True)),
172 | ("column54", models.CharField(max_length=15, null=True)),
173 | ("column55", models.CharField(max_length=15, null=True)),
174 | ("column56", models.CharField(max_length=15, null=True)),
175 | ("column57", models.CharField(max_length=15, null=True)),
176 | ("column58", models.CharField(max_length=15, null=True)),
177 | ("column59", models.CharField(max_length=15, null=True)),
178 | ("column60", models.CharField(max_length=15, null=True)),
179 | ("column61", models.CharField(max_length=15, null=True)),
180 | ("column62", models.CharField(max_length=15, null=True)),
181 | ("column63", models.CharField(max_length=15, null=True)),
182 | ("column64", models.CharField(max_length=15, null=True)),
183 | ("column65", models.CharField(max_length=15, null=True)),
184 | ("column66", models.CharField(max_length=15, null=True)),
185 | ("column67", models.CharField(max_length=15, null=True)),
186 | ("column68", models.CharField(max_length=15, null=True)),
187 | ("column69", models.CharField(max_length=15, null=True)),
188 | ("column70", models.CharField(max_length=15, null=True)),
189 | ("column71", models.CharField(max_length=15, null=True)),
190 | ("column72", models.CharField(max_length=15, null=True)),
191 | ("column73", models.CharField(max_length=15, null=True)),
192 | ("column74", models.CharField(max_length=15, null=True)),
193 | ("column75", models.CharField(max_length=15, null=True)),
194 | ("column76", models.CharField(max_length=15, null=True)),
195 | ("column77", models.CharField(max_length=15, null=True)),
196 | ("column78", models.CharField(max_length=15, null=True)),
197 | ("column79", models.CharField(max_length=15, null=True)),
198 | ("column80", models.CharField(max_length=15, null=True)),
199 | ("column81", models.CharField(max_length=15, null=True)),
200 | ("column82", models.CharField(max_length=15, null=True)),
201 | ("column83", models.CharField(max_length=15, null=True)),
202 | ("column84", models.CharField(max_length=15, null=True)),
203 | ("column85", models.CharField(max_length=15, null=True)),
204 | ("column86", models.CharField(max_length=15, null=True)),
205 | ("column87", models.CharField(max_length=15, null=True)),
206 | ("column88", models.CharField(max_length=15, null=True)),
207 | ("column89", models.CharField(max_length=15, null=True)),
208 | ("column90", models.CharField(max_length=15, null=True)),
209 | ("column91", models.CharField(max_length=15, null=True)),
210 | ("column92", models.CharField(max_length=15, null=True)),
211 | ("column93", models.CharField(max_length=15, null=True)),
212 | ("column94", models.CharField(max_length=15, null=True)),
213 | ("column95", models.CharField(max_length=15, null=True)),
214 | ("column96", models.CharField(max_length=15, null=True)),
215 | ("column97", models.CharField(max_length=15, null=True)),
216 | ("column98", models.CharField(max_length=15, null=True)),
217 | ("column99", models.CharField(max_length=15, null=True)),
218 | ],
219 | ),
220 | migrations.CreateModel(
221 | name="OneToOneTest",
222 | fields=[
223 | (
224 | "id",
225 | models.UUIDField(
226 | default=uuid.uuid4, primary_key=True, serialize=False
227 | ),
228 | ),
229 | ("created_at", models.DateTimeField(auto_now_add=True)),
230 | ("updated_at", models.DateTimeField(auto_now=True)),
231 | (
232 | "relationship",
233 | models.OneToOneField(
234 | null=True,
235 | on_delete=django.db.models.deletion.CASCADE,
236 | to="automated_logging.OrdinaryTest",
237 | ),
238 | ),
239 | ],
240 | ),
241 | migrations.CreateModel(
242 | name="M2MTest",
243 | fields=[
244 | (
245 | "id",
246 | models.UUIDField(
247 | default=uuid.uuid4, primary_key=True, serialize=False
248 | ),
249 | ),
250 | ("created_at", models.DateTimeField(auto_now_add=True)),
251 | ("updated_at", models.DateTimeField(auto_now=True)),
252 | (
253 | "relationship",
254 | models.ManyToManyField(to="automated_logging.OrdinaryTest"),
255 | ),
256 | ],
257 | ),
258 | migrations.CreateModel(
259 | name="ForeignKeyTest",
260 | fields=[
261 | (
262 | "id",
263 | models.UUIDField(
264 | default=uuid.uuid4, primary_key=True, serialize=False
265 | ),
266 | ),
267 | ("created_at", models.DateTimeField(auto_now_add=True)),
268 | ("updated_at", models.DateTimeField(auto_now=True)),
269 | (
270 | "relationship",
271 | models.ForeignKey(
272 | null=True,
273 | on_delete=django.db.models.deletion.CASCADE,
274 | to="automated_logging.OrdinaryTest",
275 | ),
276 | ),
277 | ],
278 | ),
279 | ]
280 |
281 | else:
282 | operations = []
283 |
--------------------------------------------------------------------------------
/automated_logging/migrations/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/indietyp/django-automated-logging/254bd35699259c41d650d79f0e142a7ed2c73dca/automated_logging/migrations/__init__.py
--------------------------------------------------------------------------------
/automated_logging/models.py:
--------------------------------------------------------------------------------
1 | """
2 | Model definitions for django-automated-logging.
3 | """
4 |
5 | import uuid
6 |
7 | from django.conf import settings
8 | from django.core.validators import MaxValueValidator, MinValueValidator
9 | from django.db import models
10 | from django.db.models import (
11 | CASCADE,
12 | CharField,
13 | DurationField,
14 | ForeignKey,
15 | GenericIPAddressField,
16 | PositiveIntegerField,
17 | PositiveSmallIntegerField,
18 | SmallIntegerField,
19 | TextField,
20 | )
21 | from picklefield.fields import PickledObjectField
22 |
23 | from automated_logging.helpers import Operation
24 | from automated_logging.helpers.enums import (
25 | DjangoOperations,
26 | PastM2MOperationMap,
27 | ShortOperationMap,
28 | )
29 | from automated_logging.settings import dev
30 |
31 |
32 | class BaseModel(models.Model):
33 | """BaseModel that is inherited from every model. Includes basic information."""
34 |
35 | id = models.UUIDField(default=uuid.uuid4, primary_key=True, db_index=True)
36 |
37 | created_at = models.DateTimeField(auto_now_add=True)
38 | updated_at = models.DateTimeField(auto_now=True)
39 |
40 | class Meta:
41 | abstract = True
42 |
43 |
44 | class Application(BaseModel):
45 | """
46 | Used to save from which application an event or model originates.
47 | This is used to group by application.
48 |
49 | The application name can be null,
50 | if the name is None, then the application is unknown.
51 | """
52 |
53 | name = CharField(max_length=255, null=True)
54 |
55 | class Meta:
56 | verbose_name = "Application"
57 | verbose_name_plural = "Applications"
58 |
59 | class LoggingIgnore:
60 | complete = True
61 |
62 | def __str__(self):
63 | return self.name or "Unknown"
64 |
65 |
66 | class ModelMirror(BaseModel):
67 | """
68 | Used to mirror properties of models - this is used to preserve logs of
69 | models removed to make the logs independent of the presence of the model
70 | in the application.
71 | """
72 |
73 | name = CharField(max_length=255)
74 | application = ForeignKey(Application, on_delete=CASCADE)
75 |
76 | class Meta:
77 | verbose_name = "Model Mirror"
78 | verbose_name_plural = "Model Mirrors"
79 |
80 | class LoggingIgnore:
81 | complete = True
82 |
83 | def __str__(self):
84 | return self.name
85 |
86 |
87 | class ModelField(BaseModel):
88 | """
89 | Used to mirror properties of model fields - this is used to preserve logs of
90 | models and fields that might be removed/modified and have them independent
91 | of the actual field.
92 | """
93 |
94 | name = CharField(max_length=255)
95 |
96 | mirror = ForeignKey(ModelMirror, on_delete=CASCADE)
97 | type = CharField(max_length=255) # string of type
98 |
99 | class Meta:
100 | verbose_name = "Model Field"
101 | verbose_name_plural = "Model Fields"
102 |
103 | class LoggingIgnore:
104 | complete = True
105 |
106 |
107 | class ModelEntry(BaseModel):
108 | """
109 | Used to mirror the evaluated model value (via repr) and primary key and
110 | to ensure the log integrity independent of presence of the entry.
111 | """
112 |
113 | mirror = ForeignKey(ModelMirror, on_delete=CASCADE)
114 |
115 | value = TextField() # (repr)
116 | primary_key = TextField()
117 |
118 | class Meta:
119 | verbose_name = "Model Entry"
120 | verbose_name_plural = "Model Entries"
121 |
122 | class LoggingIgnore:
123 | complete = True
124 |
125 | def __str__(self) -> str:
126 | return f"{self.mirror.name}" f'(pk="{self.primary_key}", value="{self.value}")'
127 |
128 | def long(self) -> str:
129 | """
130 | long representation
131 | """
132 |
133 | return f"{self.mirror.application.name}.{self})"
134 |
135 | def short(self) -> str:
136 | """
137 | short representation
138 | """
139 | return f"{self.mirror.name}({self.primary_key})"
140 |
141 |
142 | class ModelEvent(BaseModel):
143 | """
144 | Used to record model entry events, like modification, removal or adding of
145 | values or relationships.
146 | """
147 |
148 | operation = SmallIntegerField(
149 | validators=[MinValueValidator(-1), MaxValueValidator(1)],
150 | null=True,
151 | choices=DjangoOperations,
152 | )
153 |
154 | user = ForeignKey(
155 | settings.AUTH_USER_MODEL, on_delete=CASCADE, null=True
156 | ) # maybe don't cascade?
157 | entry = ForeignKey(ModelEntry, on_delete=CASCADE)
158 |
159 | # modifications = None # One2Many -> ModelModification
160 | # relationships = None # One2Many -> ModelRelationship
161 |
162 | # v experimental, opt-in (pickled object)
163 | snapshot = PickledObjectField(null=True)
164 | performance = DurationField(null=True)
165 |
166 | class Meta:
167 | verbose_name = "Model Event"
168 | verbose_name_plural = "Model Events"
169 |
170 | class LoggingIgnore:
171 | complete = True
172 |
173 |
174 | class ModelValueModification(BaseModel):
175 | """
176 | Used to record the model entry event modifications of simple values.
177 |
178 | The operation attribute can have 4 valid values:
179 | -1 (delete), 0 (modify), 1 (create), None (n/a)
180 |
181 | previous and current record the value change that happened.
182 | """
183 |
184 | operation = SmallIntegerField(
185 | validators=[MinValueValidator(-1), MaxValueValidator(1)],
186 | null=True,
187 | choices=DjangoOperations,
188 | )
189 |
190 | field = ForeignKey(ModelField, on_delete=CASCADE)
191 |
192 | previous = TextField(null=True)
193 | current = TextField(null=True)
194 |
195 | event = ForeignKey(ModelEvent, on_delete=CASCADE, related_name="modifications")
196 |
197 | class Meta:
198 | verbose_name = "Model Entry Event Value Modification"
199 | verbose_name_plural = "Model Entry Event Value Modifications"
200 |
201 | class LoggingIgnore:
202 | complete = True
203 |
204 | def __str__(self) -> str:
205 | return (
206 | f"[{self.field.mirror.application.name}:"
207 | f"{self.field.mirror.name}:"
208 | f"{self.field.name}] "
209 | f"{self.previous} -> {self.current}"
210 | )
211 |
212 | def short(self) -> str:
213 | """
214 | short representation analogue of __str__
215 | """
216 | operation = Operation(self.operation)
217 | shorthand = {v: k for k, v in ShortOperationMap.items()}[operation]
218 |
219 | return f"{shorthand}{self.field.name}"
220 |
221 |
222 | class ModelRelationshipModification(BaseModel):
223 | """
224 | Used to record the model entry even modifications of relationships. (M2M, Foreign)
225 |
226 |
227 | The operation attribute can have 4 valid values:
228 | -1 (delete), 0 (modify), 1 (create), None (n/a)
229 |
230 | field is the field where the relationship changed (entry got added or removed)
231 | and model is the entry that got removed/added from the relationship.
232 | """
233 |
234 | operation = SmallIntegerField(
235 | validators=[MinValueValidator(-1), MaxValueValidator(1)],
236 | null=True,
237 | choices=DjangoOperations,
238 | )
239 |
240 | field = ForeignKey(ModelField, on_delete=CASCADE)
241 | entry = ForeignKey(ModelEntry, on_delete=CASCADE)
242 |
243 | event = ForeignKey(ModelEvent, on_delete=CASCADE, related_name="relationships")
244 |
245 | class Meta:
246 | verbose_name = "Model Entry Event Relationship Modification"
247 | verbose_name_plural = "Model Entry Event Relationship Modifications"
248 |
249 | class LoggingIgnore:
250 | complete = True
251 |
252 | def __str__(self) -> str:
253 | operation = Operation(self.operation)
254 | past = {v: k for k, v in PastM2MOperationMap.items()}[operation]
255 |
256 | return (
257 | f"[{self.field.mirror.application}:"
258 | f"{self.field.mirror.name}:"
259 | f"{self.field.name}] "
260 | f"{past} {self.entry}"
261 | )
262 |
263 | def short(self) -> str:
264 | """
265 | short representation
266 | """
267 | operation = Operation(self.operation)
268 | shorthand = {v: k for k, v in ShortOperationMap.items()}[operation]
269 | return f"{shorthand}{self.entry.short()}"
270 |
271 | def medium(self) -> [str, str]:
272 | """
273 | short representation analogue of __str__ with additional field context
274 | :return:
275 | """
276 | operation = Operation(self.operation)
277 | shorthand = {v: k for k, v in ShortOperationMap.items()}[operation]
278 |
279 | return f"{shorthand}{self.field.name}", f"{self.entry.short()}"
280 |
281 |
282 | class RequestContext(BaseModel):
283 | """
284 | Used to record contents of request and responses and their type.
285 | """
286 |
287 | content = PickledObjectField(null=True)
288 | type = CharField(max_length=255)
289 |
290 | class LoggingIgnore:
291 | complete = True
292 |
293 |
294 | class RequestEvent(BaseModel):
295 | """
296 | Used to record events of requests that happened.
297 |
298 | uri is the accessed path and data is the data that was being transmitted
299 | and is opt-in for collection.
300 |
301 | status and method are their respective HTTP equivalents.
302 | """
303 |
304 | user = ForeignKey(settings.AUTH_USER_MODEL, on_delete=CASCADE, null=True)
305 |
306 | # to mitigate "max_length"
307 | uri = TextField()
308 |
309 | request = ForeignKey(
310 | RequestContext, on_delete=CASCADE, null=True, related_name="request_context"
311 | )
312 | response = ForeignKey(
313 | RequestContext, on_delete=CASCADE, null=True, related_name="response_context"
314 | )
315 |
316 | status = PositiveSmallIntegerField()
317 | method = CharField(max_length=32)
318 |
319 | application = ForeignKey(Application, on_delete=CASCADE)
320 |
321 | ip = GenericIPAddressField(null=True)
322 |
323 | class Meta:
324 | verbose_name = "Request Event"
325 | verbose_name_plural = "Request Events"
326 |
327 | class LoggingIgnore:
328 | complete = True
329 |
330 |
331 | class UnspecifiedEvent(BaseModel):
332 | """
333 | Used to record unspecified internal events that are dispatched via
334 | the python logging library. saves the message, level, line, file and application.
335 | """
336 |
337 | message = TextField(null=True)
338 | level = PositiveIntegerField(default=20)
339 |
340 | line = PositiveIntegerField(null=True)
341 | file = TextField(null=True)
342 |
343 | application = ForeignKey(Application, on_delete=CASCADE)
344 |
345 | class Meta:
346 | verbose_name = "Unspecified Event"
347 | verbose_name_plural = "Unspecified Events"
348 |
349 | class LoggingIgnore:
350 | complete = True
351 |
352 |
353 | if dev:
354 | # if in development mode (set when testing or development)
355 | # import extra models
356 | from automated_logging.tests.models import *
357 |
--------------------------------------------------------------------------------
/automated_logging/settings.py:
--------------------------------------------------------------------------------
1 | """
2 | Serialization of AUTOMATED_LOGGING_SETTINGS
3 | """
4 |
5 | from collections import namedtuple
6 | from functools import lru_cache
7 | from logging import INFO, NOTSET, CRITICAL
8 | from pprint import pprint
9 |
10 | from marshmallow.fields import Boolean, Integer
11 | from marshmallow.validate import OneOf, Range
12 |
13 | from automated_logging.helpers.schemas import (
14 | Set,
15 | LowerCaseString,
16 | SearchString,
17 | MissingNested,
18 | BaseSchema,
19 | Search,
20 | Duration,
21 | )
22 |
23 |
24 | class RequestExcludeSchema(BaseSchema):
25 | """
26 | Configuration schema for request exclusion, that is only used in RequestSchema,
27 | is used to exclude unknown sources, applications, methods and status codes.
28 | """
29 |
30 | unknown = Boolean(missing=False)
31 | applications = Set(SearchString(), missing=set())
32 |
33 | methods = Set(LowerCaseString(), missing={"GET"})
34 | status = Set(Integer(validate=Range(min=0)), missing={200})
35 |
36 |
37 | class RequestDataSchema(BaseSchema):
38 | """
39 | Configuration schema for request data that is only used in RequestSchema
40 | and is used to enable data collection, ignore keys that are going to be omitted
41 | mask keys (their value is going to be replaced with )
42 | """
43 |
44 | enabled = Set(
45 | LowerCaseString(validate=OneOf(["request", "response"])),
46 | missing=set(),
47 | )
48 | query = Boolean(missing=False)
49 |
50 | ignore = Set(LowerCaseString(), missing=set())
51 | mask = Set(LowerCaseString(), missing={"password"})
52 |
53 | # TODO: add more, change name?
54 | content_types = Set(
55 | LowerCaseString(validate=OneOf(["application/json"])),
56 | missing={"application/json"},
57 | )
58 |
59 |
60 | class RequestSchema(BaseSchema):
61 | """
62 | Configuration schema for the request module.
63 | """
64 |
65 | loglevel = Integer(missing=INFO, validate=Range(min=NOTSET, max=CRITICAL))
66 | exclude = MissingNested(RequestExcludeSchema)
67 |
68 | data = MissingNested(RequestDataSchema)
69 |
70 | ip = Boolean(missing=True)
71 | # TODO: performance setting?
72 |
73 | log_request_was_not_recorded = Boolean(missing=True)
74 | max_age = Duration(missing=None)
75 |
76 |
77 | class ModelExcludeSchema(BaseSchema):
78 | """
79 | Configuration schema, that is only used in ModelSchema and is used to
80 | exclude unknown sources, fields, models and applications.
81 |
82 | fields should be either (every field that matches this name will be excluded),
83 | or ., or ..
84 |
85 | models should be either (every model regardless of module or application).
86 | (python module location) or . (python module location)
87 | """
88 |
89 | unknown = Boolean(missing=False)
90 | fields = Set(SearchString(), missing=set())
91 | models = Set(SearchString(), missing=set())
92 | applications = Set(SearchString(), missing=set())
93 |
94 |
95 | class ModelSchema(BaseSchema):
96 | """
97 | Configuration schema for the model module. mask property indicates
98 | which fields to specifically replace with ,
99 | this should be used for fields that are
100 | sensitive, but shouldn't be completely excluded.
101 | """
102 |
103 | loglevel = Integer(missing=INFO, validate=Range(min=NOTSET, max=CRITICAL))
104 | exclude = MissingNested(ModelExcludeSchema)
105 |
106 | # should the log message include all modifications done?
107 | detailed_message = Boolean(missing=True)
108 |
109 | # if execution_time should be measured of ModelEvent
110 | performance = Boolean(missing=False)
111 | snapshot = Boolean(missing=False)
112 |
113 | max_age = Duration(missing=None)
114 |
115 |
116 | class UnspecifiedExcludeSchema(BaseSchema):
117 | """
118 | Configuration schema, that is only used in UnspecifiedSchema and defines
119 | the configuration settings to allow unknown sources, exclude files and
120 | specific Django applications
121 | """
122 |
123 | unknown = Boolean(missing=False)
124 | files = Set(SearchString(), missing=set())
125 | applications = Set(SearchString(), missing=set())
126 |
127 |
128 | class UnspecifiedSchema(BaseSchema):
129 | """
130 | Configuration schema for the unspecified module.
131 | """
132 |
133 | loglevel = Integer(missing=INFO, validate=Range(min=NOTSET, max=CRITICAL))
134 | exclude = MissingNested(UnspecifiedExcludeSchema)
135 |
136 | max_age = Duration(missing=None)
137 |
138 |
139 | class GlobalsExcludeSchema(BaseSchema):
140 | """
141 | Configuration schema, that is used for every single module.
142 | There are some packages where it is sensible to have the same
143 | exclusions.
144 |
145 | Things specified in globals will get appended to the other configurations.
146 | """
147 |
148 | applications = Set(
149 | SearchString(),
150 | missing={
151 | Search("glob", "session*"),
152 | Search("plain", "admin"),
153 | Search("plain", "basehttp"),
154 | Search("plain", "migrations"),
155 | Search("plain", "contenttypes"),
156 | },
157 | )
158 |
159 |
160 | class GlobalsSchema(BaseSchema):
161 | """
162 | Configuration schema for global, module unspecific configuration details.
163 | """
164 |
165 | exclude = MissingNested(GlobalsExcludeSchema)
166 |
167 |
168 | class ConfigSchema(BaseSchema):
169 | """
170 | Skeleton configuration schema, that is used to enable/disable modules
171 | and includes the nested module configurations.
172 | """
173 |
174 | modules = Set(
175 | LowerCaseString(validate=OneOf(["request", "model", "unspecified"])),
176 | missing={"request", "model", "unspecified"},
177 | )
178 |
179 | request = MissingNested(RequestSchema)
180 | model = MissingNested(ModelSchema)
181 | unspecified = MissingNested(UnspecifiedSchema)
182 |
183 | globals = MissingNested(GlobalsSchema)
184 |
185 |
186 | default: namedtuple = ConfigSchema().load({})
187 |
188 |
189 | class Settings:
190 | """
191 | Settings wrapper,
192 | with the wrapper we can force lru_cache to be
193 | cleared on the specific instance
194 | """
195 |
196 | def __init__(self):
197 | self.loaded = None
198 | self.load()
199 |
200 | @lru_cache()
201 | def load(self):
202 | """
203 | loads settings from the schemes provided,
204 | done via function to utilize LRU cache
205 | """
206 |
207 | from django.conf import settings as st
208 |
209 | loaded: namedtuple = default
210 |
211 | if hasattr(st, "AUTOMATED_LOGGING"):
212 | loaded = ConfigSchema().load(st.AUTOMATED_LOGGING)
213 |
214 | # be sure `loaded` has globals as we're working with those,
215 | # if that is not the case early return.
216 | if not hasattr(loaded, "globals"):
217 | return loaded
218 |
219 | # use the binary **or** operator to apply globals to Set() attributes
220 | values = {}
221 | for name in loaded._fields:
222 | field = getattr(loaded, name)
223 | values[name] = field
224 |
225 | if not isinstance(field, tuple) or name == "globals":
226 | continue
227 |
228 | values[name] = field | loaded.globals
229 |
230 | self.loaded = loaded._replace(**values)
231 | return self
232 |
233 | def __getattr__(self, item):
234 | # self.load() should only trigger when the cache is invalid
235 | self.load()
236 |
237 | return getattr(self.loaded, item)
238 |
239 |
240 | @lru_cache()
241 | def load_dev():
242 | """
243 | utilize LRU cache and local imports to always
244 | have an up to date version of the settings
245 |
246 | :return:
247 | """
248 | from django.conf import settings as st
249 |
250 | return getattr(st, "AUTOMATED_LOGGING_DEV", False)
251 |
252 |
253 | if __name__ == "__main__":
254 | from automated_logging.helpers import namedtuple2dict
255 |
256 | pprint(namedtuple2dict(default))
257 |
258 | settings = Settings()
259 | dev = load_dev()
260 |
--------------------------------------------------------------------------------
/automated_logging/signals/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | Helper functions that are specifically used in the signals only.
3 | """
4 |
5 | import re
6 | from fnmatch import fnmatch
7 | from functools import lru_cache
8 | from pathlib import Path
9 | from typing import List, Optional, Callable, Any
10 |
11 | from automated_logging.helpers import (
12 | get_or_create_meta,
13 | get_or_create_thread,
14 | function2path,
15 | Operation,
16 | )
17 | from automated_logging.models import RequestEvent, UnspecifiedEvent
18 | import automated_logging.decorators
19 | from automated_logging.settings import settings
20 | from automated_logging.helpers.schemas import Search
21 |
22 |
23 | # suboptimal meta is also cached -> look into how to solve
24 | @lru_cache()
25 | def cached_model_exclusion(sender, meta, operation) -> bool:
26 | """cached so that we don't need to abuse ._meta and can invalidate the cache"""
27 | return model_exclusion(sender, meta, operation)
28 |
29 |
30 | def lazy_model_exclusion(instance, operation, sender) -> bool:
31 | """
32 | First look if the model has been excluded already
33 | -> only then look if excluded.
34 |
35 | Replaced by LRU-Cache.
36 | """
37 |
38 | return cached_model_exclusion(sender, instance._meta, operation)
39 |
40 |
41 | def candidate_in_scope(candidate: str, scope: List[Search]) -> bool:
42 | """
43 | Check if the candidate string is valid with the scope supplied,
44 | the scope should be list of search strings - that can be either
45 | glob, plain or regex
46 |
47 | :param candidate: search string
48 | :param scope: List of Search
49 | :return: valid?
50 | """
51 |
52 | for search in scope:
53 | match = False
54 | if search.type == "glob":
55 | match = fnmatch(candidate.lower(), search.value.lower())
56 | if search.type == "plain":
57 | match = candidate.lower() == search.value.lower()
58 | if search.type == "regex":
59 | match = bool(re.match(search.value, candidate, re.IGNORECASE))
60 |
61 | if match:
62 | return True
63 |
64 | return False
65 |
66 |
67 | def request_exclusion(event: RequestEvent, view: Optional[Callable] = None) -> bool:
68 | """
69 | Determine if a request should be ignored/excluded from getting
70 | logged, these exclusions should be specified in the settings.
71 |
72 | :param event: RequestEvent
73 | :param view: Optional - function used by the resolver
74 | :return: should be excluded?
75 | """
76 |
77 | if view:
78 | thread, _ = get_or_create_thread()
79 | ignore = thread.dal["ignore.views"]
80 | include = thread.dal["include.views"]
81 | path = function2path(view)
82 |
83 | # if include None or method in include return False and don't
84 | # check further, else just continue with checking
85 | if path in include and (include[path] is None or event.method in include[path]):
86 | return False
87 |
88 | if (
89 | path in ignore
90 | # if ignored[compiled] is None, then no method will be ignored
91 | and ignore[path] is not None
92 | # ignored[compiled] == [] indicates all should be ignored
93 | and (len(ignore[path]) == 0 or event.method in ignore[path])
94 | ):
95 | return True
96 |
97 | exclusions = settings.request.exclude
98 | if event.method.lower() in exclusions.methods:
99 | return True
100 |
101 | if event.application.name and candidate_in_scope(
102 | event.application.name, exclusions.applications
103 | ):
104 | return True
105 |
106 | if event.status in exclusions.status:
107 | return True
108 |
109 | # if the application.name = None, then the application is unknown.
110 | # exclusions.unknown specifies if unknown should be excluded!
111 | if not event.application.name and exclusions.unknown:
112 | return True
113 |
114 | return False
115 |
116 |
117 | def _function_model_exclusion(sender, scope: str, item: Any) -> Optional[bool]:
118 | if not sender:
119 | return None
120 |
121 | thread, _ = get_or_create_thread()
122 |
123 | # noinspection PyProtectedMember
124 | ignore = automated_logging.decorators._exclude_models
125 | # noinspection PyProtectedMember
126 | include = automated_logging.decorators._include_models
127 |
128 | path = function2path(sender)
129 |
130 | if path in include:
131 | items = getattr(include[path], scope)
132 | if items is None or item in items:
133 | return False
134 |
135 | if path in ignore:
136 | items = getattr(ignore[path], scope)
137 | if items is not None and (len(items) == 0 or item in items):
138 | return True
139 |
140 | return None
141 |
142 |
143 | def model_exclusion(sender, meta, operation: Operation) -> bool:
144 | """
145 | Determine if the instance of a model should be excluded,
146 | these exclusions should be specified in the settings.
147 |
148 | :param meta:
149 | :param sender:
150 | :param operation:
151 | :return: should be excluded?
152 | """
153 | decorators = _function_model_exclusion(sender, "operations", operation)
154 | if decorators is not None:
155 | return decorators
156 |
157 | if hasattr(sender, "LoggingIgnore") and (
158 | getattr(sender.LoggingIgnore, "complete", False)
159 | or {
160 | Operation.CREATE: "create",
161 | Operation.MODIFY: "modify",
162 | Operation.DELETE: "delete",
163 | }[operation]
164 | in [o.lower() for o in getattr(sender.LoggingIgnore, "operations", [])]
165 | ):
166 | return True
167 |
168 | exclusions = settings.model.exclude
169 | module = sender.__module__
170 | name = sender.__name__
171 | application = meta.app_label
172 |
173 | if (
174 | candidate_in_scope(name, exclusions.models)
175 | or candidate_in_scope(f"{module}.{name}", exclusions.models)
176 | or candidate_in_scope(f"{application}.{name}", exclusions.models)
177 | ):
178 | return True
179 |
180 | if candidate_in_scope(module, exclusions.models):
181 | return True
182 |
183 | if application and candidate_in_scope(application, exclusions.applications):
184 | return True
185 |
186 | # if there is no application string then we assume the model
187 | # location is unknown, if the flag exclude.unknown = True, then we just exclude
188 | if not application and exclusions.unknown:
189 | return True
190 |
191 | return False
192 |
193 |
194 | def field_exclusion(field: str, instance, sender=None) -> bool:
195 | """
196 | Determine if the field of an instance should be excluded.
197 | """
198 |
199 | decorators = _function_model_exclusion(sender, "fields", field)
200 | if decorators is not None:
201 | return decorators
202 |
203 | if hasattr(instance.__class__, "LoggingIgnore") and (
204 | getattr(instance.__class__.LoggingIgnore, "complete", False)
205 | or field in getattr(instance.__class__.LoggingIgnore, "fields", [])
206 | ):
207 | return True
208 |
209 | exclusions = settings.model.exclude
210 | application = instance._meta.app_label
211 | model = instance.__class__.__name__
212 |
213 | if (
214 | candidate_in_scope(field, exclusions.fields)
215 | or candidate_in_scope(f"{model}.{field}", exclusions.fields)
216 | or candidate_in_scope(f"{application}.{model}.{field}", exclusions.fields)
217 | ):
218 | return True
219 |
220 | return False
221 |
222 |
223 | def unspecified_exclusion(event: UnspecifiedEvent) -> bool:
224 | """
225 | Determine if an unspecified event needs to be excluded.
226 | """
227 | exclusions = settings.unspecified.exclude
228 |
229 | if event.application.name and candidate_in_scope(
230 | event.application.name, exclusions.applications
231 | ):
232 | return True
233 |
234 | if candidate_in_scope(str(event.file), exclusions.files):
235 | return True
236 |
237 | path = Path(event.file)
238 | # match greedily by first trying the complete path, if that doesn't match try
239 | # full relative and then complete relative.
240 | if [
241 | v
242 | for v in exclusions.files
243 | if v.type != "regex"
244 | and (
245 | path.match(v.value)
246 | or path.match(f"/*{v.value}")
247 | or fnmatch(path, f"{v.value}/*")
248 | or fnmatch(path, f"/*{v.value}")
249 | or fnmatch(path, f"/*{v.value}/*")
250 | )
251 | ]:
252 | return True
253 |
254 | # application.name = None and exclusion.unknown = True
255 | if not event.application.name and exclusions.unknown:
256 | return True
257 |
258 | return False
259 |
--------------------------------------------------------------------------------
/automated_logging/signals/m2m.py:
--------------------------------------------------------------------------------
1 | """
2 | This module specifically handlers "many to many" changes, those are
3 | a bit more complicated as we need to detect the changes
4 | on a per field basis.
5 |
6 | This finds the changes and redirects them to the handler,
7 | without doing any changes to the database.
8 | """
9 |
10 | import logging
11 | from typing import Optional
12 |
13 | from django.db.models import Manager
14 | from django.db.models.fields.related import ManyToManyField
15 | from django.db.models.signals import m2m_changed
16 | from django.dispatch import receiver
17 |
18 | from automated_logging.helpers import (
19 | Operation,
20 | get_or_create_model_event,
21 | get_or_create_meta,
22 | )
23 | from automated_logging.models import (
24 | ModelRelationshipModification,
25 | ModelEntry,
26 | ModelMirror,
27 | Application,
28 | ModelField,
29 | )
30 | from automated_logging.settings import settings
31 | from automated_logging.signals import lazy_model_exclusion
32 |
33 | logger = logging.getLogger(__name__)
34 |
35 |
36 | def find_m2m_rel(sender, model) -> Optional[ManyToManyField]:
37 | """
38 | This finds the "many to many" relationship that is used by the sender.
39 | """
40 | for field in model._meta.get_fields():
41 | if isinstance(field, ManyToManyField) and field.remote_field.through == sender:
42 | return field
43 |
44 | return None
45 |
46 |
47 | def post_processor(sender, instance, model, operation, targets):
48 | """
49 | if the change is in reverse or not, the processing of the changes is still
50 | the same, so we have this method to take care of constructing the changes
51 | :param sender:
52 | :param instance:
53 | :param model:
54 | :param operation:
55 | :param targets:
56 | :return:
57 | """
58 | relationships = []
59 |
60 | m2m_rel = find_m2m_rel(sender, model)
61 | if not m2m_rel:
62 | logger.warning(f"[DAL] save[m2m] could not find ManyToManyField for {instance}")
63 | return
64 |
65 | field = ModelField()
66 | field.name = m2m_rel.name
67 | field.mirror = ModelMirror(
68 | name=model.__name__, application=Application(name=instance._meta.app_label)
69 | )
70 | field.type = m2m_rel.__class__.__name__
71 |
72 | # there is the possibility that a pre_clear occurred, if that is the case
73 | # extend the targets and pop the list of affected instances from the attached
74 | # field
75 | get_or_create_meta(instance)
76 | if (
77 | hasattr(instance._meta.dal, "m2m_pre_clear")
78 | and field.name in instance._meta.dal.m2m_pre_clear
79 | and operation == Operation.DELETE
80 | ):
81 | cleared = instance._meta.dal.m2m_pre_clear[field.name]
82 | targets.extend(cleared)
83 | instance._meta.dal.m2m_pre_clear.pop(field.name)
84 |
85 | for target in targets:
86 | relationship = ModelRelationshipModification()
87 | relationship.operation = operation
88 | relationship.field = field
89 | mirror = ModelMirror()
90 | mirror.name = target.__class__.__name__
91 | mirror.application = Application(name=target._meta.app_label)
92 | relationship.entry = ModelEntry(
93 | mirror=mirror, value=repr(target), primary_key=target.pk
94 | )
95 | relationships.append(relationship)
96 |
97 | if len(relationships) == 0:
98 | # there was no actual change, so we're not propagating the event
99 | return
100 |
101 | event, _ = get_or_create_model_event(instance, operation)
102 |
103 | user = None
104 | logger.log(
105 | settings.model.loglevel,
106 | f'{user or "Anonymous"} modified field '
107 | f"{field.name} | Model: "
108 | f"{field.mirror.application}.{field.mirror} "
109 | f'| Modifications: {", ".join([r.short() for r in relationships])}',
110 | extra={
111 | "action": "model[m2m]",
112 | "data": {"instance": instance, "sender": sender},
113 | "relationships": relationships,
114 | "event": event,
115 | },
116 | )
117 |
118 |
119 | def pre_clear_processor(sender, instance, pks, model, reverse, operation) -> None:
120 | """
121 | pre_clear needs a specific processor as we attach the changes that are about
122 | to happen to the instance first, and then use them in post_delete/post_clear
123 |
124 | if reverse = False then every element gets removed from the relationship field,
125 | but if reverse = True then instance should be removed from every target.
126 |
127 | Note: it seems that pre_clear is not getting fired for reverse.
128 |
129 | :return: None
130 | """
131 | if reverse:
132 | return
133 |
134 | get_or_create_meta(instance)
135 |
136 | rel = find_m2m_rel(sender, instance.__class__)
137 | if "m2m_pre_clear" not in instance._meta.dal:
138 | instance._meta.dal.m2m_pre_clear = {}
139 |
140 | cleared = getattr(instance, rel.name, [])
141 | if isinstance(cleared, Manager):
142 | cleared = list(cleared.all())
143 | instance._meta.dal.m2m_pre_clear = {rel.name: cleared}
144 |
145 |
146 | @receiver(m2m_changed, weak=False)
147 | def m2m_changed_signal(
148 | sender, instance, action, reverse, model, pk_set, using, **kwargs
149 | ) -> None:
150 | """
151 | Django sends this signal when many-to-many relationships change.
152 |
153 | One of the more complex signals, due to the fact that change can be reversed
154 | we need to either process
155 | instance field changes of pk_set (reverse=False) or
156 | pk_set field changes of instance. (reverse=True)
157 |
158 | The changes will always get applied in the model where the field in defined.
159 |
160 | # TODO: post_remove also gets triggered when there is nothing actually getting removed
161 | :return: None
162 | """
163 | if action not in ["post_add", "post_remove", "pre_clear", "post_clear"]:
164 | return
165 |
166 | if action == "pre_clear":
167 | operation = Operation.DELETE
168 |
169 | return pre_clear_processor(
170 | sender,
171 | instance,
172 | list(pk_set) if pk_set else None,
173 | model,
174 | reverse,
175 | operation,
176 | )
177 | elif action == "post_add":
178 | operation = Operation.CREATE
179 | elif action == "post_clear":
180 | operation = Operation.DELETE
181 | else:
182 | operation = Operation.DELETE
183 |
184 | targets = model.objects.filter(pk__in=list(pk_set)) if pk_set else []
185 | if reverse:
186 | for target in [
187 | t for t in targets if not lazy_model_exclusion(t, operation, t.__class__)
188 | ]:
189 | post_processor(sender, target, target.__class__, operation, [instance])
190 | else:
191 | if lazy_model_exclusion(instance, operation, instance.__class__):
192 | return
193 |
194 | post_processor(sender, instance, instance.__class__, operation, targets)
195 |
--------------------------------------------------------------------------------
/automated_logging/signals/request.py:
--------------------------------------------------------------------------------
1 | """
2 | File handles the processing and redirection of all request related
3 | signals
4 | """
5 |
6 | import logging
7 | import urllib.parse
8 |
9 | from django.core.handlers.wsgi import WSGIRequest
10 | from django.dispatch import receiver
11 | from django.core.signals import got_request_exception, request_finished
12 | from django.http import Http404, JsonResponse
13 | from django.urls import resolve
14 |
15 | from automated_logging.middleware import AutomatedLoggingMiddleware
16 | from automated_logging.models import RequestEvent, Application, RequestContext
17 | from automated_logging.settings import settings
18 | from automated_logging.signals import request_exclusion
19 |
20 | # TODO: should django-ipware be optional?
21 | try:
22 | from ipware import get_client_ip
23 | except ImportError:
24 | get_client_ip = None
25 |
26 |
27 | logger = logging.getLogger(__name__)
28 |
29 |
30 | @receiver(request_finished, weak=False)
31 | def request_finished_signal(sender, **kwargs) -> None:
32 | """
33 | This signal gets the environment from the local thread and
34 | sends a logging message, that message will be processed by the
35 | handler later on.
36 |
37 | This is a simple redirection.
38 |
39 | :return: -
40 | """
41 | level = settings.request.loglevel
42 | environ = AutomatedLoggingMiddleware.get_current_environ()
43 |
44 | if not environ:
45 | if settings.request.log_request_was_not_recorded:
46 | logger.info(
47 | "Environment for request couldn't be determined. "
48 | "Request was not recorded."
49 | )
50 | return
51 |
52 | request = RequestEvent()
53 |
54 | request.user = AutomatedLoggingMiddleware.get_current_user(environ)
55 | request.uri = environ.request.get_full_path()
56 |
57 | if not settings.request.data.query:
58 | request.uri = urllib.parse.urlparse(request.uri).path
59 |
60 | if "request" in settings.request.data.enabled:
61 | request_context = RequestContext()
62 | request_context.content = environ.request.body
63 | request_context.type = environ.request.content_type
64 |
65 | request.request = request_context
66 |
67 | if "response" in settings.request.data.enabled:
68 | response_context = RequestContext()
69 | response_context.content = environ.response.content
70 | response_context.type = environ.response["Content-Type"]
71 |
72 | request.response = response_context
73 |
74 | # TODO: context parsing, masking and removal
75 | if get_client_ip and settings.request.ip:
76 | request.ip, _ = get_client_ip(environ.request)
77 |
78 | request.status = environ.response.status_code if environ.response else None
79 | request.method = environ.request.method.upper()
80 | request.context_type = environ.request.content_type
81 |
82 | try:
83 | function = resolve(environ.request.path).func
84 | except Http404:
85 | function = None
86 |
87 | request.application = Application(name=None)
88 | if function:
89 | application = function.__module__.split(".")[0]
90 | request.application = Application(name=application)
91 |
92 | if request_exclusion(request, function):
93 | return
94 |
95 | logger_ip = f" from {request.ip}" if get_client_ip and settings.request.ip else ""
96 | logger.log(
97 | level,
98 | f"[{request.method}] [{request.status}] "
99 | f'{getattr(request, "user", None) or "Anonymous"} '
100 | f"at {request.uri}{logger_ip}",
101 | extra={"action": "request", "event": request},
102 | )
103 |
104 |
105 | @receiver(got_request_exception, weak=False)
106 | def request_exception(sender, request, **kwargs):
107 | """
108 | Exception logging for requests, via the django signal.
109 |
110 | The signal can also return a WSGIRequest exception, which does not
111 | have all fields that are needed.
112 |
113 | :return: -
114 | """
115 |
116 | status = int(request.status_code) if hasattr(request, "status_code") else None
117 | method = request.method if hasattr(request, "method") else None
118 | reason = request.reason_phrase if hasattr(request, "reason_phrase") else None
119 | level = logging.CRITICAL if status and status <= 500 else logging.WARNING
120 |
121 | is_wsgi = isinstance(request, WSGIRequest)
122 |
123 | logger.log(
124 | level,
125 | f'[{method or "UNK"}] [{status or "UNK"}] '
126 | f'{is_wsgi and "[WSGIResponse] "}'
127 | f'Exception: {reason or "UNKNOWN"}',
128 | )
129 |
130 |
131 | @receiver(request_finished, weak=False)
132 | def thread_cleanup(sender, **kwargs):
133 | """
134 | This signal just calls the thread cleanup function to make sure,
135 | that the custom thread object is always clean for the next request.
136 | This needs to be always the last function registered by the receiver!
137 |
138 | :return: -
139 | """
140 | AutomatedLoggingMiddleware.cleanup()
141 |
--------------------------------------------------------------------------------
/automated_logging/signals/save.py:
--------------------------------------------------------------------------------
1 | """
2 | File handles every signal related to the saving/deletion of django models.
3 | """
4 |
5 | import logging
6 | from collections import namedtuple
7 | from datetime import datetime
8 | from pprint import pprint
9 | from typing import Any
10 |
11 | from django.core.exceptions import ObjectDoesNotExist
12 | from django.db import transaction
13 | from django.db.models.signals import pre_save, post_save, post_delete
14 | from django.dispatch import receiver
15 |
16 | from automated_logging.models import (
17 | ModelValueModification,
18 | ModelField,
19 | ModelMirror,
20 | Application,
21 | )
22 | from automated_logging.settings import settings
23 | from automated_logging.signals import (
24 | model_exclusion,
25 | lazy_model_exclusion,
26 | field_exclusion,
27 | )
28 | from automated_logging.helpers import (
29 | get_or_create_meta,
30 | Operation,
31 | get_or_create_model_event,
32 | )
33 | from automated_logging.helpers.enums import PastOperationMap
34 |
35 | ChangeSet = namedtuple("ChangeSet", ("deleted", "added", "changed"))
36 | logger = logging.getLogger(__name__)
37 |
38 |
39 | def normalize_save_value(value: Any):
40 | """normalize the values given to the function to make stuff more readable"""
41 | if value is None or value == "":
42 | return None
43 | if isinstance(value, str):
44 | return value
45 |
46 | return repr(value)
47 |
48 |
49 | @receiver(pre_save, weak=False)
50 | @transaction.atomic
51 | def pre_save_signal(sender, instance, **kwargs) -> None:
52 | """
53 | Compares the current instance and old instance (fetched via the pk)
54 | and generates a dictionary of changes
55 |
56 | :param sender:
57 | :param instance:
58 | :param kwargs:
59 | :return: None
60 | """
61 | get_or_create_meta(instance)
62 | # clear the event to be sure
63 | instance._meta.dal.event = None
64 |
65 | operation = Operation.MODIFY
66 | try:
67 | pre = sender.objects.get(pk=instance.pk)
68 | except ObjectDoesNotExist:
69 | # __dict__ is used on pre, therefore we need to create a function
70 | # that uses __dict__ too, but returns nothing.
71 |
72 | pre = lambda _: None
73 | operation = Operation.CREATE
74 |
75 | excluded = lazy_model_exclusion(instance, operation, instance.__class__)
76 | if excluded:
77 | return
78 |
79 | old, new = pre.__dict__, instance.__dict__
80 |
81 | previously = set(
82 | k for k in old.keys() if not k.startswith("_") and old[k] is not None
83 | )
84 | currently = set(
85 | k for k in new.keys() if not k.startswith("_") and new[k] is not None
86 | )
87 |
88 | added = currently.difference(previously)
89 | deleted = previously.difference(currently)
90 | changed = {
91 | k
92 | for k in
93 | # take all keys from old and new, and only use those that are in both
94 | set(old.keys()) & set(new.keys())
95 | # remove values that have been added or deleted (optimization)
96 | .difference(added).difference(deleted)
97 | # check if the value is equal, if not they are not changed
98 | if old[k] != new[k]
99 | }
100 |
101 | summary = [
102 | *(
103 | {
104 | "operation": Operation.CREATE,
105 | "previous": None,
106 | "current": new[k],
107 | "key": k,
108 | }
109 | for k in added
110 | ),
111 | *(
112 | {
113 | "operation": Operation.DELETE,
114 | "previous": old[k],
115 | "current": None,
116 | "key": k,
117 | }
118 | for k in deleted
119 | ),
120 | *(
121 | {
122 | "operation": Operation.MODIFY,
123 | "previous": old[k],
124 | "current": new[k],
125 | "key": k,
126 | }
127 | for k in changed
128 | ),
129 | ]
130 |
131 | # exclude fields not present in _meta.get_fields
132 | fields = {f.name: f for f in instance._meta.get_fields()}
133 | extra = {f.attname: f for f in instance._meta.get_fields() if hasattr(f, "attname")}
134 | fields = {**extra, **fields}
135 |
136 | summary = [s for s in summary if s["key"] in fields.keys()]
137 |
138 | # field exclusion
139 | summary = [
140 | s
141 | for s in summary
142 | if not field_exclusion(s["key"], instance, instance.__class__)
143 | ]
144 |
145 | model = ModelMirror()
146 | model.name = sender.__name__
147 | model.application = Application(name=instance._meta.app_label)
148 |
149 | modifications = []
150 | for entry in summary:
151 | field = ModelField()
152 | field.name = entry["key"]
153 | field.mirror = model
154 |
155 | field.type = fields[entry["key"]].__class__.__name__
156 |
157 | modification = ModelValueModification()
158 | modification.operation = entry["operation"]
159 | modification.field = field
160 |
161 | modification.previous = normalize_save_value(entry["previous"])
162 | modification.current = normalize_save_value(entry["current"])
163 |
164 | modifications.append(modification)
165 |
166 | instance._meta.dal.modifications = modifications
167 |
168 | if settings.model.performance:
169 | instance._meta.dal.performance = datetime.now()
170 |
171 |
172 | def post_processor(status, sender, instance, updated=None, suffix="") -> None:
173 | """
174 | Due to the fact that both post_delete and post_save have
175 | the same logic for propagating changes, we have this helper class
176 | to do so, just simply wraps and logs the data the handler needs.
177 |
178 | The event gets created here instead of the handler to keep
179 | everything consistent and have the handler as simple as possible.
180 |
181 | :param status: Operation
182 | :param sender: model class
183 | :param instance: model instance
184 | :param updated: updated fields
185 | :param suffix: suffix to be added to the message
186 | :return: None
187 | """
188 | past = {v: k for k, v in PastOperationMap.items()}
189 |
190 | get_or_create_meta(instance)
191 |
192 | event, _ = get_or_create_model_event(instance, status, force=True, extra=True)
193 | modifications = getattr(instance._meta.dal, "modifications", [])
194 |
195 | # clear the modifications meta list
196 | instance._meta.dal.modifications = []
197 |
198 | if len(modifications) == 0 and status == Operation.MODIFY:
199 | # if the event is modify, but nothing changed, don't actually propagate
200 | return
201 |
202 | logger.log(
203 | settings.model.loglevel,
204 | f'{event.user or "Anonymous"} {past[status]} '
205 | f"{event.entry.mirror.application}.{sender.__name__} | "
206 | f"Instance: {instance!r}{suffix}",
207 | extra={
208 | "action": "model",
209 | "data": {"status": status, "instance": instance},
210 | "event": event,
211 | "modifications": modifications,
212 | },
213 | )
214 |
215 |
216 | @receiver(post_save, weak=False)
217 | @transaction.atomic
218 | def post_save_signal(
219 | sender, instance, created, update_fields: frozenset, **kwargs
220 | ) -> None:
221 | """
222 | Signal is getting called after a save has been concluded. When this
223 | is the case we can be sure the save was successful and then only
224 | propagate the changes to the handler.
225 |
226 | :param sender: model class
227 | :param instance: model instance
228 | :param created: bool, was the model created?
229 | :param update_fields: which fields got explicitly updated?
230 | :param kwargs: django needs kwargs to be there
231 | :return: -
232 | """
233 | status = Operation.CREATE if created else Operation.MODIFY
234 | if lazy_model_exclusion(
235 | instance,
236 | status,
237 | instance.__class__,
238 | ):
239 | return
240 | get_or_create_meta(instance)
241 |
242 | suffix = f""
243 | if (
244 | status == Operation.MODIFY
245 | and hasattr(instance._meta.dal, "modifications")
246 | and settings.model.detailed_message
247 | ):
248 | suffix = (
249 | f" | Modifications: "
250 | f'{", ".join([m.short() for m in instance._meta.dal.modifications])}'
251 | )
252 |
253 | if update_fields is not None and hasattr(instance._meta.dal, "modifications"):
254 | instance._meta.dal.modifications = [
255 | m for m in instance._meta.dal.modifications if m.field.name in update_fields
256 | ]
257 |
258 | post_processor(status, sender, instance, update_fields, suffix)
259 |
260 |
261 | @receiver(post_delete, weak=False)
262 | @transaction.atomic
263 | def post_delete_signal(sender, instance, **kwargs) -> None:
264 | """
265 | Signal is getting called after instance deletion. We just redirect the
266 | event to the post_processor.
267 |
268 | TODO: consider doing a "delete snapshot"
269 |
270 | :param sender: model class
271 | :param instance: model instance
272 | :param kwargs: required bt django
273 | :return: -
274 | """
275 |
276 | get_or_create_meta(instance)
277 | instance._meta.dal.event = None
278 |
279 | if lazy_model_exclusion(instance, Operation.DELETE, instance.__class__):
280 | return
281 |
282 | post_processor(Operation.DELETE, sender, instance)
283 |
--------------------------------------------------------------------------------
/automated_logging/templates/dal/admin/view.html:
--------------------------------------------------------------------------------
1 | {% extends "admin/change_form.html" %}
2 | {% load i18n %}
3 |
4 | {% block submit_buttons_bottom %}
5 |
8 | {% endblock %}
9 |
--------------------------------------------------------------------------------
/automated_logging/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/indietyp/django-automated-logging/254bd35699259c41d650d79f0e142a7ed2c73dca/automated_logging/tests/__init__.py
--------------------------------------------------------------------------------
/automated_logging/tests/base.py:
--------------------------------------------------------------------------------
1 | """ Test base every unit test uses """
2 |
3 | import importlib
4 | import logging.config
5 | from copy import copy, deepcopy
6 |
7 | from django.conf import settings
8 | from django.contrib.auth import get_user_model
9 | from django.contrib.auth.models import AbstractUser
10 | from django.test import TestCase, RequestFactory
11 | from django.urls import path
12 |
13 | from automated_logging.helpers import namedtuple2dict
14 | from automated_logging.middleware import AutomatedLoggingMiddleware
15 | from automated_logging.models import ModelEvent, RequestEvent, UnspecifiedEvent
16 | from automated_logging.signals import cached_model_exclusion
17 |
18 | User: AbstractUser = get_user_model()
19 | USER_CREDENTIALS = {"username": "example", "password": "example"}
20 |
21 |
22 | def clear_cache():
23 | """utility method to clear the cache"""
24 | if hasattr(AutomatedLoggingMiddleware.thread, "dal"):
25 | delattr(AutomatedLoggingMiddleware.thread, "dal")
26 |
27 | import automated_logging.decorators
28 |
29 | # noinspection PyProtectedMember
30 | automated_logging.decorators._exclude_models.clear()
31 | # noinspection PyProtectedMember
32 | automated_logging.decorators._include_models.clear()
33 |
34 | cached_model_exclusion.cache_clear()
35 |
36 |
37 | class BaseTestCase(TestCase):
38 | def __init__(self, method_name):
39 | from django.conf import settings
40 |
41 | settings.AUTOMATED_LOGGING_DEV = True
42 |
43 | super().__init__(method_name)
44 |
45 | def request(self, method, view, data=None):
46 | """
47 | request a specific view and return the response.
48 |
49 | This is not ideal and super hacky. Backups the actual urlpatterns,
50 | and then overrides the urlpatterns with a temporary one and then
51 | inserts the new one again.
52 | """
53 |
54 | urlconf = importlib.import_module(settings.ROOT_URLCONF)
55 |
56 | backup = copy(urlconf.urlpatterns)
57 | urlconf.urlpatterns.clear()
58 | urlconf.urlpatterns.append(path("", view))
59 |
60 | response = self.client.generic(method, "/", data=data)
61 |
62 | urlconf.urlpatterns.clear()
63 | urlconf.urlpatterns.extend(backup)
64 |
65 | return response
66 |
67 | def setUp(self):
68 | """setUp the DAL specific test environment"""
69 | from django.conf import settings
70 | from automated_logging.settings import default, settings as conf
71 |
72 | self.user = User.objects.create_user(**USER_CREDENTIALS)
73 | self.user.save()
74 |
75 | self.original_config = deepcopy(settings.AUTOMATED_LOGGING)
76 |
77 | base = namedtuple2dict(default)
78 |
79 | settings.AUTOMATED_LOGGING.clear()
80 | for key, value in base.items():
81 | settings.AUTOMATED_LOGGING[key] = deepcopy(value)
82 |
83 | conf.load.cache_clear()
84 |
85 | self.setUpLogging()
86 | super().setUp()
87 |
88 | def tearDown(self) -> None:
89 | """tearDown the DAL specific environment"""
90 | from django.conf import settings
91 | from automated_logging.settings import settings as conf
92 |
93 | super().tearDown()
94 |
95 | self.tearDownLogging()
96 |
97 | settings.AUTOMATED_LOGGING.clear()
98 | for key, value in self.original_config.items():
99 | settings.AUTOMATED_LOGGING[key] = deepcopy(value)
100 |
101 | conf.load.cache_clear()
102 |
103 | clear_cache()
104 |
105 | @staticmethod
106 | def clear():
107 | """clear all events"""
108 | ModelEvent.objects.all().delete()
109 | RequestEvent.objects.all().delete()
110 | UnspecifiedEvent.objects.all().delete()
111 |
112 | def tearDownLogging(self):
113 | """
114 | replace our own logging config
115 | with the original to not break any other tests
116 | that might depend on it.
117 | """
118 | from django.conf import settings
119 |
120 | settings.LOGGING = self.logging_backup
121 | logging.config.dictConfig(settings.LOGGING)
122 |
123 | def setUpLogging(self):
124 | """sets up logging dict, so that we can actually use our own"""
125 | from django.conf import settings
126 |
127 | self.logging_backup = deepcopy(settings.LOGGING)
128 | settings.LOGGING = {
129 | "version": 1,
130 | "disable_existing_loggers": False,
131 | "root": {
132 | "level": "INFO",
133 | "handlers": ["console", "db"],
134 | },
135 | "formatters": {
136 | "verbose": {
137 | "format": "%(levelname)s %(asctime)s %(module)s "
138 | "%(process)d %(thread)d %(message)s"
139 | },
140 | "simple": {"format": "%(levelname)s %(message)s"},
141 | "syslog": {
142 | "format": "%(asctime)s %%LOCAL0-%(levelname) %(message)s"
143 | # 'format': '%(levelname)s %(message)s'
144 | },
145 | },
146 | "handlers": {
147 | "console": {
148 | "level": "INFO",
149 | "class": "logging.StreamHandler",
150 | "formatter": "verbose",
151 | },
152 | "db": {
153 | "level": "INFO",
154 | "class": "automated_logging.handlers.DatabaseHandler",
155 | },
156 | },
157 | "loggers": {
158 | "automated_logging": {
159 | "level": "INFO",
160 | "handlers": ["console", "db"],
161 | "propagate": False,
162 | },
163 | "django": {
164 | "level": "INFO",
165 | "handlers": ["console", "db"],
166 | "propagate": False,
167 | },
168 | },
169 | }
170 |
171 | logging.config.dictConfig(settings.LOGGING)
172 |
173 | def bypass_request_restrictions(self):
174 | """bypass all request default restrictions of DAL"""
175 | from django.conf import settings
176 | from automated_logging.settings import settings as conf
177 |
178 | settings.AUTOMATED_LOGGING["request"]["exclude"]["status"] = []
179 | settings.AUTOMATED_LOGGING["request"]["exclude"]["methods"] = []
180 | conf.load.cache_clear()
181 |
182 | self.clear()
183 |
--------------------------------------------------------------------------------
/automated_logging/tests/helpers.py:
--------------------------------------------------------------------------------
1 | """ test specific helpers """
2 |
3 | import string
4 | from random import choice
5 |
6 |
7 | def random_string(length=10):
8 | """generate a random string with the length specified"""
9 | return "".join(choice(string.ascii_letters) for _ in range(length))
10 |
--------------------------------------------------------------------------------
/automated_logging/tests/models.py:
--------------------------------------------------------------------------------
1 | import uuid
2 | from django.db.models import (
3 | CASCADE,
4 | CharField,
5 | DateTimeField,
6 | ForeignKey,
7 | ManyToManyField,
8 | Model,
9 | OneToOneField,
10 | UUIDField,
11 | )
12 |
13 | from automated_logging.decorators import exclude_model, include_model
14 |
15 |
16 | class TestBase(Model):
17 | """Base for all the test models"""
18 |
19 | id = UUIDField(default=uuid.uuid4, primary_key=True)
20 |
21 | created_at = DateTimeField(auto_now_add=True)
22 | updated_at = DateTimeField(auto_now=True)
23 |
24 | class Meta:
25 | abstract = True
26 | app_label = "automated_logging"
27 |
28 |
29 | class OrdinaryBaseTest(TestBase):
30 | """Ordinary base test. Has a random char field."""
31 |
32 | random = CharField(max_length=255, null=True)
33 | random2 = CharField(max_length=255, null=True)
34 |
35 | class Meta:
36 | abstract = True
37 | app_label = "automated_logging"
38 |
39 |
40 | class OrdinaryTest(OrdinaryBaseTest):
41 | """Ordinary test. Has a random char field."""
42 |
43 | class Meta:
44 | app_label = "automated_logging"
45 |
46 |
47 | class M2MTest(TestBase):
48 | """Used to test the Many-To-Many Relationship functionality of DAL"""
49 |
50 | relationship = ManyToManyField(OrdinaryTest)
51 |
52 | class Meta:
53 | app_label = "automated_logging"
54 |
55 |
56 | class ForeignKeyTest(TestBase):
57 | """Used to test ForeignKey functionality of DAL."""
58 |
59 | relationship = ForeignKey(OrdinaryTest, on_delete=CASCADE, null=True)
60 |
61 | class Meta:
62 | app_label = "automated_logging"
63 |
64 |
65 | class OneToOneTest(TestBase):
66 | """Used to test the One-To-One Relationship functionality of DAL."""
67 |
68 | relationship = OneToOneField(OrdinaryTest, on_delete=CASCADE, null=True)
69 |
70 | class Meta:
71 | app_label = "automated_logging"
72 |
73 |
74 | class SpeedTest(TestBase):
75 | """Used to test the speed of DAL"""
76 |
77 | for idx in range(100):
78 | exec(f"column{idx} = CharField(max_length=15, null=True)")
79 |
80 | class Meta:
81 | app_label = "automated_logging"
82 |
83 |
84 | class FullClassBasedExclusionTest(OrdinaryBaseTest):
85 | """Used to test the full model exclusion via meta class"""
86 |
87 | class Meta:
88 | app_label = "automated_logging"
89 |
90 | class LoggingIgnore:
91 | complete = True
92 |
93 |
94 | class PartialClassBasedExclusionTest(OrdinaryBaseTest):
95 | """Used to test partial ignore via fields"""
96 |
97 | class Meta:
98 | app_label = "automated_logging"
99 |
100 | class LoggingIgnore:
101 | fields = ["random"]
102 | operations = ["delete"]
103 |
104 |
105 | @exclude_model
106 | class FullDecoratorBasedExclusionTest(OrdinaryBaseTest):
107 | """Used to test full decorator exclusion"""
108 |
109 | class Meta:
110 | app_label = "automated_logging"
111 |
112 |
113 | @exclude_model(operations=["delete"], fields=["random"])
114 | class PartialDecoratorBasedExclusionTest(OrdinaryBaseTest):
115 | """Used to test partial decorator exclusion"""
116 |
117 | class Meta:
118 | app_label = "automated_logging"
119 |
120 |
121 | @include_model
122 | class DecoratorOverrideExclusionTest(OrdinaryBaseTest):
123 | """
124 | Used to check if include_model
125 | has precedence over class based configuration
126 | """
127 |
128 | class Meta:
129 | app_label = "automated_logging"
130 |
131 | class LoggingIgnore:
132 | complete = True
133 |
--------------------------------------------------------------------------------
/automated_logging/tests/test_handler.py:
--------------------------------------------------------------------------------
1 | # test max_age (rework?)
2 | # test save
3 | # test module removal
4 | import logging
5 | import logging.config
6 | from datetime import timedelta
7 | import time
8 |
9 | from django.http import JsonResponse
10 | from marshmallow import ValidationError
11 |
12 | from automated_logging.helpers.exceptions import CouldNotConvertError
13 | from automated_logging.models import ModelEvent, RequestEvent, UnspecifiedEvent
14 | from automated_logging.tests.models import OrdinaryTest
15 | from automated_logging.tests.base import BaseTestCase
16 |
17 |
18 | class TestDatabaseHandlerTestCase(BaseTestCase):
19 | @staticmethod
20 | def view(request):
21 | return JsonResponse({})
22 |
23 | def test_max_age(self):
24 | from django.conf import settings
25 | from automated_logging.settings import settings as conf
26 |
27 | duration = timedelta(seconds=1)
28 | logger = logging.getLogger(__name__)
29 |
30 | settings.AUTOMATED_LOGGING["model"]["max_age"] = duration
31 | settings.AUTOMATED_LOGGING["request"]["max_age"] = duration
32 | settings.AUTOMATED_LOGGING["unspecified"]["max_age"] = duration
33 |
34 | conf.load.cache_clear()
35 |
36 | self.clear()
37 | self.bypass_request_restrictions()
38 |
39 | OrdinaryTest().save()
40 | self.request("GET", self.view)
41 | logger.info("I have the high ground Anakin!")
42 |
43 | self.assertEqual(ModelEvent.objects.count(), 1)
44 | self.assertEqual(RequestEvent.objects.count(), 1)
45 | self.assertEqual(UnspecifiedEvent.objects.count(), 1)
46 |
47 | time.sleep(2)
48 |
49 | logger.info("A surprise, to be sure, but a welcome one.")
50 |
51 | self.assertEqual(ModelEvent.objects.count(), 0)
52 | self.assertEqual(RequestEvent.objects.count(), 0)
53 | self.assertEqual(UnspecifiedEvent.objects.count(), 1)
54 |
55 | def test_max_age_input_methods(self):
56 | from django.conf import settings
57 | from automated_logging.settings import settings as conf
58 |
59 | logger = logging.getLogger(__name__)
60 |
61 | settings.AUTOMATED_LOGGING["unspecified"]["max_age"] = timedelta(seconds=1)
62 | conf.load.cache_clear()
63 | self.clear()
64 |
65 | logger.info("I will do what I must.")
66 | time.sleep(1)
67 | logger.info("Hello There.")
68 | self.assertEqual(UnspecifiedEvent.objects.count(), 1)
69 |
70 | settings.AUTOMATED_LOGGING["unspecified"]["max_age"] = 1
71 | conf.load.cache_clear()
72 | self.clear()
73 |
74 | logger.info("A yes, the negotiator.")
75 | time.sleep(1)
76 | logger.info("Your tactics confuse and frighten me, sir.")
77 | self.assertEqual(UnspecifiedEvent.objects.count(), 1)
78 |
79 | settings.AUTOMATED_LOGGING["unspecified"]["max_age"] = "PT1S"
80 | conf.load.cache_clear()
81 | self.clear()
82 |
83 | logger.info("Don't make me kill you.")
84 | time.sleep(1)
85 | logger.info("An old friend from the dead.")
86 | self.assertEqual(UnspecifiedEvent.objects.count(), 1)
87 |
88 | def test_batching(self):
89 | from django.conf import settings
90 |
91 | logger = logging.getLogger(__name__)
92 |
93 | config = settings.LOGGING
94 |
95 | config["handlers"]["db"]["batch"] = 10
96 | logging.config.dictConfig(config)
97 |
98 | self.clear()
99 | for _ in range(9):
100 | logger.info("It's a trick. Send no reply")
101 |
102 | self.assertEqual(UnspecifiedEvent.objects.count(), 0)
103 | logger.info("I can't see a thing. My cockpit's fogging")
104 | self.assertEqual(UnspecifiedEvent.objects.count(), 10)
105 |
106 | config["handlers"]["db"]["batch"] = 1
107 | logging.config.dictConfig(config)
108 |
--------------------------------------------------------------------------------
/automated_logging/tests/test_m2m.py:
--------------------------------------------------------------------------------
1 | """ Test all Many-To-Many related things """
2 |
3 | import random
4 |
5 | from automated_logging.helpers import Operation
6 | from automated_logging.models import ModelEvent
7 | from automated_logging.tests.models import (
8 | M2MTest,
9 | OrdinaryTest,
10 | OneToOneTest,
11 | ForeignKeyTest,
12 | )
13 | from automated_logging.signals.m2m import find_m2m_rel
14 | from automated_logging.tests.base import BaseTestCase
15 | from automated_logging.tests.helpers import random_string
16 |
17 |
18 | class LoggedOutM2MRelationshipsTestCase(BaseTestCase):
19 | def setUp(self):
20 | super().setUp()
21 |
22 | # delete all previous model events
23 | ModelEvent.objects.all().delete()
24 |
25 | @staticmethod
26 | def generate_children(samples=10):
27 | """generate X children that are going to be used in various tests"""
28 | children = [OrdinaryTest(random=random_string()) for _ in range(samples)]
29 | [c.save() for c in children]
30 |
31 | return children
32 |
33 | def test_add(self):
34 | """check if adding X members works correctly"""
35 |
36 | samples = 10
37 | children = self.generate_children(samples)
38 |
39 | m2m = M2MTest()
40 | m2m.save()
41 |
42 | ModelEvent.objects.all().delete()
43 | m2m.relationship.add(*children)
44 | m2m.save()
45 |
46 | events = ModelEvent.objects.all()
47 | self.assertEqual(events.count(), 1)
48 |
49 | event = events[0]
50 | self.assertEqual(event.modifications.count(), 0)
51 | self.assertEqual(event.relationships.count(), samples)
52 |
53 | children = {str(c.id): c for c in children}
54 |
55 | for relationship in event.relationships.all():
56 | self.assertEqual(relationship.operation, int(Operation.CREATE))
57 | self.assertEqual(relationship.field.name, "relationship")
58 | self.assertIn(relationship.entry.primary_key, children)
59 |
60 | def test_delete(self):
61 | """check if deleting X elements works correctly"""
62 |
63 | samples = 10
64 | removed = 5
65 | children = self.generate_children(samples)
66 |
67 | m2m = M2MTest()
68 | m2m.save()
69 | m2m.relationship.add(*children)
70 | m2m.save()
71 | ModelEvent.objects.all().delete()
72 |
73 | selected = random.sample(children, k=removed)
74 | m2m.relationship.remove(*selected)
75 | m2m.save()
76 |
77 | events = ModelEvent.objects.all()
78 | self.assertEqual(events.count(), 1)
79 |
80 | event = events[0]
81 | self.assertEqual(event.modifications.count(), 0)
82 | self.assertEqual(event.relationships.count(), removed)
83 |
84 | children = {str(c.id): c for c in children}
85 | for relationship in event.relationships.all():
86 | self.assertEqual(relationship.operation, int(Operation.DELETE))
87 | self.assertEqual(relationship.field.name, "relationship")
88 | self.assertIn(relationship.entry.primary_key, children)
89 |
90 | def test_clear(self):
91 | """test if clearing all elements works correctly"""
92 |
93 | samples = 10
94 | children = self.generate_children(samples)
95 |
96 | m2m = M2MTest()
97 | m2m.save()
98 | m2m.relationship.add(*children)
99 | m2m.save()
100 | ModelEvent.objects.all().delete()
101 |
102 | m2m.relationship.clear()
103 | m2m.save()
104 |
105 | events = ModelEvent.objects.all()
106 | self.assertEqual(events.count(), 1)
107 |
108 | event = events[0]
109 | self.assertEqual(event.modifications.count(), 0)
110 | self.assertEqual(event.relationships.count(), samples)
111 |
112 | children = {str(c.id): c for c in children}
113 | for relationship in event.relationships.all():
114 | self.assertEqual(relationship.operation, int(Operation.DELETE))
115 | self.assertEqual(relationship.field.name, "relationship")
116 | self.assertIn(relationship.entry.primary_key, children)
117 |
118 | def test_one2one(self):
119 | """
120 | test if OneToOne are correctly recognized,
121 | should be handled by save.py
122 | """
123 |
124 | o2o = OneToOneTest()
125 | o2o.save()
126 |
127 | subject = OrdinaryTest(random=random_string())
128 | subject.save()
129 | ModelEvent.objects.all().delete()
130 |
131 | o2o.relationship = subject
132 | o2o.save()
133 |
134 | events = ModelEvent.objects.all()
135 | self.assertEqual(events.count(), 1)
136 |
137 | event = events[0]
138 |
139 | self.assertEqual(event.modifications.count(), 1)
140 | self.assertEqual(event.relationships.count(), 0)
141 |
142 | modification = event.modifications.all()[0]
143 | self.assertEqual(modification.field.name, "relationship_id")
144 | self.assertEqual(modification.current, repr(subject.pk))
145 |
146 | def test_foreign(self):
147 | """
148 | test if ForeignKey are correctly recognized.
149 |
150 | should be handled by save.py
151 | """
152 |
153 | fk = ForeignKeyTest()
154 | fk.save()
155 |
156 | subject = OrdinaryTest(random=random_string())
157 | subject.save()
158 |
159 | ModelEvent.objects.all().delete()
160 |
161 | fk.relationship = subject
162 | fk.save()
163 |
164 | events = ModelEvent.objects.all()
165 | self.assertEqual(events.count(), 1)
166 |
167 | event = events[0]
168 |
169 | self.assertEqual(event.modifications.count(), 1)
170 | self.assertEqual(event.relationships.count(), 0)
171 |
172 | modification = event.modifications.all()[0]
173 | self.assertEqual(modification.field.name, "relationship_id")
174 | self.assertEqual(modification.current, repr(subject.pk))
175 |
176 | # def test_no_change(self):
177 | # samples = 10
178 | # children = self.generate_children(samples)
179 | #
180 | # subject = OrdinaryTest(random=random_string())
181 | # subject.save()
182 | #
183 | # m2m = M2MTest()
184 | # m2m.save()
185 | # m2m.relationship.add(*children)
186 | # m2m.save()
187 | # ModelEvent.objects.all().delete()
188 | #
189 | # m2m.relationship.remove(subject)
190 | # m2m.save()
191 | #
192 | # events = ModelEvent.objects.all()
193 | # # TODO: fails
194 | # # self.assertEqual(events.count(), 0)
195 |
196 | def test_find(self):
197 | m2m = M2MTest()
198 | m2m.save()
199 |
200 | self.assertIsNotNone(find_m2m_rel(m2m.relationship.through, M2MTest))
201 | self.assertIsNone(find_m2m_rel(m2m.relationship.through, OrdinaryTest))
202 |
203 | def test_reverse(self):
204 | m2m = M2MTest()
205 | m2m.save()
206 |
207 | subject = OrdinaryTest(random=random_string())
208 | subject.save()
209 |
210 | ModelEvent.objects.all().delete()
211 |
212 | subject.m2mtest_set.add(m2m)
213 | subject.save()
214 |
215 | events = ModelEvent.objects.all()
216 | self.assertEqual(events.count(), 1)
217 |
218 | event = events[0]
219 |
220 | self.assertEqual(event.modifications.count(), 0)
221 | self.assertEqual(event.relationships.count(), 1)
222 | self.assertEqual(event.entry.mirror.name, "M2MTest")
223 |
224 | relationship = event.relationships.all()[0]
225 | self.assertEqual(relationship.operation, int(Operation.CREATE))
226 | self.assertEqual(relationship.field.name, "relationship")
227 | self.assertEqual(relationship.entry.primary_key, str(subject.id))
228 |
229 |
230 | # TODO: test lazy_model_exclusion
231 |
--------------------------------------------------------------------------------
/automated_logging/tests/test_misc.py:
--------------------------------------------------------------------------------
1 | from datetime import timedelta
2 |
3 | from marshmallow import ValidationError
4 |
5 | from automated_logging.signals import _function_model_exclusion
6 | from automated_logging.tests.base import BaseTestCase
7 |
8 |
9 | class MiscellaneousTestCase(BaseTestCase):
10 | def test_no_sender(self):
11 | self.assertIsNone(_function_model_exclusion(None, "", ""))
12 |
13 | def test_wrong_duration(self):
14 | from django.conf import settings
15 | from automated_logging.settings import settings as conf
16 |
17 | settings.AUTOMATED_LOGGING["unspecified"]["max_age"] = complex(1, 1)
18 | conf.load.cache_clear()
19 | self.clear()
20 |
21 | self.assertRaises(ValidationError, conf.load)
22 |
23 | settings.AUTOMATED_LOGGING["unspecified"]["max_age"] = (
24 | timedelta.max.total_seconds() + 1
25 | )
26 | conf.load.cache_clear()
27 | self.clear()
28 |
29 | self.assertRaises(ValidationError, conf.load)
30 |
31 | settings.AUTOMATED_LOGGING["unspecified"]["max_age"] = "Haha, error go brrr"
32 | conf.load.cache_clear()
33 | self.clear()
34 |
35 | self.assertRaises(ValidationError, conf.load)
36 |
37 | def test_unsupported_search_string(self):
38 | from django.conf import settings
39 | from automated_logging.settings import settings as conf
40 |
41 | settings.AUTOMATED_LOGGING["unspecified"]["exclude"]["applications"] = [
42 | "te:abc"
43 | ]
44 | conf.load.cache_clear()
45 | self.clear()
46 |
47 | self.assertRaises(ValidationError, conf.load)
48 |
49 | # settings.AUTOMATED_LOGGING['unspecified']['exclude']['applications'] = []
50 | conf.load.cache_clear()
51 | self.clear()
52 |
53 | def test_duration_none(self):
54 | from django.conf import settings
55 | from automated_logging.settings import settings as conf
56 |
57 | settings.AUTOMATED_LOGGING["unspecified"]["max_age"] = None
58 | conf.load.cache_clear()
59 | self.clear()
60 |
61 | self.assertIsNone(conf.unspecified.max_age)
62 |
--------------------------------------------------------------------------------
/automated_logging/tests/test_request.py:
--------------------------------------------------------------------------------
1 | """ Test everything related to requests """
2 |
3 | import json
4 | from copy import deepcopy
5 |
6 | from django.http import JsonResponse
7 |
8 | from automated_logging.models import RequestEvent
9 | from automated_logging.tests.base import BaseTestCase, USER_CREDENTIALS
10 | from automated_logging.tests.helpers import random_string
11 |
12 |
13 | class LoggedOutRequestsTestCase(BaseTestCase):
14 | def setUp(self):
15 | from django.conf import settings
16 | from automated_logging.settings import settings as conf
17 |
18 | super().setUp()
19 |
20 | settings.AUTOMATED_LOGGING["request"]["exclude"]["applications"] = []
21 | conf.load.cache_clear()
22 |
23 | RequestEvent.objects.all().delete()
24 |
25 | @staticmethod
26 | def view(request):
27 | return JsonResponse({})
28 |
29 | def test_simple(self):
30 | self.bypass_request_restrictions()
31 |
32 | self.request("GET", self.view)
33 |
34 | events = RequestEvent.objects.all()
35 | self.assertEqual(events.count(), 1)
36 |
37 | event = events[0]
38 |
39 | self.assertEqual(event.user, None)
40 |
41 |
42 | class LoggedInRequestsTestCase(BaseTestCase):
43 | def setUp(self):
44 | from django.conf import settings
45 | from automated_logging.settings import settings as conf
46 |
47 | super().setUp()
48 |
49 | settings.AUTOMATED_LOGGING["request"]["exclude"]["applications"] = []
50 | conf.load.cache_clear()
51 |
52 | self.client.login(**USER_CREDENTIALS)
53 |
54 | RequestEvent.objects.all().delete()
55 |
56 | @staticmethod
57 | def view(request):
58 | return JsonResponse({})
59 |
60 | def test_simple(self):
61 | self.bypass_request_restrictions()
62 |
63 | self.request("GET", self.view)
64 |
65 | events = RequestEvent.objects.all()
66 | self.assertEqual(events.count(), 1)
67 |
68 | event = events[0]
69 |
70 | self.assertEqual(event.ip, "127.0.0.1")
71 | self.assertEqual(event.user, self.user)
72 | self.assertEqual(event.status, 200)
73 | self.assertEqual(event.method, "GET")
74 | self.assertEqual(event.uri, "/")
75 |
76 | def test_404(self):
77 | self.bypass_request_restrictions()
78 |
79 | self.client.get(f"/{random_string()}")
80 |
81 | events = RequestEvent.objects.all()
82 | self.assertEqual(events.count(), 1)
83 |
84 | event = events[0]
85 | self.assertEqual(event.status, 404)
86 |
87 | @staticmethod
88 | def exception(request):
89 | raise Exception
90 |
91 | def test_500(self):
92 | self.bypass_request_restrictions()
93 |
94 | try:
95 | self.request("GET", self.exception)
96 | except:
97 | pass
98 |
99 | events = RequestEvent.objects.all()
100 | self.assertEqual(events.count(), 1)
101 |
102 | event = events[0]
103 | self.assertGreaterEqual(event.status, 500)
104 |
105 |
106 | class DataRecordingRequestsTestCase(BaseTestCase):
107 | def setUp(self):
108 | from django.conf import settings
109 | from automated_logging.settings import settings as conf
110 |
111 | super().setUp()
112 |
113 | settings.AUTOMATED_LOGGING["request"]["exclude"]["applications"] = []
114 | settings.AUTOMATED_LOGGING["request"]["data"]["enabled"] = [
115 | "response",
116 | "request",
117 | ]
118 | conf.load.cache_clear()
119 |
120 | self.client.login(**USER_CREDENTIALS)
121 |
122 | RequestEvent.objects.all().delete()
123 |
124 | @staticmethod
125 | def view(request):
126 | return JsonResponse({"test": "example"})
127 |
128 | def test_payload(self):
129 | # TODO: preliminary until request/response parsing is implemented
130 | from django.conf import settings
131 | from automated_logging.settings import settings as conf
132 |
133 | self.bypass_request_restrictions()
134 |
135 | settings.AUTOMATED_LOGGING["request"]["data"]["enabled"] = [
136 | "request",
137 | "response",
138 | ]
139 | conf.load.cache_clear()
140 |
141 | self.request("GET", self.view, data=json.dumps({"X": "Y"}))
142 |
143 | events = RequestEvent.objects.all()
144 | self.assertEqual(events.count(), 1)
145 |
146 | response = json.dumps({"test": "example"})
147 | request = json.dumps({"X": "Y"})
148 | event = events[0]
149 | self.assertEqual(event.response.content.decode(), response)
150 | self.assertEqual(event.request.content.decode(), request)
151 |
152 | def test_exclusion_by_application(self):
153 | self.request("GET", self.view)
154 | self.assertEqual(RequestEvent.objects.count(), 0)
155 |
--------------------------------------------------------------------------------
/automated_logging/tests/test_save.py:
--------------------------------------------------------------------------------
1 | """ Test the save functionality """
2 |
3 | import datetime
4 |
5 | from django.http import JsonResponse
6 |
7 | from automated_logging.helpers import Operation
8 | from automated_logging.models import ModelEvent
9 | from automated_logging.tests.base import BaseTestCase, USER_CREDENTIALS
10 | from automated_logging.tests.helpers import random_string
11 | from automated_logging.tests.models import OrdinaryTest
12 |
13 |
14 | class LoggedOutSaveModificationsTestCase(BaseTestCase):
15 | def setUp(self):
16 | super().setUp()
17 |
18 | # delete all previous model events
19 | ModelEvent.objects.all().delete()
20 |
21 | def test_create_simple_value(self):
22 | """
23 | test if creation results in the correct fields
24 | :return:
25 | """
26 | self.bypass_request_restrictions()
27 | value = random_string()
28 |
29 | instance = OrdinaryTest()
30 | instance.random = value
31 | instance.save()
32 |
33 | events = ModelEvent.objects.all()
34 | self.assertEqual(events.count(), 1)
35 |
36 | event = events[0]
37 |
38 | self.assertEqual(event.operation, int(Operation.CREATE))
39 | self.assertEqual(event.user, None)
40 |
41 | self.assertEqual(event.entry.primary_key, str(instance.pk))
42 | self.assertEqual(event.entry.value, repr(instance))
43 |
44 | self.assertEqual(event.entry.mirror.name, "OrdinaryTest")
45 | self.assertEqual(event.entry.mirror.application.name, "automated_logging")
46 |
47 | modifications = event.modifications.all()
48 | # pk and random added and modified
49 | self.assertEqual(modifications.count(), 2)
50 | self.assertEqual({m.field.name for m in modifications}, {"random", "id"})
51 |
52 | modification = [m for m in modifications if m.field.name == "random"][0]
53 | self.assertEqual(modification.operation, int(Operation.CREATE))
54 | self.assertEqual(modification.previous, None)
55 | self.assertEqual(modification.current, value)
56 | self.assertEqual(modification.event, event)
57 |
58 | self.assertEqual(modification.field.name, "random")
59 | self.assertEqual(modification.field.type, "CharField")
60 |
61 | relationships = event.relationships.all()
62 | self.assertEqual(relationships.count(), 0)
63 |
64 | def test_modify(self):
65 | """
66 | test if modification results
67 | in proper delete and create field operations
68 | :return:
69 | """
70 | previous, current = random_string(10), random_string(10)
71 |
72 | # create instance
73 | instance = OrdinaryTest()
74 | instance.random = previous
75 | instance.save()
76 |
77 | # delete all stuff related to the instance events to have a clean slate
78 | ModelEvent.objects.all().delete()
79 |
80 | instance.random = current
81 | instance.save()
82 |
83 | events = ModelEvent.objects.all()
84 | self.assertEqual(events.count(), 1)
85 |
86 | event = events[0]
87 |
88 | self.assertEqual(event.user, None)
89 | self.assertEqual(event.operation, int(Operation.MODIFY))
90 |
91 | modifications = event.modifications.all()
92 | self.assertEqual(modifications.count(), 1)
93 |
94 | modification = modifications[0]
95 | self.assertEqual(modification.operation, int(Operation.MODIFY))
96 | self.assertEqual(modification.previous, previous)
97 | self.assertEqual(modification.current, current)
98 |
99 | relationships = event.relationships.all()
100 | self.assertEqual(relationships.count(), 0)
101 |
102 | def test_honor_save(self):
103 | """
104 | test if saving honors the only attribute
105 |
106 | :return:
107 | """
108 | previous1, random1, random2 = (
109 | random_string(10),
110 | random_string(10),
111 | random_string(10),
112 | )
113 |
114 | # create instance
115 | instance = OrdinaryTest()
116 | instance.random = previous1
117 | instance.save()
118 |
119 | ModelEvent.objects.all().delete()
120 |
121 | instance.random = random1
122 | instance.random2 = random2
123 | instance.save(update_fields=["random2"])
124 |
125 | events = ModelEvent.objects.all()
126 | self.assertEqual(events.count(), 1)
127 |
128 | event = events[0]
129 | modifications = event.modifications.all()
130 | self.assertEqual(modifications.count(), 1)
131 |
132 | modification = modifications[0]
133 | self.assertEqual(modification.operation, int(Operation.CREATE))
134 | self.assertEqual(modification.field.name, "random2")
135 |
136 | def test_delete(self):
137 | """
138 | test if deletion is working correctly and records all the changes
139 | :return:
140 | """
141 | value = random_string(10)
142 |
143 | # create instance
144 | instance = OrdinaryTest()
145 | instance.random = value
146 | instance.save()
147 |
148 | pk = instance.pk
149 | re = repr(instance)
150 |
151 | ModelEvent.objects.all().delete()
152 |
153 | instance.delete()
154 |
155 | # DUP of save
156 | events = ModelEvent.objects.all()
157 | self.assertEqual(events.count(), 1)
158 |
159 | event = events[0]
160 | self.assertEqual(event.operation, int(Operation.DELETE))
161 | self.assertEqual(event.user, None)
162 |
163 | self.assertEqual(event.entry.primary_key, str(pk))
164 | self.assertEqual(event.entry.value, re)
165 |
166 | # DELETE does currently not record deleted values
167 | modifications = event.modifications.all()
168 | self.assertEqual(modifications.count(), 0)
169 |
170 | def test_reproducibility(self):
171 | """
172 | test if all the changes where done
173 | correctly so that you can properly derive the
174 | current state from all the accumulated changes
175 |
176 | TODO
177 | """
178 | pass
179 |
180 | def test_performance(self):
181 | """
182 | test if setting the performance parameter works correctly
183 |
184 | :return:
185 | """
186 | from django.conf import settings
187 | from automated_logging.settings import settings as conf
188 |
189 | self.bypass_request_restrictions()
190 |
191 | settings.AUTOMATED_LOGGING["model"]["performance"] = True
192 | conf.load.cache_clear()
193 |
194 | ModelEvent.objects.all().delete()
195 | instance = OrdinaryTest()
196 | instance.random = random_string(10)
197 | checkpoint = datetime.datetime.now()
198 | instance.save()
199 | checkpoint = datetime.datetime.now() - checkpoint
200 |
201 | events = ModelEvent.objects.all()
202 | self.assertEqual(events.count(), 1)
203 |
204 | event = events[0]
205 | self.assertIsNotNone(event.performance)
206 | self.assertLess(event.performance.total_seconds(), checkpoint.total_seconds())
207 |
208 | def test_snapshot(self):
209 | from django.conf import settings
210 | from automated_logging.settings import settings as conf
211 |
212 | self.bypass_request_restrictions()
213 |
214 | settings.AUTOMATED_LOGGING["model"]["snapshot"] = True
215 | conf.load.cache_clear()
216 |
217 | instance = OrdinaryTest(random=random_string())
218 | instance.save()
219 |
220 | events = ModelEvent.objects.all()
221 | self.assertEqual(events.count(), 1)
222 |
223 | event = events[0]
224 | self.assertIsNotNone(event.snapshot)
225 | self.assertEqual(instance, event.snapshot)
226 |
227 |
228 | class LoggedInSaveModificationsTestCase(BaseTestCase):
229 | def setUp(self):
230 | super().setUp()
231 |
232 | self.client.login(**USER_CREDENTIALS)
233 |
234 | self.clear()
235 |
236 | @staticmethod
237 | def view(request):
238 | value = random_string()
239 |
240 | instance = OrdinaryTest()
241 | instance.random = value
242 | instance.save()
243 |
244 | return JsonResponse({})
245 |
246 | def test_user(self):
247 | """Test if DAL recognizes the user through the middleware"""
248 | self.bypass_request_restrictions()
249 |
250 | response = self.request("GET", self.view)
251 | self.assertEqual(response.content, b"{}")
252 |
253 | events = ModelEvent.objects.all()
254 | self.assertEqual(events.count(), 1)
255 |
256 | event = events[0]
257 | self.assertEqual(event.user, self.user)
258 |
259 |
260 | # TODO: test snapshot
261 |
--------------------------------------------------------------------------------
/automated_logging/urls.py:
--------------------------------------------------------------------------------
1 | urlpatterns = []
2 |
--------------------------------------------------------------------------------
/automated_logging/views.py:
--------------------------------------------------------------------------------
1 | """
2 | Automated Logging specific views,
3 | currently unused except for testing redirection
4 | """
5 |
--------------------------------------------------------------------------------
/package-lock.json:
--------------------------------------------------------------------------------
1 | {
2 | "lockfileVersion": 1
3 | }
4 |
--------------------------------------------------------------------------------
/poetry.toml:
--------------------------------------------------------------------------------
1 | [virtualenvs]
2 | in-project = true
3 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.poetry]
2 | name = "django-automated-logging"
3 | version = "6.2.2"
4 | description = "Django Database Based Automated Logging - finally solved and done in a proper way."
5 | authors = ["Bilal Mahmoud "]
6 | license = "MIT"
7 | readme = "README.md"
8 | homepage = "https://github.com/indietyp/django-automated-logging"
9 | repository = "https://github.com/indietyp/django-automated-logging"
10 | keywords = ['django', 'automation', 'tools', 'backend', 'logging']
11 | classifiers = [
12 | 'Development Status :: 5 - Production/Stable',
13 | 'Environment :: Web Environment',
14 | 'Framework :: Django',
15 | 'Framework :: Django :: 2.2',
16 | 'Framework :: Django :: 3.0',
17 | 'Framework :: Django :: 3.1',
18 | 'Framework :: Django :: 3.2',
19 | 'Intended Audience :: Developers',
20 | 'License :: OSI Approved :: MIT License',
21 | 'Programming Language :: Python :: 3',
22 | 'Programming Language :: Python :: 3.6',
23 | 'Programming Language :: Python :: 3.7',
24 | 'Programming Language :: Python :: 3.8',
25 | 'Programming Language :: Python :: 3.9',
26 | 'Topic :: Software Development :: Libraries :: Python Modules',
27 | ]
28 | packages = [{include = "automated_logging"}]
29 |
30 | [tool.poetry.dependencies]
31 | python = ">=3.10, <4"
32 | django = ">=3.2, <6"
33 | marshmallow = "^3.21.1"
34 | django-picklefield = "^3.1"
35 | django-ipware = "^6.0.4"
36 |
37 | [tool.poetry.group.dev.dependencies]
38 | black = "^24.2.0"
39 | django-admin-generator = "^2.6.0"
40 | psycopg2-binary = "^2.9.9"
41 | pytz = "^2024.1"
42 | coverage = "<7.0"
43 | coveralls = "^3.3.1"
44 |
45 | [build-system]
46 | requires = ["poetry>=0.12"]
47 | build-backend = "poetry.masonry.api"
48 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/indietyp/django-automated-logging/254bd35699259c41d650d79f0e142a7ed2c73dca/tests/__init__.py
--------------------------------------------------------------------------------
/tests/run.py:
--------------------------------------------------------------------------------
1 | import os
2 | import sys
3 | from pathlib import Path
4 |
5 | import django
6 | from coverage import coverage
7 |
8 | from django.conf import settings
9 | from django.test.utils import get_runner
10 |
11 | if __name__ == "__main__":
12 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
13 | BASE_DIR = Path(BASE_DIR)
14 |
15 | sys.path.append(BASE_DIR.as_posix())
16 | os.chdir(BASE_DIR.as_posix())
17 |
18 | Coverage = coverage(config_file=".coveragerc")
19 | Coverage.start()
20 |
21 | os.environ["DJANGO_SETTINGS_MODULE"] = "tests.settings"
22 | django.setup()
23 |
24 | TestRunner = get_runner(settings)
25 |
26 | test_runner = TestRunner()
27 | failures = test_runner.run_tests(["automated_logging"])
28 |
29 | if failures:
30 | sys.exit(1)
31 |
32 | Coverage.stop()
33 |
34 | print("Coverage Summary:")
35 | Coverage.report()
36 |
37 | location = BASE_DIR / "tmp" / "coverage"
38 | Coverage.html_report(directory=location.as_posix())
39 | print(f"HTML version: file://{location}/index.html")
40 |
--------------------------------------------------------------------------------
/tests/settings.py:
--------------------------------------------------------------------------------
1 | """
2 | settings to be used for testing,
3 | mostly just defaults, because we set those ourselves.
4 | """
5 |
6 | import os
7 | from datetime import timedelta
8 |
9 | BASE_DIR = os.path.dirname((os.path.abspath(__file__)))
10 | SECRET_KEY = "fake"
11 | DEBUG = True
12 |
13 | INSTALLED_APPS = [
14 | "django.contrib.admin",
15 | "django.contrib.auth",
16 | "django.contrib.contenttypes",
17 | "django.contrib.sessions",
18 | "django.contrib.messages",
19 | "django.contrib.staticfiles",
20 | "automated_logging",
21 | ]
22 |
23 | MIDDLEWARE = [
24 | "django.middleware.security.SecurityMiddleware",
25 | "django.contrib.sessions.middleware.SessionMiddleware",
26 | "django.middleware.common.CommonMiddleware",
27 | "django.middleware.csrf.CsrfViewMiddleware",
28 | "django.contrib.auth.middleware.AuthenticationMiddleware",
29 | "django.contrib.messages.middleware.MessageMiddleware",
30 | "django.middleware.clickjacking.XFrameOptionsMiddleware",
31 | "automated_logging.middleware.AutomatedLoggingMiddleware",
32 | ]
33 |
34 | ROOT_URLCONF = "tests.urls"
35 |
36 | TEMPLATES = [
37 | {
38 | "BACKEND": "django.template.backends.django.DjangoTemplates",
39 | "DIRS": [],
40 | "APP_DIRS": True,
41 | "OPTIONS": {
42 | "context_processors": [
43 | "django.template.context_processors.debug",
44 | "django.template.context_processors.request",
45 | "django.contrib.auth.context_processors.auth",
46 | "django.contrib.messages.context_processors.messages",
47 | ],
48 | },
49 | },
50 | ]
51 |
52 | DATABASES = {
53 | "default": {
54 | "ENGINE": "django.db.backends.sqlite3",
55 | "NAME": os.path.join(BASE_DIR, "db.sqlite3"),
56 | }
57 | }
58 |
59 | AUTOMATED_LOGGING_DEV = True
60 | AUTOMATED_LOGGING = {}
61 |
62 |
63 | LOGGING = {
64 | "version": 1,
65 | "disable_existing_loggers": False,
66 | "root": {
67 | "level": "INFO",
68 | "handlers": ["console", "db"],
69 | },
70 | "formatters": {
71 | "verbose": {
72 | "format": "%(levelname)s %(asctime)s %(module)s "
73 | "%(process)d %(thread)d %(message)s"
74 | },
75 | },
76 | "handlers": {
77 | "console": {
78 | "level": "INFO",
79 | "class": "logging.StreamHandler",
80 | "formatter": "verbose",
81 | },
82 | "db": {
83 | "level": "INFO",
84 | "class": "automated_logging.handlers.DatabaseHandler",
85 | "threading": False,
86 | },
87 | },
88 | "loggers": {
89 | "automated_logging": {
90 | "level": "INFO",
91 | "handlers": ["console", "db"],
92 | "propagate": False,
93 | },
94 | "django": {
95 | "level": "INFO",
96 | "handlers": ["console", "db"],
97 | "propagate": False,
98 | },
99 | },
100 | }
101 |
--------------------------------------------------------------------------------
/tests/urls.py:
--------------------------------------------------------------------------------
1 | urlpatterns = []
2 |
--------------------------------------------------------------------------------