├── .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://badgen.net/pypi/v/django-automated-logging)](https://pypi.python.org/pypi?name=django-automated-logging) 4 | [![](https://badgen.net/pypi/license/django-automated-logging)](https://pypi.python.org/pypi?name=django-automated-logging) 5 | [![](https://img.shields.io/pypi/status/django-automated-logging.svg)](https://pypi.python.org/pypi?name=django-automated-logging) 6 | [![](https://badgen.net/pypi/python/django-automated-logging)](https://pypi.python.org/pypi?name=django-automated-logging) 7 | [![Build Status](https://www.travis-ci.com/indietyp/django-automated-logging.svg?branch=master)](https://www.travis-ci.com/indietyp/django-automated-logging) 8 | [![](https://coveralls.io/repos/github/indietyp/django-automated-logging/badge.svg?branch=master)](https://coveralls.io/github/indietyp/django-automated-logging?branch=master) 9 | [![](https://api.codacy.com/project/badge/Grade/96fdb764fc34486399802b2f8267efcc)](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://img.shields.io/badge/Support%20the%20Project-PayPal-green.svg)](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 | --------------------------------------------------------------------------------