diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 7e62f0a..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Build - -on: [push, pull_request] - -jobs: - build: - - runs-on: ubuntu-22.04 - strategy: - max-parallel: 4 - matrix: - python-version: [3.8, 3.9, '3.10', 3.11, 3.12] - django-version: [4.2, '5.0'] - exclude: - # Django 5.0 only supports Python 3.10 to 3.12 - - python-version: 3.8 - django-version: 5.0 - - python-version: 3.9 - django-version: 5.0 - - steps: - - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - name: Install Dependencies - run: | - python -m pip install --upgrade pip - python -m pip install django==${{ matrix.django-version }} - python -m pip install -e . - - name: Run Tests - run: | - cd ./easyaudit/tests - python -m manage test diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..5b2cbf4 --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,78 @@ +name: Publish Python 🐍 package 📦 to PyPI + +on: + push: + branches: + - master + workflow_dispatch: + +jobs: + get-package-version: + name: Get ${{ github.ref_name }} package version + runs-on: ubuntu-latest + outputs: + package-version: ${{ steps.package-version.outputs.package-version }} + version-compairson: ${{ steps.semver.outputs.comparison-result}} + steps: + - name: Checkout ${{ github.repository }} + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + virtualenvs-create: true + virtualenvs-in-project: true + installer-parallel: true + + - name: Get package version + id: package-version + run: echo "package-version=v$(poetry version --short)" >> $GITHUB_OUTPUT + + - name: Get release version + id: release-version + uses: pozetroninc/github-action-get-latest-release@master + with: + repository: ${{ github.repository }} + + - name: Analyze semver + id: semver + uses: madhead/semver-utils@latest + with: + lenient: false + version: ${{ steps.package-version.outputs.package-version }} + compare-to: ${{ steps.release-version.outputs.release }} + + - name: Check pre-release + if: ${{ steps.semver.outputs.prerelease != '' }} + run: | + echo "Checking if version is a pre-release" + echo "::error::Skipping pre-release version: ${{ steps.package-version.outputs.package-version}}" + exit 1 + + deploy: + name: Deploy Python 🐍 distributions 📦 to PyPI + needs: [get-package-version] + runs-on: ubuntu-latest + if: ${{ needs.get-package-version.outputs.version-compairson != '=' }} + + steps: + - name: Checkout ${{ github.repository }} + uses: actions/checkout@v3 + + - name: Build and publish to PyPI + uses: JRubics/poetry-publish@v1.13 + with: + pypi_token: ${{ secrets.PYPI_API_TOKEN }} + + - name: Create release ${{ needs.get-package-version.outputs.package-version }} + uses: ncipollo/release-action@v1 + with: + commit: ${{ github.ref_name }} + tag: ${{ needs.get-package-version.outputs.package-version }} + generateReleaseNotes: true + artifacts: | + dist/*.whl + dist/*.tar.gz diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ddf5b23 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,79 @@ +name: CI + +on: [pull_request, workflow_dispatch] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + get-python-versions: + name: Get Python versions + runs-on: ubuntu-latest + outputs: + python-matrix: ${{ steps.get-python-versions-action.outputs.latest-python-versions }} + steps: + - name: Get Python version matrix + uses: snok/latest-python-versions@v1 + id: get-python-versions-action + with: + min-version: 3.8 + + ci: + name: CI + needs: [get-python-versions] + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ${{ fromJson(needs.get-python-versions.outputs.python-matrix) }} + + steps: + - name: Checkout ${{ github.repository }} + uses: actions/checkout@v4 + + - name: Setup Python ${{ matrix.python-version }} + id: setup-python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + virtualenvs-create: true + virtualenvs-in-project: true + installer-parallel: true + + - name: Load cached environment + uses: actions/cache@v3 + with: + path: .venv + key: venv-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }} + + - name: Install dependencies + id: poetry-install + run: | + poetry install --no-interaction --no-root + echo "django_version=$(poetry run django-admin --version)" >> $GITHUB_OUTPUT + + - name: Run pre-commit + run: poetry run pre-commit run --all-files + + - name: Run Tests (Django ${{ steps.poetry-install.outputs.django_version }}) + run: | + poetry run pytest \ + --junitxml=pytest.xml \ + --cov-report=term-missing:skip-covered \ + --cov=easyaudit | tee pytest-coverage.txt + + - name: Add coverage comment + uses: MishaKav/pytest-coverage-comment@main + continue-on-error: true + with: + pytest-coverage-path: ./pytest-coverage.txt + junitxml-path: ./pytest.xml + report-only-changed-files: true + title: Coverage Report + unique-id-for-comment: ${{ matrix.python-version }} + remove-link-from-badge: true diff --git a/.gitignore b/.gitignore index 4002ae3..692e722 100644 --- a/.gitignore +++ b/.gitignore @@ -78,6 +78,7 @@ celerybeat-schedule .env # virtualenv +.venv/ venv/ ENV/ @@ -87,3 +88,6 @@ ENV/ # Rope project settings .ropeproject .idea + +# DB +db.sqlite3 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..056dc99 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,12 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.1.11 + hooks: + - id: ruff + - id: ruff-format + + - repo: https://github.com/djlint/djLint + rev: v1.34.1 + hooks: + - id: djlint-reformat-django + - id: djlint-django diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 1e728ae..0000000 --- a/.travis.yml +++ /dev/null @@ -1,49 +0,0 @@ -language: python - -dist: xenial - -python: - - '3.5' - - '3.6' - - '3.7' - - '3.8' - - 'pypy3' - -env: - - DJANGO="django>=2.2,<2.2.8" - - DJANGO="django>=2.2.8,<3.0" - - DJANGO="django>=3.0,<3.1" - - DJANGO="django>=3.1,<3.2" - - DJANGO="django>=3.2,<3.3" - - DJANGO="django>=4.0,<5.0" - -install: - - pip install ${DJANGO} - - pip install -e . - -before_script: - - cd easyaudit/tests - -script: - - python manage.py test - -matrix: - exclude: - - python: "3.5" - env: DJANGO="django>=3.0,<3.1" - - python: "3.5" - env: DJANGO="django>=3.1,<3.2" - - python: "3.5" - env: DJANGO="django>=3.2,<3.3" - - python: "3.8" - env: DJANGO="django>=2.0,<2.1" - - python: "3.8" - env: DJANGO="django>=2.1,<2.2" - - python: "3.8" - env: DJANGO="django>=2.2,<2.2.8" - - python: "3.5" - env: DJANGO="django>=4.0,<5.0" - - python: "3.6" - env: DJANGO="django>=4.0,<5.0" - - python: "3.7" - env: DJANGO="django>=4.0,<5.0" diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..a202bc7 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d6f7446 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,130 @@ +# Contribution Guide + +If you're reading this, you're likely interested in contributing to `django-easy-audit`. Thank you! This project could not continue without contributors like you. + +This guide lays the foundational principles and technical documentation for contributing to this project. If you're considering adding to this project, please read this document in its entirety. + +### Contributions + +If your motivation is centered around a problem you're facing, please create an issue first after a search of the existing issues to ensure you aren't creating a duplicate. Issues are important as they illustrate the need and document the change process in this package. + +Documentation contributions are arguably more important than writing code, so if you've found missing documentation or want to expand upon/clarify existing docs to help your fellow users, we welcome your work! + +Do note that contributions that clearly fall outside the scope of this project will be declined. Please give careful thought to the nature of your idea before doing any work. + +### Responsibilities + +- Ensure cross-platform compatibility for every change that's accepted. Windows, Mac, Debian & Ubuntu Linux. +- Ensure that any new code you write is covered by tests. +- Create issues for any changes and enhancements. Discuss things transparently and get community feedback. +- Keep changes as small as possible to ease the burden of code review. +- Be welcoming to newcomers and encourage diverse new contributors from all backgrounds. See the [Python Community Code of Conduct](https://www.python.org/psf/codeofconduct/). + +## Your First Contribution + +Never contributed to open-source before? Unsure of where to begin contributing to `django-easy-audit`? You can start by looking through these resources: + +- [Make a Pull Request](http://makeapullrequest.com/) +- [First Timers Only](http://www.firsttimersonly.com/) +- [How to Contribute to an Open Source Project on GitHub](https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github) + +If you're still unsure, feel free to ask for help! Everyone starts as a beginner. + +## Making changes + +For something that is bigger than a one or two line fix: + +1. Create your own fork of the code. +2. Make the changes in your fork. +3. Create a pull request from your fork against the main branch. + +Small contributions such as fixing spelling errors, where the content is small enough to not be considered intellectual property, can be submitted as a patch without forking. + +As a rule of thumb, changes are obvious fixes if they do not introduce any new functionality or creative thinking. As long as the change does not affect functionality, some likely examples include the following: + +- Spelling / grammar fixes +- Typo correction, white space and formatting changes +- Comment clean up +- Bug fixes that change default return values or error codes stored in constants +- Adding logging messages or debugging output +- Changes to ‘metadata’ files like .gitignore, build scripts, etc. +- Moving source files from one directory or package to another + +## Reporting bugs + +### Security vulnerabilities + +If you find a security vulnerability, **DO NOT** open an issue. Email [natancalzolari@gmail.com](mailto:natancalzolari@gmail.com) instead so as to not expose the vulnerability to the public. + +In order to determine whether you are dealing with a security issue, ask yourself these two questions: + +- Can I access something that's not mine, or something I shouldn't have access to? +- Can I disable something for other people? + +If the answer to either of those two questions are _yes_, then you're probably dealing with a security issue. Note that even if you answer _no_ to both questions, you may still be dealing with a security issue, so if you're unsure just send an email. + +### Bugs + +When filing an issue, make sure to answer these five questions: + +1. What version of Python and Django are you using? + ``` + python --version + django-admin --version + ``` +2. What operating system and processor architecture are you using? +3. What did you do? +4. What did you expect to see? +5. What did you see instead? + +### Features or enhancements + +If you find yourself wishing for a feature that doesn't exist in `django-easy-audit`, you are probably not alone. There are bound to be others out there with similar needs. Many of the features that `django-easy-audit` has today have been added because our users saw the need. Open an issue on our issues list on GitHub which describes the feature you would like to see, why you need it, and how it should work. + +## Setting up a development environment + +This project uses the following tools. + +- [Poetry](https://python-poetry.org/) for management of packaging, dependencies, and virtual environments +- [pytest](https://docs.pytest.org/) for writing tests +- [ruff](https://astral.sh/ruff) for Python source linting and formatting +- [djLint](https://www.djlint.com/) for HTML linting and formatting +- [pre-commit](https://pre-commit.com/) for Git hooks + +### Installing dependencies + +1. Install Python. You should use the lowest version of Python that this project supports to ensure your code changes don't include features that are only available in the latest Python version. This specifier can be found in [pyproject.toml](pyproject.toml) under `tool.poetry.dependencies.python`. The official installers can be found here: [Download Python](https://www.python.org/downloads/) +2. [Install Poetry](https://python-poetry.org/docs/#installing-with-the-official-installer). We recommend you use the official installer: + + ``` + curl -sSL https://install.python-poetry.org | python3 - + ``` + + Verify the installation by running `poetry --version`. + +3. Navigate to the repository root and [install this package](https://python-poetry.org/docs/cli/#install) and its dependencies: + + ``` + poetry install + ``` + + This project is configured to create a virtual environment inside the project root (`./.venv`). The following commands may change depending on how you choose to [use the Poetry virtual environment](https://python-poetry.org/docs/basic-usage/#using-your-virtual-environment). The easiest way to do this is to simply run the `poetry shell` command to activate the environment. + +4. Install pre-commit into your git hooks: + + ``` + pre-commit install + ``` + +### Verifying your setup + +To verify that your setup is working, run the following commands: + +``` +pytest +ruff . +djlint . +pre-commit run --all-files +``` + +If any of the above processes fail, please reach out to the project maintainers for support! diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index be45339..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,4 +0,0 @@ -include LICENSE -include README.rst -recursive-include easyaudit/static * -recursive-include easyaudit/templates * diff --git a/README.md b/README.md index 96d44e9..b993998 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # django-easy-audit [![pypi](https://img.shields.io/pypi/v/django-easy-audit.svg)](https://pypi.org/project/django-easy-audit/) -![PyPI - Django Version](https://img.shields.io/pypi/djversions/django-easy-audit) +![PyPI - Django Version](https://img.shields.io/pypi/frameworkversions/django/django-easy-audit) Yet another Django audit log app, hopefully the easiest one. @@ -11,25 +11,25 @@ This app allows you to keep track of every action taken by your users. 1. Install Django Easy Audit by running `pip install django-easy-audit`. - *Alternatively, you can download the [latest release](https://github.com/soynatan/django-easy-audit/releases) from GitHub, unzip it, and place the folder 'easyaudit' in the root of your project.* + _Alternatively, you can download the [latest release](https://github.com/soynatan/django-easy-audit/releases) from GitHub, unzip it, and place the folder 'easyaudit' in the root of your project._ 2. Add 'easyaudit' to your `INSTALLED_APPS` like this: - ```python - INSTALLED_APPS = [ - ... - 'easyaudit', - ] - ``` + ```python + INSTALLED_APPS = [ + ... + 'easyaudit', + ] + ``` 3. Add Easy Audit's middleware to your `MIDDLEWARE` (or `MIDDLEWARE_CLASSES`) setting like this: - ```python - MIDDLEWARE = ( - ... - 'easyaudit.middleware.easyaudit.EasyAuditMiddleware', - ) - ``` + ```python + MIDDLEWARE = ( + ... + 'easyaudit.middleware.easyaudit.EasyAuditMiddleware', + ) + ``` 4. Run `python manage.py migrate easyaudit` to create the app's models. @@ -41,21 +41,21 @@ For an exhaustive list of available settings, please [check our wiki](https://gi Below are some of the settings you may want to use. These should be defined in your project's `settings.py` file: -* `DJANGO_EASY_AUDIT_WATCH_MODEL_EVENTS` +- `DJANGO_EASY_AUDIT_WATCH_MODEL_EVENTS` -* `DJANGO_EASY_AUDIT_WATCH_AUTH_EVENTS` +- `DJANGO_EASY_AUDIT_WATCH_AUTH_EVENTS` -* `DJANGO_EASY_AUDIT_WATCH_REQUEST_EVENTS` +- `DJANGO_EASY_AUDIT_WATCH_REQUEST_EVENTS` Set these to `False` to stop logging model, authentication, and/or request events. -* `DJANGO_EASY_AUDIT_UNREGISTERED_CLASSES_EXTRA` +- `DJANGO_EASY_AUDIT_UNREGISTERED_CLASSES_EXTRA` A list of Django models which will be ignored by Django Easy Audit. Use it to prevent logging one or more of your project's models. List items can be classes or strings with `app_name.model_name` format. -* `DJANGO_EASY_AUDIT_UNREGISTERED_URLS_EXTRA` +- `DJANGO_EASY_AUDIT_UNREGISTERED_URLS_EXTRA` A list of URLs which will be ignored by Django Easy Audit. List items are expected to be regular expressions that @@ -63,7 +63,7 @@ Below are some of the settings you may want to use. These should be defined in y [Check our wiki](https://github.com/soynatan/django-easy-audit/wiki/Settings#request-auditing) for more details on how to use it. -* `DJANGO_EASY_AUDIT_CRUD_DIFFERENCE_CALLBACKS` +- `DJANGO_EASY_AUDIT_CRUD_DIFFERENCE_CALLBACKS` May point to a list of callables/string-paths-to-functions-classes in which the application code can determine on a per CRUDEvent whether or not the application chooses to create the CRUDEvent or not. This is different @@ -71,7 +71,7 @@ Below are some of the settings you may want to use. These should be defined in y This is meant to be for dynamic configurations where the application may inspect the current save/create/delete and choose whether or not to save that into the database or ignore it. -* `DJANGO_EASY_AUDIT_USER_DB_CONSTRAINT` +- `DJANGO_EASY_AUDIT_USER_DB_CONSTRAINT` Default is `True`. This is reserved for future use (does not do anything yet). The functionality provided by the setting (whether enabled or disabled) could be handled more explicitly in certain @@ -80,62 +80,66 @@ Below are some of the settings you may want to use. These should be defined in y Again, this doesn't do anything yet, and if it ever does, the version will be increased and the README will be updated accordingly. If you keep your database together (the standard usage), you have nothing to worry about. -* `DJANGO_EASY_AUDIT_CRUD_EVENT_LIST_FILTER` +- `DJANGO_EASY_AUDIT_CRUD_EVENT_LIST_FILTER` -* `DJANGO_EASY_AUDIT_LOGIN_EVENT_LIST_FILTER` +- `DJANGO_EASY_AUDIT_LOGIN_EVENT_LIST_FILTER` -* `DJANGO_EASY_AUDIT_REQUEST_EVENT_LIST_FILTER` +- `DJANGO_EASY_AUDIT_REQUEST_EVENT_LIST_FILTER` Changeview filters configuration. Used to remove filters when the corresponding list of data would be too long. Defaults are: - - ['event_type', 'content_type', 'user', 'datetime', ] for CRUDEventAdmin - - ['login_type', 'user', 'datetime', ] for LoginEventAdmin - - ['method', 'user', 'datetime', ] for RequestEventAdmin -* `DJANGO_EASY_AUDIT_DATABASE_ALIAS` + - ['event_type', 'content_type', 'user', 'datetime', ] for CRUDEventAdmin + - ['login_type', 'user', 'datetime', ] for LoginEventAdmin + - ['method', 'user', 'datetime', ] for RequestEventAdmin + +- `DJANGO_EASY_AUDIT_DATABASE_ALIAS` By default it is the Django `default` database alias. But for projects that have split databases, this is necessary in order to keep database atomicity concerns in check during signal handlers. To clarify, this is only _truly_ necessary for the model signals. -* `DJANGO_EASY_AUDIT_PROPAGATE_EXCEPTIONS` +- `DJANGO_EASY_AUDIT_PROPAGATE_EXCEPTIONS` + + Default is `False`. When set to `True`, easyaudit will propagate exceptions occurred in own signal handlers. The + recommended approach is to use Django's `DEBUG` setting in order to only propagate errors in development: - Default is `False`. When set to `True`, easyaudit will propagate exceptions occurred in own signal handlers. The - recommended approach is to use Django's `DEBUG` setting in order to only propagate errors in development: ```python DJANGO_EASY_AUDIT_PROPAGATE_EXCEPTIONS = DEBUG ``` -* `DJANGO_EASY_AUDIT_CRUD_EVENT_NO_CHANGED_FIELDS_SKIP` +- `DJANGO_EASY_AUDIT_CRUD_EVENT_NO_CHANGED_FIELDS_SKIP` By default this is `False`, but this allows the calling project not to save `CRUDEvent` if the changed fields as determined by the `pre_save` handler sees that there are no changed fields. We are keeping it off by default so that projects that wish to use this (potentially less `CRUDEvent`) can choose to turn it on! And those that do not want it (yet or ever), or those that do not closely follow the release notes of this project will have one less worry when upgrading. -* `DJANGO_EASY_AUDIT_CHECK_IF_REQUEST_USER_EXISTS` +- `DJANGO_EASY_AUDIT_CHECK_IF_REQUEST_USER_EXISTS` By default this is `True`, but this allows the calling project to make easyaudit ignore user validation on audit event creation. This is useful when you have a app with soft delete or no delete on users model. With this set to `False`, easyaudit only fetch `request.user` for audit event creation, no db check is made, meaning you can speed up audit events creation and save some DB calls. -* `DJANGO_EASY_AUDIT_READONLY_EVENTS` +- `DJANGO_EASY_AUDIT_READONLY_EVENTS` Default is `False`. The events visible through the admin interface are editable by default by a superuser. Set this to `True` if you wish to make the recorded events read-only through the admin UI. -* `DJANGO_EASY_AUDIT_LOGGING_BACKEND` +- `DJANGO_EASY_AUDIT_LOGGING_BACKEND` A pluggable backend option for logging. Defaults to `easyaudit.backends.ModelBackend`. This class expects to have 3 methods: - * `login(self, login_info_dict):` - * `crud(self, crud_info_dict):` - * `request(self, request_info_dict):` + + - `login(self, login_info_dict):` + - `crud(self, crud_info_dict):` + - `request(self, request_info_dict):` each of these methods accept a dictionary containing the info regarding the event. example overriding: + ```python import logging @@ -154,7 +158,7 @@ Below are some of the settings you may want to use. These should be defined in y def crud(self, crud_info): self.logger.info(msg='your message', extra=crud_info) return crud_info - ``` + ``` ## What does it do @@ -181,6 +185,10 @@ may be your best choice. The good thing about this app is that it doesn't get in the way. It is [easy and quick to install](https://github.com/soynatan/django-easy-audit/wiki/Installation), and it begins logging everything right away, without you having to inject code anywhere in your project. +## Contributing + +Interested in contributing to `django-easy-audit`? Please read our [Contribution guide](CONTRIBUTING.md). + ## Contact Find me on Twitter at [@soynatan](https://twitter.com/soynatan), diff --git a/README.rst b/README.rst deleted file mode 100644 index c77a501..0000000 --- a/README.rst +++ /dev/null @@ -1,26 +0,0 @@ -================= -django-easy-audit -================= - -Yet another Django audit log app, hopefully the simplest one. - -Quick start ------------ - -1. Add "easyaudit" to your INSTALLED_APPS setting like this:: - - INSTALLED_APPS = [ - ... - 'easyaudit', - ] - -2. Add django-easy-audit's middleware to your MIDDLEWARE (or MIDDLEWARE_CLASSES) setting like this:: - - MIDDLEWARE = ( - ... - 'easyaudit.middleware.easyaudit.EasyAuditMiddleware', - ) - -3. Run 'python manage.py migrate easyaudit' to create the audit models. - -4. That's it! Now every CRUD event on your whole project will be registered in the audit models, which you will be able to query from the Django admin app. Additionally, this app will also log all authentication events and all URLs requested. diff --git a/easyaudit/admin.py b/easyaudit/admin.py index 66f2914..c298eb9 100644 --- a/easyaudit/admin.py +++ b/easyaudit/admin.py @@ -1,30 +1,40 @@ +import csv +import datetime + from django.contrib import admin -from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.http import HttpResponse -import csv, datetime - -try: # Django 2.0 - from django.urls import reverse -except: # Django < 2.0 - from django.core.urlresolvers import reverse - -from django.utils.safestring import mark_safe +from django.urls import reverse from django.utils.html import escape -from . import settings -from .models import CRUDEvent, LoginEvent, RequestEvent -from .admin_helpers import prettify_json, EasyAuditModelAdmin -from .settings import (CRUD_EVENT_LIST_FILTER, LOGIN_EVENT_LIST_FILTER, REQUEST_EVENT_LIST_FILTER, - CRUD_EVENT_SEARCH_FIELDS, LOGIN_EVENT_SEARCH_FIELDS, REQUEST_EVENT_SEARCH_FIELDS, - READONLY_EVENTS) +from django.utils.safestring import mark_safe -# Export event audits to csv +from .admin_helpers import EasyAuditModelAdmin, prettify_json +from .models import CRUDEvent, LoginEvent, RequestEvent +from .settings import ( + ADMIN_SHOW_AUTH_EVENTS, + ADMIN_SHOW_MODEL_EVENTS, + ADMIN_SHOW_REQUEST_EVENTS, + CRUD_EVENT_LIST_FILTER, + CRUD_EVENT_SEARCH_FIELDS, + LOGIN_EVENT_LIST_FILTER, + LOGIN_EVENT_SEARCH_FIELDS, + REQUEST_EVENT_LIST_FILTER, + REQUEST_EVENT_SEARCH_FIELDS, +) + + +@admin.display(description="Export to CSV") def export_to_csv(modeladmin, request, queryset): + """Export event audits to csv.""" opts = modeladmin.model._meta - response = HttpResponse(content_type='text/csv') - response['Content-Disposition'] = 'attachment;' 'filename={}.csv'.format(opts.verbose_name) + response = HttpResponse(content_type="text/csv") + response["Content-Disposition"] = f"attachment;filename={opts.verbose_name}.csv" writer = csv.writer(response) - fields = [field for field in opts.get_fields() if not field.many_to_many and not field.one_to_many] + fields = [ + field + for field in opts.get_fields() + if not field.many_to_many and not field.one_to_many + ] # Write a first row with header information writer.writerow([field.verbose_name for field in fields]) # Write data rows @@ -33,42 +43,56 @@ def export_to_csv(modeladmin, request, queryset): for field in fields: value = getattr(obj, field.name) if isinstance(value, datetime.datetime): - value = value.strftime('%d/%m/%Y') + value = value.strftime("%d/%m/%Y") data_row.append(value) writer.writerow(data_row) return response -export_to_csv.short_description = 'Export to CSV' #short description - # CRUD events class CRUDEventAdmin(EasyAuditModelAdmin): - list_display = ['get_event_type_display', 'get_content_type', 'object_id', 'object_repr_link', 'user_link', 'datetime'] - date_hierarchy = 'datetime' + list_display = [ + "get_event_type_display", + "get_content_type", + "object_id", + "object_repr_link", + "user_link", + "datetime", + ] + date_hierarchy = "datetime" list_filter = CRUD_EVENT_LIST_FILTER search_fields = CRUD_EVENT_SEARCH_FIELDS - readonly_fields = ['event_type', 'object_id', 'get_content_type', - 'object_repr', 'object_json_repr_prettified', 'get_user', - 'user_pk_as_string', 'datetime', 'changed_fields_prettified'] - exclude = ['object_json_repr', 'changed_fields'] + readonly_fields = [ + "event_type", + "object_id", + "get_content_type", + "object_repr", + "object_json_repr_prettified", + "get_user", + "user_pk_as_string", + "datetime", + "changed_fields_prettified", + ] + exclude = ["object_json_repr", "changed_fields"] def get_changelist_instance(self, *args, **kwargs): changelist_instance = super().get_changelist_instance(*args, **kwargs) content_type_ids = [obj.content_type_id for obj in changelist_instance.result_list] - self.content_types_by_id = {content_type.id: content_type for content_type in ContentType.objects.filter(id__in=content_type_ids)} + self.content_types_by_id = { + ct.id: ct for ct in ContentType.objects.filter(id__in=content_type_ids) + } return changelist_instance + @admin.display(description="Content Type") def get_content_type(self, obj): return self.content_types_by_id[obj.content_type_id] - get_content_type.short_description = "Content Type" - + @admin.display(description="User") def get_user(self, obj): return self.users_by_id.get(obj.user_id) - get_user.short_description = "User" - + @admin.display(description="object repr") def object_repr_link(self, obj): if obj.event_type == CRUDEvent.DELETE: html = obj.object_repr @@ -76,40 +100,45 @@ def object_repr_link(self, obj): escaped_obj_repr = escape(obj.object_repr) try: content_type = self.get_content_type(obj) - url = reverse("admin:%s_%s_change" % ( - content_type.app_label, - content_type.model, - ), args=(obj.object_id,)) - html = '%s' % (url, escaped_obj_repr) + url = reverse( + f"admin:{content_type.app_label}_{content_type.model}_change", + args=(obj.object_id,), + ) + html = f'{escaped_obj_repr}' except Exception: html = escaped_obj_repr - return mark_safe(html) - - object_repr_link.short_description = 'object repr' + return mark_safe(html) # noqa: S308 + @admin.display(description="object json repr") def object_json_repr_prettified(self, obj): return prettify_json(obj.object_json_repr) - object_json_repr_prettified.short_description = 'object json repr' - + @admin.display(description="changed fields") def changed_fields_prettified(self, obj): return prettify_json(obj.changed_fields) - changed_fields_prettified.short_description = 'changed fields' - - actions = [export_to_csv] - -if settings.ADMIN_SHOW_MODEL_EVENTS: - admin.site.register(CRUDEvent, CRUDEventAdmin) + actions = [export_to_csv] # Login events class LoginEventAdmin(EasyAuditModelAdmin): - list_display = ['datetime', 'get_login_type_display', 'user_link', "get_username", 'remote_ip'] - date_hierarchy = 'datetime' + list_display = [ + "datetime", + "get_login_type_display", + "user_link", + "get_username", + "remote_ip", + ] + date_hierarchy = "datetime" list_filter = LOGIN_EVENT_LIST_FILTER search_fields = LOGIN_EVENT_SEARCH_FIELDS - readonly_fields = ['login_type', 'get_username', 'get_user', 'remote_ip', 'datetime', ] + readonly_fields = [ + "login_type", + "get_username", + "get_user", + "remote_ip", + "datetime", + ] def get_user(self, obj): return self.users_by_id.get(obj.user_id) @@ -118,31 +147,39 @@ def get_user(self, obj): def get_username(self, obj): user = self.get_user(obj) - username = user.get_username() if user else None - return username + return user.get_username() if user else None get_username.short_description = "User name" - actions = [export_to_csv] - -if settings.ADMIN_SHOW_AUTH_EVENTS: - admin.site.register(LoginEvent, LoginEventAdmin) + actions = [export_to_csv] # Request events class RequestEventAdmin(EasyAuditModelAdmin): - list_display = ['datetime', 'user_link', 'method', 'url', 'remote_ip'] - date_hierarchy = 'datetime' + list_display = ["datetime", "user_link", "method", "url", "remote_ip"] + date_hierarchy = "datetime" list_filter = REQUEST_EVENT_LIST_FILTER search_fields = REQUEST_EVENT_SEARCH_FIELDS - readonly_fields = ['url', 'method', 'query_string', 'get_user', 'remote_ip', 'datetime', ] + readonly_fields = [ + "url", + "method", + "query_string", + "get_user", + "remote_ip", + "datetime", + ] def get_user(self, obj): return self.users_by_id.get(obj.user_id) get_user.short_description = "User" - actions = [export_to_csv] + actions = [export_to_csv] + -if settings.ADMIN_SHOW_REQUEST_EVENTS: +if ADMIN_SHOW_MODEL_EVENTS: + admin.site.register(CRUDEvent, CRUDEventAdmin) +if ADMIN_SHOW_AUTH_EVENTS: + admin.site.register(LoginEvent, LoginEventAdmin) +if ADMIN_SHOW_REQUEST_EVENTS: admin.site.register(RequestEvent, RequestEventAdmin) diff --git a/easyaudit/admin_helpers.py b/easyaudit/admin_helpers.py index 8abc703..3020bbe 100644 --- a/easyaudit/admin_helpers.py +++ b/easyaudit/admin_helpers.py @@ -1,31 +1,23 @@ -from django.contrib import admin -from django.core.exceptions import PermissionDenied -from django.contrib.auth import get_user_model -from django.shortcuts import render - -try: # Django 2.0 - from django.urls import reverse -except: # Django < 2.0 - from django.core.urlresolvers import reverse +import json -from django.urls import re_path +from django.contrib import admin, messages +from django.contrib.auth import get_user_model +from django.core.exceptions import PermissionDenied from django.http import HttpResponseRedirect -from django.utils.translation import gettext_lazy as _ -from django.contrib import messages -from django.utils.safestring import mark_safe +from django.shortcuts import render +from django.urls import re_path, reverse from django.utils.html import escape -from . import settings +from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ -import json +from .settings import READONLY_EVENTS, TRUNCATE_TABLE_SQL_STATEMENT def prettify_json(json_string): - """Given a JSON string, it returns it as a - safe formatted HTML""" + """Given a JSON string, it returns it as a safe formatted HTML.""" escaped = escape(json_string) try: data = json.loads(escaped) - # html = '
' + json.dumps(data, sort_keys=True, indent=4) + '
' html = json.dumps(data, sort_keys=True, indent=4) except Exception: html = escaped @@ -36,47 +28,51 @@ class EasyAuditModelAdmin(admin.ModelAdmin): def get_changelist_instance(self, *args, **kwargs): changelist_instance = super().get_changelist_instance(*args, **kwargs) user_ids = [obj.user_id for obj in changelist_instance.result_list] - self.users_by_id = {user.id: user for user in get_user_model().objects.filter(id__in=user_ids)} + self.users_by_id = { + user.id: user for user in get_user_model().objects.filter(id__in=user_ids) + } return changelist_instance def get_readonly_fields(self, request, obj=None): - "Mark all fields of model as readonly if configured to do so." - if settings.READONLY_EVENTS: + """Mark all fields of model as readonly if configured to do so.""" + if READONLY_EVENTS: return [f.name for f in self.model._meta.get_fields()] - else: - return self.readonly_fields + return self.readonly_fields + @admin.display(description="User") def user_link(self, obj): user = self.users_by_id.get(obj.user_id) - #return mark_safe(get_user_link(user)) if user is None: - return '-' + return "-" escaped = escape(str(user)) try: user_model = get_user_model() - url = reverse("admin:%s_%s_change" % ( - user_model._meta.app_label, - user_model._meta.model_name, - ), args=(user.id,)) - html = '%s' % (url, escaped) + url = reverse( + f"admin:{user_model._meta.app_label}_{user_model._meta.model_name}_change" + ) + html = f'{escaped}' + html = f'{escaped}' except Exception: html = escaped - return mark_safe(html) - user_link.short_description = 'user' + return mark_safe(html) # noqa: S308 def has_add_permission(self, request, obj=None): return False def has_delete_permission(self, request, obj=None): - if settings.READONLY_EVENTS: + if READONLY_EVENTS: return False return super().has_delete_permission(request, obj) def get_urls(self): - info = self.model._meta.app_label, self.model._meta.model_name - urls = super(EasyAuditModelAdmin, self).get_urls() + urls = super().get_urls() my_urls = [ - re_path(r'^purge/$', self.admin_site.admin_view(self.purge), {}, name="%s_%s_purge" % info), + re_path( + r"^purge/$", + self.admin_site.admin_view(self.purge), + {}, + name=f"{self.model._meta.app_label}_{self.model._meta.model_name}_purge", + ), ] return my_urls + urls @@ -85,19 +81,19 @@ def purge(self, request): # Helper view to remove all rows in a table def purge_objects(self, request): - """ - Removes all objects in this table. - This action first displays a confirmation page; - next, it deletes all objects and redirects back to the change list. - """ + """Remove all objects in this table. - if settings.READONLY_EVENTS: + This action first displays a confirmation page; next, it deletes all objects and + redirects back to the change list. + """ + if READONLY_EVENTS: raise PermissionDenied def truncate_table(model): - if settings.TRUNCATE_TABLE_SQL_STATEMENT: + if TRUNCATE_TABLE_SQL_STATEMENT: from django.db import connection - sql = settings.TRUNCATE_TABLE_SQL_STATEMENT.format(db_table=model._meta.db_table) + + sql = TRUNCATE_TABLE_SQL_STATEMENT.format(db_table=model._meta.db_table) cursor = connection.cursor() cursor.execute(sql) else: @@ -108,23 +104,31 @@ def truncate_table(model): # Check that the user has delete permission for the actual model if not request.user.is_superuser: - raise PermissionDenied + raise PermissionDenied if not modeladmin.has_delete_permission(request): raise PermissionDenied # If the user has already confirmed or cancelled the deletion, # (eventually) do the deletion and return to the change list view again. - if request.method == 'POST': - if 'btn-confirm' in request.POST: + if request.method == "POST": + if "btn-confirm" in request.POST: try: n = modeladmin.model.objects.count() truncate_table(modeladmin.model) - modeladmin.message_user(request, _("Successfully removed %d rows" % n), messages.SUCCESS); + modeladmin.message_user( + request, _("Successfully removed %d rows" % n), messages.SUCCESS + ) except Exception as e: - modeladmin.message_user(request, _(u'ERROR') + ': %r' % e, messages.ERROR) + modeladmin.message_user( + request, _("ERROR") + ": %r" % e, messages.ERROR + ) else: - modeladmin.message_user(request, _("Action cancelled by user"), messages.SUCCESS); - return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (opts.app_label, opts.model_name))) + modeladmin.message_user( + request, _("Action cancelled by user"), messages.SUCCESS + ) + return HttpResponseRedirect( + reverse(f"admin:{opts.app_label}_{opts.model_name}_changelist") + ) context = { "title": _("Purge all %s ... are you sure?") % opts.verbose_name_plural, @@ -133,8 +137,4 @@ def truncate_table(model): } # Display the confirmation page - return render( - request, - 'admin/easyaudit/purge_confirmation.html', - context - ) + return render(request, "admin/easyaudit/purge_confirmation.html", context) diff --git a/easyaudit/apps.py b/easyaudit/apps.py index 2d93b2e..5c8d924 100644 --- a/easyaudit/apps.py +++ b/easyaudit/apps.py @@ -1,9 +1,14 @@ from django.apps import AppConfig + class EasyAuditConfig(AppConfig): - name = 'easyaudit' - verbose_name = 'Easy Audit Application' - default_auto_field = 'django.db.models.AutoField' + name = "easyaudit" + verbose_name = "Easy Audit Application" + default_auto_field = "django.db.models.AutoField" def ready(self): - from easyaudit.signals import auth_signals, model_signals, request_signals \ No newline at end of file + from easyaudit.signals import ( # noqa: F401 + auth_signals, + model_signals, + request_signals, + ) diff --git a/easyaudit/backends.py b/easyaudit/backends.py index b80c724..4beceef 100644 --- a/easyaudit/backends.py +++ b/easyaudit/backends.py @@ -1,11 +1,11 @@ import logging -from easyaudit.models import RequestEvent, CRUDEvent, LoginEvent + +from easyaudit.models import CRUDEvent, LoginEvent, RequestEvent logger = logging.getLogger(__name__) class ModelBackend: - def request(self, request_info): return RequestEvent.objects.create(**request_info) @@ -13,4 +13,4 @@ def crud(self, crud_info): return CRUDEvent.objects.create(**crud_info) def login(self, login_info): - return LoginEvent.objects.create(**login_info) \ No newline at end of file + return LoginEvent.objects.create(**login_info) diff --git a/easyaudit/crudhistory_admin_mixin.py b/easyaudit/crudhistory_admin_mixin.py index 19029d7..8b9fc30 100644 --- a/easyaudit/crudhistory_admin_mixin.py +++ b/easyaudit/crudhistory_admin_mixin.py @@ -22,15 +22,17 @@ def get_redirect_url__to_referer(self, request: HttpRequest): preserved_filters = self.get_preserved_filters(request) opts = self.model._meta - redirect_url = add_preserved_filters( - {'preserved_filters': preserved_filters, 'opts': opts}, - request.META.get('HTTP_REFERER', '/'), + return add_preserved_filters( + {"preserved_filters": preserved_filters, "opts": opts}, + request.META.get("HTTP_REFERER", "/"), ) - return redirect_url - def process_action( - self, request, obj_id, action_key, **kwargs, + self, + request, + obj_id, + action_key, + **kwargs, ): action_methods = self.get_action_methods() action = action_methods[action_key] @@ -40,40 +42,35 @@ def process_action( class CRUDHistoryAdminMixin(BaseProcessActionsAdminMixin, admin.ModelAdmin): CRUD_HISTORY = "crud_history" - crud_history_translated_title = _('CRUD history') + crud_history_translated_title = _("CRUD history") def get_urls(self) -> list: - urls = super(CRUDHistoryAdminMixin, self).get_urls() + urls = super().get_urls() info = self._get_path_info() crud_history_urls = [ path( - f'/{self.CRUD_HISTORY}/', + f"/{self.CRUD_HISTORY}/", self.admin_site.admin_view(self.crud_history_view), - name=f'%s_%s_{self.CRUD_HISTORY}' % info, - ), + name=f"%s_%s_{self.CRUD_HISTORY}" % info, + ) ] return crud_history_urls + urls def get_action_methods(self) -> dict: - methods = super(CRUDHistoryAdminMixin, self).get_action_methods() - methods.update( - {self.CRUD_HISTORY: self.crud_history_action,} - ) + methods = super().get_action_methods() + methods.update({self.CRUD_HISTORY: self.crud_history_action}) return methods def crud_history_view(self, request: HttpRequest, object_id: int): return self.process_action(request, object_id, self.CRUD_HISTORY) def crud_history_action(self, request: HttpRequest, obj: Model) -> HttpResponseRedirect: - base_history_url = reverse(f"admin:easyaudit_crudevent_changelist", ) + base_history_url = reverse( + "admin:easyaudit_crudevent_changelist", + ) app_label, model_name = self._get_path_info() content_type = ContentType.objects.get_by_natural_key(app_label, model_name) - params = { - # "content_type__app_label": app_label, - "content_type__id": content_type.id, - # "content_type__model_name": model_name, - "object_id": obj.id, - } + params = {"content_type__id": content_type.id, "object_id": obj.id} params = urlencode(params) history_url = f"{base_history_url}?{params}" @@ -83,7 +80,7 @@ def crud_history_action(self, request: HttpRequest, obj: Model) -> HttpResponseR def get_crud_history_url(self, obj: Model) -> str: info = self._get_path_info() - return reverse(f'admin:%s_%s_{self.CRUD_HISTORY}' % info, args=[obj.pk]) + return reverse(f"admin:%s_%s_{self.CRUD_HISTORY}" % info, args=[obj.pk]) def crud_history_link(self, obj: Model) -> str: crud_history_url = self.get_crud_history_url(obj=obj) @@ -101,4 +98,4 @@ class SomeModelAdmin(CRUDHistoryAdminMixin): list_display = ( "id", "crud_history_link", - ) \ No newline at end of file + ) diff --git a/easyaudit/middleware/easyaudit.py b/easyaudit/middleware/easyaudit.py index bbfb458..c23a996 100644 --- a/easyaudit/middleware/easyaudit.py +++ b/easyaudit/middleware/easyaudit.py @@ -1,28 +1,27 @@ - # makes easy-audit thread-safe -try: - from threading import local -except ImportError: - from django.utils._threading_local import local +import contextlib +from threading import local + -class MockRequest(object): +class MockRequest: def __init__(self, *args, **kwargs): - user = kwargs.pop('user', None) + user = kwargs.pop("user", None) self.user = user - super(MockRequest, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) _thread_locals = local() def get_current_request(): - return getattr(_thread_locals, 'request', None) + return getattr(_thread_locals, "request", None) def get_current_user(): request = get_current_request() if request: - return getattr(request, 'user', None) + return getattr(request, "user", None) + return None def set_current_user(user): @@ -34,41 +33,35 @@ def set_current_user(user): def clear_request(): - try: + with contextlib.suppress(AttributeError): del _thread_locals.request - except AttributeError: - pass class EasyAuditMiddleware: - """Makes request available to this app signals.""" + def __init__(self, get_response=None): self.get_response = get_response def __call__(self, request): - _thread_locals.request = request # seems redundant w/process_request, but keeping in for now. - if hasattr(self, 'process_request'): + _thread_locals.request = ( + request # seems redundant w/process_request, but keeping in for now. + ) + if hasattr(self, "process_request"): response = self.process_request(request) response = response or self.get_response(request) - if hasattr(self, 'process_response'): + if hasattr(self, "process_response"): response = self.process_response(request, response) return response def process_request(self, request): _thread_locals.request = request - return None def process_response(self, request, response): - try: + with contextlib.suppress(AttributeError): del _thread_locals.request - except AttributeError: - pass return response def process_exception(self, request, exception): - try: + with contextlib.suppress(AttributeError): del _thread_locals.request - except AttributeError: - pass - return None diff --git a/easyaudit/migrations/0001_initial.py b/easyaudit/migrations/0001_initial.py index 3b8560f..7d759fa 100644 --- a/easyaudit/migrations/0001_initial.py +++ b/easyaudit/migrations/0001_initial.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - from django.db import models, migrations import django.db.models.deletion from django.conf import settings diff --git a/easyaudit/migrations/0002_auto_20170125_0759.py b/easyaudit/migrations/0002_auto_20170125_0759.py index 74c269a..b24ce36 100644 --- a/easyaudit/migrations/0002_auto_20170125_0759.py +++ b/easyaudit/migrations/0002_auto_20170125_0759.py @@ -1,6 +1,4 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.10.5 on 2017-01-25 07:59 -from __future__ import unicode_literals from django.conf import settings from django.db import migrations, models diff --git a/easyaudit/migrations/0003_auto_20170228_1505.py b/easyaudit/migrations/0003_auto_20170228_1505.py index 8a890a4..5ba80d1 100644 --- a/easyaudit/migrations/0003_auto_20170228_1505.py +++ b/easyaudit/migrations/0003_auto_20170228_1505.py @@ -1,6 +1,4 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.9.12 on 2017-02-28 15:05 -from __future__ import unicode_literals from django.conf import settings from django.db import migrations, models diff --git a/easyaudit/migrations/0004_auto_20170620_1354.py b/easyaudit/migrations/0004_auto_20170620_1354.py index 0657d21..a91cc89 100644 --- a/easyaudit/migrations/0004_auto_20170620_1354.py +++ b/easyaudit/migrations/0004_auto_20170620_1354.py @@ -1,6 +1,4 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.1 on 2017-06-20 13:54 -from __future__ import unicode_literals from django.db import migrations diff --git a/easyaudit/migrations/0005_auto_20170713_1155.py b/easyaudit/migrations/0005_auto_20170713_1155.py index 80efad6..613e366 100644 --- a/easyaudit/migrations/0005_auto_20170713_1155.py +++ b/easyaudit/migrations/0005_auto_20170713_1155.py @@ -1,6 +1,4 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.2 on 2017-07-13 15:55 -from __future__ import unicode_literals from django.db import migrations, models diff --git a/easyaudit/migrations/0006_auto_20171018_1242.py b/easyaudit/migrations/0006_auto_20171018_1242.py index da920fe..05a911c 100644 --- a/easyaudit/migrations/0006_auto_20171018_1242.py +++ b/easyaudit/migrations/0006_auto_20171018_1242.py @@ -1,6 +1,4 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.6 on 2017-10-18 12:42 -from __future__ import unicode_literals from django.conf import settings from django.db import migrations, models diff --git a/easyaudit/migrations/0007_auto_20180105_0838.py b/easyaudit/migrations/0007_auto_20180105_0838.py index eac5a8f..49945b2 100644 --- a/easyaudit/migrations/0007_auto_20180105_0838.py +++ b/easyaudit/migrations/0007_auto_20180105_0838.py @@ -1,6 +1,4 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.6 on 2018-01-05 08:38 -from __future__ import unicode_literals from django.db import migrations, models diff --git a/easyaudit/migrations/0008_auto_20180220_1908.py b/easyaudit/migrations/0008_auto_20180220_1908.py index efe19ad..dbf9132 100644 --- a/easyaudit/migrations/0008_auto_20180220_1908.py +++ b/easyaudit/migrations/0008_auto_20180220_1908.py @@ -1,6 +1,4 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.8 on 2018-02-20 19:08 -from __future__ import unicode_literals from django.db import migrations, models diff --git a/easyaudit/migrations/0009_auto_20180314_2225.py b/easyaudit/migrations/0009_auto_20180314_2225.py index f626134..e6b9726 100644 --- a/easyaudit/migrations/0009_auto_20180314_2225.py +++ b/easyaudit/migrations/0009_auto_20180314_2225.py @@ -1,6 +1,4 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.7 on 2018-03-14 22:25 -from __future__ import unicode_literals from django.db import migrations, models diff --git a/easyaudit/migrations/0011_auto_20181101_1339.py b/easyaudit/migrations/0011_auto_20181101_1339.py index d374549..57d77b0 100644 --- a/easyaudit/migrations/0011_auto_20181101_1339.py +++ b/easyaudit/migrations/0011_auto_20181101_1339.py @@ -1,6 +1,4 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11 on 2018-11-01 13:39 -from __future__ import unicode_literals from django.db import migrations, models diff --git a/easyaudit/migrations/0012_auto_20181018_0012.py b/easyaudit/migrations/0012_auto_20181018_0012.py index 78d2a83..71dec9d 100644 --- a/easyaudit/migrations/0012_auto_20181018_0012.py +++ b/easyaudit/migrations/0012_auto_20181018_0012.py @@ -1,6 +1,4 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.12 on 2018-10-18 00:12 -from __future__ import unicode_literals from django.conf import settings from django.db import migrations, models diff --git a/easyaudit/migrations/0018_auto_20240105_0125.py b/easyaudit/migrations/0018_auto_20240105_0125.py new file mode 100644 index 0000000..2825cff --- /dev/null +++ b/easyaudit/migrations/0018_auto_20240105_0125.py @@ -0,0 +1,61 @@ +# Generated by Django 3.2 on 2024-01-05 01:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('easyaudit', '0017_alter_requestevent_datetime'), + ] + + operations = [ + migrations.AlterField( + model_name='crudevent', + name='changed_fields', + field=models.TextField(blank=True, default='', verbose_name='Changed fields'), + ), + migrations.AlterField( + model_name='crudevent', + name='object_json_repr', + field=models.TextField(blank=True, default='', verbose_name='Object JSON representation'), + ), + migrations.AlterField( + model_name='crudevent', + name='object_repr', + field=models.TextField(blank=True, default='', verbose_name='Object representation'), + ), + migrations.AlterField( + model_name='crudevent', + name='user_pk_as_string', + field=models.CharField(blank=True, default='', help_text='String version of the user pk', max_length=255, verbose_name='User PK as string'), + ), + migrations.AlterField( + model_name='loginevent', + name='remote_ip', + field=models.CharField(db_index=True, default='', max_length=50, verbose_name='Remote IP'), + ), + migrations.AlterField( + model_name='loginevent', + name='username', + field=models.CharField(blank=True, default='', max_length=255, verbose_name='Username'), + ), + migrations.AlterField( + model_name='requestevent', + name='query_string', + field=models.TextField(default='', verbose_name='Query string'), + ), + migrations.AlterField( + model_name='requestevent', + name='remote_ip', + field=models.CharField(db_index=True, default='', max_length=50, verbose_name='Remote IP'), + ), + migrations.AlterIndexTogether( + name='crudevent', + index_together=set(), + ), + migrations.AddIndex( + model_name='crudevent', + index=models.Index(fields=['object_id', 'content_type'], name='easyaudit_c_object__82020b_idx'), + ), + ] diff --git a/easyaudit/models.py b/easyaudit/models.py index 65b3a3c..b32ed9b 100644 --- a/easyaudit/models.py +++ b/easyaudit/models.py @@ -18,46 +18,67 @@ class CRUDEvent(models.Model): M2M_CLEAR_REV = 11 TYPES = ( - (CREATE, _('Create')), - (UPDATE, _('Update')), - (DELETE, _('Delete')), - (M2M_CHANGE, _('Many-to-Many Change')), - (M2M_CHANGE_REV, _('Reverse Many-to-Many Change')), - (M2M_ADD, _('Many-to-Many Add')), - (M2M_ADD_REV, _('Reverse Many-to-Many Add')), - (M2M_REMOVE, _('Many-to-Many Remove')), - (M2M_REMOVE_REV, _('Reverse Many-to-Many Remove')), - (M2M_CLEAR, _('Many-to-Many Clear')), - (M2M_CLEAR_REV, _('Reverse Many-to-Many Clear')), + (CREATE, _("Create")), + (UPDATE, _("Update")), + (DELETE, _("Delete")), + (M2M_CHANGE, _("Many-to-Many Change")), + (M2M_CHANGE_REV, _("Reverse Many-to-Many Change")), + (M2M_ADD, _("Many-to-Many Add")), + (M2M_ADD_REV, _("Reverse Many-to-Many Add")), + (M2M_REMOVE, _("Many-to-Many Remove")), + (M2M_REMOVE_REV, _("Reverse Many-to-Many Remove")), + (M2M_CLEAR, _("Many-to-Many Clear")), + (M2M_CLEAR_REV, _("Reverse Many-to-Many Clear")), ) - event_type = models.SmallIntegerField(choices=TYPES, verbose_name=_('Event type')) - object_id = models.CharField(max_length=255, verbose_name=_('Object ID')) - content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, db_constraint=False, verbose_name=_('Content type')) - object_repr = models.TextField(null=True, blank=True, verbose_name=_('Object representation')) - object_json_repr = models.TextField(null=True, blank=True, verbose_name=_('Object JSON representation')) - changed_fields = models.TextField(null=True, blank=True, verbose_name=_('Changed fields')) - user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, - blank=True, on_delete=models.SET_NULL, - db_constraint=False, verbose_name=_('User')) - user_pk_as_string = models.CharField(max_length=255, null=True, blank=True, - help_text=_('String version of the user pk'), verbose_name=_('User PK as string')) - datetime = models.DateTimeField(auto_now_add=True, verbose_name=_('Date time')) + event_type = models.SmallIntegerField(choices=TYPES, verbose_name=_("Event type")) + object_id = models.CharField(max_length=255, verbose_name=_("Object ID")) + content_type = models.ForeignKey( + ContentType, + on_delete=models.CASCADE, + db_constraint=False, + verbose_name=_("Content type"), + ) + object_repr = models.TextField( + default="", blank=True, verbose_name=_("Object representation") + ) + object_json_repr = models.TextField( + default="", blank=True, verbose_name=_("Object JSON representation") + ) + changed_fields = models.TextField( + default="", blank=True, verbose_name=_("Changed fields") + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + db_constraint=False, + verbose_name=_("User"), + ) + user_pk_as_string = models.CharField( + max_length=255, + default="", + blank=True, + help_text=_("String version of the user pk"), + verbose_name=_("User PK as string"), + ) + datetime = models.DateTimeField(auto_now_add=True, verbose_name=_("Date time")) + + class Meta: + verbose_name = _("CRUD event") + verbose_name_plural = _("CRUD events") + ordering = ["-datetime"] + indexes = [models.Index(fields=["object_id", "content_type"])] def is_create(self): - return self.CREATE == self.event_type + return self.event_type == self.CREATE def is_update(self): - return self.UPDATE == self.event_type + return self.event_type == self.UPDATE def is_delete(self): - return self.DELETE == self.event_type - - class Meta: - verbose_name = _('CRUD event') - verbose_name_plural = _('CRUD events') - ordering = ['-datetime'] - index_together = ['object_id', 'content_type', ] + return self.event_type == self.DELETE class LoginEvent(models.Model): @@ -65,35 +86,55 @@ class LoginEvent(models.Model): LOGOUT = 1 FAILED = 2 TYPES = ( - (LOGIN, _('Login')), - (LOGOUT, _('Logout')), - (FAILED, _('Failed login')), + (LOGIN, _("Login")), + (LOGOUT, _("Logout")), + (FAILED, _("Failed login")), + ) + login_type = models.SmallIntegerField(choices=TYPES, verbose_name=_("Event type")) + username = models.CharField( + max_length=255, default="", blank=True, verbose_name=_("Username") + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + db_constraint=False, + verbose_name=_("User"), + ) + remote_ip = models.CharField( + max_length=50, default="", db_index=True, verbose_name=_("Remote IP") ) - login_type = models.SmallIntegerField(choices=TYPES, verbose_name=_('Event type')) - username = models.CharField(max_length=255, null=True, blank=True, verbose_name=_('Username')) - user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, - on_delete=models.SET_NULL, db_constraint=False, - verbose_name=_('User')) - remote_ip = models.CharField(max_length=50, null=True, db_index=True, verbose_name=_('Remote IP')) - datetime = models.DateTimeField(auto_now_add=True, verbose_name=_('Date time')) + datetime = models.DateTimeField(auto_now_add=True, verbose_name=_("Date time")) class Meta: - verbose_name = _('login event') - verbose_name_plural = _('login events') - ordering = ['-datetime'] + verbose_name = _("login event") + verbose_name_plural = _("login events") + ordering = ["-datetime"] class RequestEvent(models.Model): - url = models.CharField(null=False, db_index=True, max_length=254, verbose_name=_('URL')) - method = models.CharField(max_length=20, null=False, db_index=True, verbose_name=_('Method')) - query_string = models.TextField(null=True, verbose_name=_('Query string')) - user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, - on_delete=models.SET_NULL, db_constraint=False, - verbose_name=_('User')) - remote_ip = models.CharField(max_length=50, null=True, db_index=True, verbose_name=_('Remote IP')) - datetime = models.DateTimeField(auto_now_add=True, db_index=True, verbose_name=_('Date time')) + url = models.CharField(null=False, db_index=True, max_length=254, verbose_name=_("URL")) + method = models.CharField( + max_length=20, null=False, db_index=True, verbose_name=_("Method") + ) + query_string = models.TextField(default="", verbose_name=_("Query string")) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + db_constraint=False, + verbose_name=_("User"), + ) + remote_ip = models.CharField( + max_length=50, default="", db_index=True, verbose_name=_("Remote IP") + ) + datetime = models.DateTimeField( + auto_now_add=True, db_index=True, verbose_name=_("Date time") + ) class Meta: - verbose_name = _('request event') - verbose_name_plural = _('request events') - ordering = ['-datetime'] + verbose_name = _("request event") + verbose_name_plural = _("request events") + ordering = ["-datetime"] diff --git a/easyaudit/settings.py b/easyaudit/settings.py index 2d5a367..e5467dd 100644 --- a/easyaudit/settings.py +++ b/easyaudit/settings.py @@ -1,4 +1,3 @@ - from importlib import import_module import django.db.utils @@ -14,10 +13,10 @@ def get_model_list(class_list): - """ - Receives a list of strings with app_name.model_name format - and turns them into classes. If an item is already a class - it ignores it. + """Get a list of model classes from a list of strings. + + Receives a list of strings with app_name.model_name format and turns them into classes. + If an item is already a class, it ignores it. """ for idx, item in enumerate(class_list): if isinstance(item, (str,)): @@ -26,29 +25,47 @@ def get_model_list(class_list): # Should Django Easy Audit log model/auth/request events? -WATCH_AUTH_EVENTS = getattr(settings, 'DJANGO_EASY_AUDIT_WATCH_AUTH_EVENTS', True) -WATCH_MODEL_EVENTS = getattr(settings, 'DJANGO_EASY_AUDIT_WATCH_MODEL_EVENTS', True) -WATCH_REQUEST_EVENTS = getattr(settings, 'DJANGO_EASY_AUDIT_WATCH_REQUEST_EVENTS', True) -REMOTE_ADDR_HEADER = getattr(settings, 'DJANGO_EASY_AUDIT_REMOTE_ADDR_HEADER', 'REMOTE_ADDR') +WATCH_AUTH_EVENTS = getattr(settings, "DJANGO_EASY_AUDIT_WATCH_AUTH_EVENTS", True) +WATCH_MODEL_EVENTS = getattr(settings, "DJANGO_EASY_AUDIT_WATCH_MODEL_EVENTS", True) +WATCH_REQUEST_EVENTS = getattr(settings, "DJANGO_EASY_AUDIT_WATCH_REQUEST_EVENTS", True) +REMOTE_ADDR_HEADER = getattr( + settings, "DJANGO_EASY_AUDIT_REMOTE_ADDR_HEADER", "REMOTE_ADDR" +) -USER_DB_CONSTRAINT = bool(getattr(settings, 'DJANGO_EASY_AUDIT_USER_DB_CONSTRAINT', True)) +USER_DB_CONSTRAINT = bool(getattr(settings, "DJANGO_EASY_AUDIT_USER_DB_CONSTRAINT", True)) # logging backend settings -LOGGING_BACKEND = getattr(settings, 'DJANGO_EASY_AUDIT_LOGGING_BACKEND', 'easyaudit.backends.ModelBackend') +LOGGING_BACKEND = getattr( + settings, "DJANGO_EASY_AUDIT_LOGGING_BACKEND", "easyaudit.backends.ModelBackend" +) # Models which Django Easy Audit will not log. # By default, all but some models will be audited. # The list of excluded models can be overwritten or extended # by defining the following settings in the project. -UNREGISTERED_CLASSES = [CRUDEvent, LoginEvent, RequestEvent, Migration, Session, Permission, ContentType, MigrationRecorder.Migration] +UNREGISTERED_CLASSES = [ + CRUDEvent, + LoginEvent, + RequestEvent, + Migration, + Session, + Permission, + ContentType, + MigrationRecorder.Migration, +] # Import and unregister LogEntry class only if Django Admin app is installed -if apps.is_installed('django.contrib.admin'): +if apps.is_installed("django.contrib.admin"): from django.contrib.admin.models import LogEntry + UNREGISTERED_CLASSES += [LogEntry] -UNREGISTERED_CLASSES = getattr(settings, 'DJANGO_EASY_AUDIT_UNREGISTERED_CLASSES_DEFAULT', UNREGISTERED_CLASSES) -UNREGISTERED_CLASSES.extend(getattr(settings, 'DJANGO_EASY_AUDIT_UNREGISTERED_CLASSES_EXTRA', [])) +UNREGISTERED_CLASSES = getattr( + settings, "DJANGO_EASY_AUDIT_UNREGISTERED_CLASSES_DEFAULT", UNREGISTERED_CLASSES +) +UNREGISTERED_CLASSES.extend( + getattr(settings, "DJANGO_EASY_AUDIT_UNREGISTERED_CLASSES_EXTRA", []) +) get_model_list(UNREGISTERED_CLASSES) @@ -56,7 +73,7 @@ def get_model_list(class_list): # If the following setting is defined in the project, # only the listed models will be audited, and every other # model will be excluded. -REGISTERED_CLASSES = getattr(settings, 'DJANGO_EASY_AUDIT_REGISTERED_CLASSES', []) +REGISTERED_CLASSES = getattr(settings, "DJANGO_EASY_AUDIT_REGISTERED_CLASSES", []) get_model_list(REGISTERED_CLASSES) @@ -65,58 +82,128 @@ def get_model_list(class_list): # The list of excluded URLs can be overwritten or extended # by defining the following settings in the project. # Note: it is a list of regular expressions. -UNREGISTERED_URLS = [r'^/admin/', r'^/static/', r'^/favicon.ico$'] -UNREGISTERED_URLS = getattr(settings, 'DJANGO_EASY_AUDIT_UNREGISTERED_URLS_DEFAULT', UNREGISTERED_URLS) -UNREGISTERED_URLS.extend(getattr(settings, 'DJANGO_EASY_AUDIT_UNREGISTERED_URLS_EXTRA', [])) +UNREGISTERED_URLS = [r"^/admin/", r"^/static/", r"^/favicon.ico$"] +UNREGISTERED_URLS = getattr( + settings, "DJANGO_EASY_AUDIT_UNREGISTERED_URLS_DEFAULT", UNREGISTERED_URLS +) +UNREGISTERED_URLS.extend(getattr(settings, "DJANGO_EASY_AUDIT_UNREGISTERED_URLS_EXTRA", [])) # URLs which Django Easy Audit WILL log. # If the following setting is defined in the project, # only the listed URLs will be audited, and every other # URL will be excluded. -REGISTERED_URLS = getattr(settings, 'DJANGO_EASY_AUDIT_REGISTERED_URLS', []) +REGISTERED_URLS = getattr(settings, "DJANGO_EASY_AUDIT_REGISTERED_URLS", []) # By default all modules are listed in the admin. # This can be changed with the following settings. -ADMIN_SHOW_MODEL_EVENTS = getattr(settings, 'DJANGO_EASY_AUDIT_ADMIN_SHOW_MODEL_EVENTS', True) -ADMIN_SHOW_AUTH_EVENTS = getattr(settings, 'DJANGO_EASY_AUDIT_ADMIN_SHOW_AUTH_EVENTS', True) -ADMIN_SHOW_REQUEST_EVENTS = getattr(settings, 'DJANGO_EASY_AUDIT_ADMIN_SHOW_REQUEST_EVENTS', True) +ADMIN_SHOW_MODEL_EVENTS = getattr( + settings, "DJANGO_EASY_AUDIT_ADMIN_SHOW_MODEL_EVENTS", True +) +ADMIN_SHOW_AUTH_EVENTS = getattr(settings, "DJANGO_EASY_AUDIT_ADMIN_SHOW_AUTH_EVENTS", True) +ADMIN_SHOW_REQUEST_EVENTS = getattr( + settings, "DJANGO_EASY_AUDIT_ADMIN_SHOW_REQUEST_EVENTS", True +) # project defined callbacks CRUD_DIFFERENCE_CALLBACKS = [] -CRUD_DIFFERENCE_CALLBACKS = getattr(settings, 'DJANGO_EASY_AUDIT_CRUD_DIFFERENCE_CALLBACKS', CRUD_DIFFERENCE_CALLBACKS) -DATABASE_ALIAS = getattr(settings, 'DJANGO_EASY_AUDIT_DATABASE_ALIAS', django.db.utils.DEFAULT_DB_ALIAS) -# the callbacks could come in as an iterable of strings, where each string is the package.module.function +CRUD_DIFFERENCE_CALLBACKS = getattr( + settings, "DJANGO_EASY_AUDIT_CRUD_DIFFERENCE_CALLBACKS", CRUD_DIFFERENCE_CALLBACKS +) +DATABASE_ALIAS = getattr( + settings, "DJANGO_EASY_AUDIT_DATABASE_ALIAS", django.db.utils.DEFAULT_DB_ALIAS +) +# The callbacks could come in as an iterable of strings, where each string is the +# package.module.function for idx, callback in enumerate(CRUD_DIFFERENCE_CALLBACKS): if not callable(callback): # keep as is if it is callable - CRUD_DIFFERENCE_CALLBACKS[idx] = getattr(import_module('.'.join(callback.split('.')[:-1])), - callback.split('.')[-1], None) + CRUD_DIFFERENCE_CALLBACKS[idx] = getattr( + import_module(".".join(callback.split(".")[:-1])), + callback.split(".")[-1], + None, + ) -# although this setting "exists" here we do not intend to use it anywhere due to test run issues -# maybe we can properly solve this at a latter time. instead, anything inside of this library -# should do the same getattr check here, bsaed on normal `settings` from `django.conf`. -CRUD_EVENT_NO_CHANGED_FIELDS_SKIP = getattr(settings, "DJANGO_EASY_AUDIT_CRUD_EVENT_NO_CHANGED_FIELDS_SKIP", False) +"""although this setting "exists" here we do not intend to use it anywhere due to test run +issues maybe we can properly solve this at a latter time. instead, anything inside of this +library should do the same getattr check here, based on normal `settings` from +`django.conf`.""" +CRUD_EVENT_NO_CHANGED_FIELDS_SKIP = getattr( + settings, "DJANGO_EASY_AUDIT_CRUD_EVENT_NO_CHANGED_FIELDS_SKIP", False +) +"""Purge table optimization: +If TRUNCATE_TABLE_SQL_STATEMENT is not empty, we use it as custom sql statement to speed up +table truncation bypassing ORM, i.e.: -# Purge table optimization: -# If TRUNCATE_TABLE_SQL_STATEMENT is not empty, we use it as custom sql statement -# to speed up table truncation bypassing ORM, i.e.: -# DJANGO_EASY_AUDIT_TRUNCATE_TABLE_SQL_STATEMENT = 'TRUNCATE TABLE "{db_table}"' # for Postgresql -# Else we use Django Orm as follows: -# model.objects.all().delete() -# which is however much costly when many rows are involved -TRUNCATE_TABLE_SQL_STATEMENT = getattr(settings, 'DJANGO_EASY_AUDIT_TRUNCATE_TABLE_SQL_STATEMENT', '') + DJANGO_EASY_AUDIT_TRUNCATE_TABLE_SQL_STATEMENT = 'TRUNCATE TABLE "{db_table}"' + +Else we use Django Orm as follows: + + model.objects.all().delete() + +which is however much costly when many rows are involved""" +TRUNCATE_TABLE_SQL_STATEMENT = getattr( + settings, "DJANGO_EASY_AUDIT_TRUNCATE_TABLE_SQL_STATEMENT", "" +) # Changeview filters configuration -CRUD_EVENT_LIST_FILTER = getattr(settings, 'DJANGO_EASY_AUDIT_CRUD_EVENT_LIST_FILTER', ['event_type', 'content_type', 'user', 'datetime', ]) -LOGIN_EVENT_LIST_FILTER = getattr(settings, 'DJANGO_EASY_AUDIT_LOGIN_EVENT_LIST_FILTER', ['login_type', 'user', 'datetime', ]) -REQUEST_EVENT_LIST_FILTER = getattr(settings, 'DJANGO_EASY_AUDIT_REQUEST_EVENT_LIST_FILTER', ['method', 'user', 'datetime', ]) +CRUD_EVENT_LIST_FILTER = getattr( + settings, + "DJANGO_EASY_AUDIT_CRUD_EVENT_LIST_FILTER", + [ + "event_type", + "content_type", + "user", + "datetime", + ], +) +LOGIN_EVENT_LIST_FILTER = getattr( + settings, + "DJANGO_EASY_AUDIT_LOGIN_EVENT_LIST_FILTER", + [ + "login_type", + "user", + "datetime", + ], +) +REQUEST_EVENT_LIST_FILTER = getattr( + settings, + "DJANGO_EASY_AUDIT_REQUEST_EVENT_LIST_FILTER", + [ + "method", + "user", + "datetime", + ], +) # Search fields configuration -CRUD_EVENT_SEARCH_FIELDS = getattr(settings, 'DJANGO_EASY_AUDIT_CRUD_EVENT_SEARCH_FIELDS', ['=object_id', 'object_json_repr', ]) -LOGIN_EVENT_SEARCH_FIELDS = getattr(settings, 'DJANGO_EASY_AUDIT_LOGIN_EVENT_SEARCH_FIELDS', ['=remote_ip', 'username', ]) -REQUEST_EVENT_SEARCH_FIELDS = getattr(settings, 'DJANGO_EASY_AUDIT_REQUEST_EVENT_SEARCH_FIELDS', ['=remote_ip', 'user__username', 'url', 'query_string', ]) - -READONLY_EVENTS = getattr(settings, 'DJANGO_EASY_AUDIT_READONLY_EVENTS', False) +CRUD_EVENT_SEARCH_FIELDS = getattr( + settings, + "DJANGO_EASY_AUDIT_CRUD_EVENT_SEARCH_FIELDS", + [ + "=object_id", + "object_json_repr", + ], +) +LOGIN_EVENT_SEARCH_FIELDS = getattr( + settings, + "DJANGO_EASY_AUDIT_LOGIN_EVENT_SEARCH_FIELDS", + [ + "=remote_ip", + "username", + ], +) +REQUEST_EVENT_SEARCH_FIELDS = getattr( + settings, + "DJANGO_EASY_AUDIT_REQUEST_EVENT_SEARCH_FIELDS", + [ + "=remote_ip", + "user__username", + "url", + "query_string", + ], +) + +READONLY_EVENTS = getattr(settings, "DJANGO_EASY_AUDIT_READONLY_EVENTS", False) diff --git a/easyaudit/signals/auth_signals.py b/easyaudit/signals/auth_signals.py index f8bf9b4..9cf4a06 100644 --- a/easyaudit/signals/auth_signals.py +++ b/easyaudit/signals/auth_signals.py @@ -1,11 +1,15 @@ -from django.contrib.auth import signals, get_user_model +from django.contrib.auth import get_user_model, signals from django.db import transaction from django.utils.module_loading import import_string from easyaudit.middleware.easyaudit import get_current_request from easyaudit.models import LoginEvent -from easyaudit.settings import REMOTE_ADDR_HEADER, WATCH_AUTH_EVENTS, LOGGING_BACKEND, \ - DATABASE_ALIAS +from easyaudit.settings import ( + DATABASE_ALIAS, + LOGGING_BACKEND, + REMOTE_ADDR_HEADER, + WATCH_AUTH_EVENTS, +) from easyaudit.utils import should_propagate_exceptions audit_logger = import_string(LOGGING_BACKEND)() @@ -14,12 +18,14 @@ def user_logged_in(sender, request, user, **kwargs): try: with transaction.atomic(using=DATABASE_ALIAS): - login_event = audit_logger.login({ - 'login_type': LoginEvent.LOGIN, - 'username': getattr(user, user.USERNAME_FIELD), - 'user_id': getattr(user, 'id', None), - 'remote_ip': request.META.get(REMOTE_ADDR_HEADER, '') - }) + audit_logger.login( + { + "login_type": LoginEvent.LOGIN, + "username": getattr(user, user.USERNAME_FIELD), + "user_id": getattr(user, "id", None), + "remote_ip": request.META.get(REMOTE_ADDR_HEADER, ""), + } + ) except Exception: if should_propagate_exceptions(): raise @@ -28,12 +34,14 @@ def user_logged_in(sender, request, user, **kwargs): def user_logged_out(sender, request, user, **kwargs): try: with transaction.atomic(using=DATABASE_ALIAS): - login_event = audit_logger.login({ - 'login_type': LoginEvent.LOGOUT, - 'username': getattr(user, user.USERNAME_FIELD), - 'user_id': getattr(user, 'id', None), - 'remote_ip': request.META.get(REMOTE_ADDR_HEADER, '') - }) + audit_logger.login( + { + "login_type": LoginEvent.LOGOUT, + "username": getattr(user, user.USERNAME_FIELD), + "user_id": getattr(user, "id", None), + "remote_ip": request.META.get(REMOTE_ADDR_HEADER, ""), + } + ) except Exception: if should_propagate_exceptions(): raise @@ -42,19 +50,27 @@ def user_logged_out(sender, request, user, **kwargs): def user_login_failed(sender, credentials, **kwargs): try: with transaction.atomic(using=DATABASE_ALIAS): - request = get_current_request() # request argument not available in django < 1.11 + request = get_current_request() user_model = get_user_model() - login_event = audit_logger.login({ - 'login_type': LoginEvent.FAILED, - 'username': credentials[user_model.USERNAME_FIELD], - 'remote_ip': request.META.get(REMOTE_ADDR_HEADER, '') - }) + audit_logger.login( + { + "login_type": LoginEvent.FAILED, + "username": credentials[user_model.USERNAME_FIELD], + "remote_ip": request.META.get(REMOTE_ADDR_HEADER, ""), + } + ) except Exception: if should_propagate_exceptions(): raise if WATCH_AUTH_EVENTS: - signals.user_logged_in.connect(user_logged_in, dispatch_uid='easy_audit_signals_logged_in') - signals.user_logged_out.connect(user_logged_out, dispatch_uid='easy_audit_signals_logged_out') - signals.user_login_failed.connect(user_login_failed, dispatch_uid='easy_audit_signals_login_failed') + signals.user_logged_in.connect( + user_logged_in, dispatch_uid="easy_audit_signals_logged_in" + ) + signals.user_logged_out.connect( + user_logged_out, dispatch_uid="easy_audit_signals_logged_out" + ) + signals.user_login_failed.connect( + user_login_failed, dispatch_uid="easy_audit_signals_login_failed" + ) diff --git a/easyaudit/signals/crud_flows.py b/easyaudit/signals/crud_flows.py new file mode 100644 index 0000000..a87627d --- /dev/null +++ b/easyaudit/signals/crud_flows.py @@ -0,0 +1,121 @@ +import contextlib +import json +import logging + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AnonymousUser +from django.contrib.contenttypes.models import ContentType +from django.core.serializers.json import DjangoJSONEncoder +from django.db import transaction +from django.utils import timezone +from django.utils.module_loading import import_string + +from easyaudit.middleware.easyaudit import get_current_user +from easyaudit.models import CRUDEvent +from easyaudit.settings import DATABASE_ALIAS, LOGGING_BACKEND +from easyaudit.utils import get_m2m_field_name, should_propagate_exceptions + +logger = logging.getLogger(__name__) +audit_logger = import_string(LOGGING_BACKEND)() + + +def get_current_user_details(): + user_id = "" + user_pk_as_string = "" + + with contextlib.suppress(Exception): + user = get_current_user() + if user and not isinstance(user, AnonymousUser): + if getattr(settings, "DJANGO_EASY_AUDIT_CHECK_IF_REQUEST_USER_EXISTS", True): + # validate that the user still exists + user = get_user_model().objects.get(pk=user.pk) + user_id, user_pk_as_string = user.id, str(user.pk) + + return user_id, user_pk_as_string + + +def log_event(event_type, instance, object_json_repr, **kwargs): + user_id, user_pk_as_string = get_current_user_details() + with transaction.atomic(using=DATABASE_ALIAS): + audit_logger.crud( + { + "content_type_id": ContentType.objects.get_for_model(instance).id, + "datetime": timezone.now(), + "event_type": event_type, + "object_id": instance.pk, + "object_json_repr": object_json_repr or "", + "object_repr": str(instance), + "user_id": user_id, + "user_pk_as_string": user_pk_as_string, + **kwargs, + } + ) + + +def handle_flow_exception(instance, signal): + instance_str = "" + with contextlib.suppress(Exception): + instance_str = f" instance: {instance}, instance pk: {instance.pk}" + + logger.exception( + f"easy audit had a {signal} exception on CRUDEvent creation.{instance_str}" + ) + if should_propagate_exceptions(): + raise + + +def pre_save_crud_flow(instance, object_json_repr, changed_fields): + try: + log_event( + CRUDEvent.UPDATE, + instance, + object_json_repr, + changed_fields=changed_fields, + ) + except Exception: + handle_flow_exception(instance, "pre_save") + + +def post_save_crud_flow(instance, object_json_repr): + try: + log_event( + CRUDEvent.CREATE, + instance, + object_json_repr, + ) + except Exception: + handle_flow_exception(instance, "pre_save") + + +def m2m_changed_crud_flow( # noqa: PLR0913 + action, model, instance, pk_set, event_type, object_json_repr +): + try: + if action == "post_clear": + changed_fields = [] + else: + changed_fields = json.dumps( + {get_m2m_field_name(model, instance): list(pk_set)}, + cls=DjangoJSONEncoder, + ) + log_event( + event_type, + instance, + object_json_repr, + changed_fields=changed_fields, + ) + except Exception: + handle_flow_exception(instance, "pre_save") + + +def post_delete_crud_flow(instance, object_json_repr): + try: + log_event( + CRUDEvent.DELETE, + instance, + object_json_repr, + ) + + except Exception: + handle_flow_exception(instance, "pre_save") diff --git a/easyaudit/signals/model_signals.py b/easyaudit/signals/model_signals.py index 568dc14..132aa7b 100644 --- a/easyaudit/signals/model_signals.py +++ b/easyaudit/signals/model_signals.py @@ -1,34 +1,37 @@ +# ruff: noqa: PLR0913 import json import logging +from functools import partial from django.conf import settings -from django.contrib.auth import get_user_model -from django.contrib.auth.models import AnonymousUser -from django.contrib.contenttypes.models import ContentType from django.core import serializers from django.core.serializers.json import DjangoJSONEncoder from django.db import transaction from django.db.models import signals -from django.utils import timezone from django.utils.encoding import force_str -from django.utils.module_loading import import_string -from easyaudit.middleware.easyaudit import get_current_request, \ - get_current_user +from easyaudit.middleware.easyaudit import get_current_request from easyaudit.models import CRUDEvent -from easyaudit.settings import REGISTERED_CLASSES, UNREGISTERED_CLASSES, \ - WATCH_MODEL_EVENTS, CRUD_DIFFERENCE_CALLBACKS, LOGGING_BACKEND, \ - DATABASE_ALIAS -from easyaudit.utils import get_m2m_field_name, model_delta, should_propagate_exceptions +from easyaudit.settings import ( + CRUD_DIFFERENCE_CALLBACKS, + REGISTERED_CLASSES, + UNREGISTERED_CLASSES, + WATCH_MODEL_EVENTS, +) +from easyaudit.utils import model_delta, should_propagate_exceptions + +from .crud_flows import ( + m2m_changed_crud_flow, + post_delete_crud_flow, + post_save_crud_flow, + pre_save_crud_flow, +) logger = logging.getLogger(__name__) -audit_logger = import_string(LOGGING_BACKEND)() def should_audit(instance): - """Returns True or False to indicate whether the instance - should be audited or not, depending on the project settings.""" - + """Return True or False to indicate whether the instance should be audited.""" # do not audit any model listed in UNREGISTERED_CLASSES for unregistered_class in UNREGISTERED_CLASSES: if isinstance(instance, unregistered_class): @@ -46,29 +49,37 @@ def should_audit(instance): return True -def get_current_user_details(): - user_id = None - user_pk_as_string = None +def call_callbacks( + instance, object_json_repr, created, raw, using, update_fields, **kwargs +) -> bool: + kwargs["request"] = get_current_request() # Make request available in callbacks + + return all( + callback( + instance, + object_json_repr, + created, + raw, + using, + update_fields, + **kwargs, + ) + for callback in CRUD_DIFFERENCE_CALLBACKS + if callable(callback) + ) - try: - user = get_current_user() - if user and not isinstance(user, AnonymousUser): - if getattr(settings, "DJANGO_EASY_AUDIT_CHECK_IF_REQUEST_USER_EXISTS", True): - # validate that the user still exists - user = get_user_model().objects.get(pk=user.pk) - user_id, user_pk_as_string = user.id, str(user.pk) - except: - pass - return user_id, user_pk_as_string +def handle_signal_exception(signal): + logger.exception(f"easy audit had a {signal} exception.") + + if should_propagate_exceptions(): + raise -# signals def pre_save(sender, instance, raw, using, update_fields, **kwargs): - """https://docs.djangoproject.com/es/1.10/ref/signals/#post-save""" if raw: # Return if loading Fixtures - return + return None try: if not should_audit(instance): @@ -78,75 +89,52 @@ def pre_save(sender, instance, raw, using, update_fields, **kwargs): try: object_json_repr = serializers.serialize("json", [instance]) except Exception: - # We need a better way for this to work. ManyToMany will fail on pre_save on create + # We need a better way for this to work. ManyToMany will fail on + # pre_save on create return None # Determine if the instance is a create created = instance.pk is None or instance._state.adding # created or updated? + delta = {} if not created: old_model = sender.objects.get(pk=instance.pk) delta = model_delta(old_model, instance) - if not delta and getattr(settings, "DJANGO_EASY_AUDIT_CRUD_EVENT_NO_CHANGED_FIELDS_SKIP", False): - return False - changed_fields = json.dumps(delta) - event_type = CRUDEvent.UPDATE - # user - user_id, user_pk_as_string = get_current_user_details() + if not delta and getattr( + settings, + "DJANGO_EASY_AUDIT_CRUD_EVENT_NO_CHANGED_FIELDS_SKIP", + False, + ): + return False # callbacks - kwargs['request'] = get_current_request() # make request available for callbacks - create_crud_event = all( - callback(instance, object_json_repr, created, raw, using, update_fields, **kwargs) - for callback in CRUD_DIFFERENCE_CALLBACKS if callable(callback)) - # create crud event only if all callbacks returned True + create_crud_event = call_callbacks( + instance, object_json_repr, created, raw, using, update_fields, **kwargs + ) + + # Create crud event only if all callbacks returned True if create_crud_event and not created: - c_t = ContentType.objects.get_for_model(instance) - - def crud_flow(): - try: - # atomicity based on the easyaudit database alias - with transaction.atomic(using=DATABASE_ALIAS): - crud_event = audit_logger.crud({ - 'event_type': event_type, - 'object_repr': str(instance), - 'object_json_repr': object_json_repr, - 'changed_fields': changed_fields, - 'content_type_id': c_t.id, - 'object_id': instance.pk, - 'user_id': user_id, - 'datetime': timezone.now(), - 'user_pk_as_string': user_pk_as_string, - }) - except Exception as e: - try: - logger.exception( - "easy audit had a pre_save exception on CRUDEvent creation. instance: {}, instance pk: {}".format( - instance, instance.pk)) - except Exception: - pass - - if should_propagate_exceptions(): - raise e + crud_flow = partial( + pre_save_crud_flow, + instance=instance, + object_json_repr=object_json_repr, + changed_fields=json.dumps(delta), + ) if getattr(settings, "TEST", False): crud_flow() else: transaction.on_commit(crud_flow, using=using) except Exception: - logger.exception('easy audit had a pre-save exception.') - - if should_propagate_exceptions(): - raise + handle_signal_exception("pre_save") def post_save(sender, instance, created, raw, using, update_fields, **kwargs): - """https://docs.djangoproject.com/es/1.10/ref/signals/#post-save""" if raw: # Return if loading Fixtures - return + return None try: if not should_audit(instance): @@ -155,61 +143,28 @@ def post_save(sender, instance, created, raw, using, update_fields, **kwargs): with transaction.atomic(using=using): object_json_repr = serializers.serialize("json", [instance]) - # created or updated? - if created: - event_type = CRUDEvent.CREATE - - # user - user_id, user_pk_as_string = get_current_user_details() - # callbacks - kwargs['request'] = get_current_request() # make request available for callbacks - create_crud_event = all(callback(instance, object_json_repr, - created, raw, using, update_fields, **kwargs) - for callback in CRUD_DIFFERENCE_CALLBACKS - if callable(callback)) + create_crud_event = call_callbacks( + instance, object_json_repr, created, raw, using, update_fields, **kwargs + ) - # create crud event only if all callbacks returned True + # Create crud event only if all callbacks returned True if create_crud_event and created: - c_t = ContentType.objects.get_for_model(instance) - - def crud_flow(): - try: - with transaction.atomic(using=DATABASE_ALIAS): - crud_event = audit_logger.crud({ - 'event_type': event_type, - 'object_repr': str(instance), - 'object_json_repr': object_json_repr, - 'content_type_id': c_t.id, - 'object_id': instance.pk, - 'user_id': user_id, - 'datetime': timezone.now(), - 'user_pk_as_string': user_pk_as_string - }) - except Exception as e: - try: - logger.exception( - "easy audit had a post_save exception on CRUDEvent creation. instance: {}, instance pk: {}".format( - instance, instance.pk)) - except Exception: - pass - - if should_propagate_exceptions(): - raise e - + crud_flow = partial( + post_save_crud_flow, + instance=instance, + object_json_repr=object_json_repr, + ) if getattr(settings, "TEST", False): crud_flow() else: transaction.on_commit(crud_flow, using=using) except Exception: - logger.exception('easy audit had a post-save exception.') - - if should_propagate_exceptions(): - raise + handle_signal_exception("post_save") def _m2m_rev_field_name(model1, model2): - """Gets the name of the reverse m2m accessor from `model1` to `model2` + """Get the name of the reverse m2m accessor from `model1` to `model2`. For example, if User has a ManyToManyField connected to Group, `_m2m_rev_field_name(Group, User)` retrieves the name of the field on @@ -217,16 +172,14 @@ def _m2m_rev_field_name(model1, model2): `user_set`, but the name can be overridden). """ m2m_field_names = [ - rel.get_accessor_name() for rel in model1._meta.get_fields() - if rel.many_to_many - and rel.auto_created - and rel.related_model == model2 + rel.get_accessor_name() + for rel in model1._meta.get_fields() + if rel.many_to_many and rel.auto_created and rel.related_model == model2 ] return m2m_field_names[0] def m2m_changed(sender, instance, action, reverse, model, pk_set, using, **kwargs): - """https://docs.djangoproject.com/es/1.10/ref/signals/#m2m-changed""" try: if not should_audit(instance): return False @@ -237,16 +190,14 @@ def m2m_changed(sender, instance, action, reverse, model, pk_set, using, **kwarg object_json_repr = serializers.serialize("json", [instance]) if reverse: - if action == 'post_add': - event_type = CRUDEvent.M2M_ADD_REV - elif action == 'post_remove': - event_type = CRUDEvent.M2M_REMOVE_REV - elif action == 'post_clear': - event_type = CRUDEvent.M2M_CLEAR_REV - else: - event_type = CRUDEvent.M2M_CHANGE_REV # just in case - - # add reverse M2M changes to event. must use json lib because + reverse_actions = { + "post_add": CRUDEvent.M2M_ADD_REV, + "post_remove": CRUDEvent.M2M_REMOVE_REV, + "post_clear": CRUDEvent.M2M_CLEAR_REV, + } + event_type = reverse_actions.get(action, CRUDEvent.M2M_CHANGE_REV) + + # Add reverse M2M changes to event. Must use json lib because # django serializers ignore extra fields. tmp_repr = json.loads(object_json_repr) @@ -254,67 +205,36 @@ def m2m_changed(sender, instance, action, reverse, model, pk_set, using, **kwarg related_instances = getattr(instance, m2m_rev_field).all() related_ids = [r.pk for r in related_instances] - tmp_repr[0]['m2m_rev_model'] = force_str(model._meta) - tmp_repr[0]['m2m_rev_pks'] = related_ids - tmp_repr[0]['m2m_rev_action'] = action + tmp_repr[0]["m2m_rev_model"] = force_str(model._meta) + tmp_repr[0]["m2m_rev_pks"] = related_ids + tmp_repr[0]["m2m_rev_action"] = action object_json_repr = json.dumps(tmp_repr, cls=DjangoJSONEncoder) else: - if action == 'post_add': - event_type = CRUDEvent.M2M_ADD - elif action == 'post_remove': - event_type = CRUDEvent.M2M_REMOVE - elif action == 'post_clear': - event_type = CRUDEvent.M2M_CLEAR - else: - event_type = CRUDEvent.M2M_CHANGE # just in case - - # user - user_id, user_pk_as_string = get_current_user_details() - - c_t = ContentType.objects.get_for_model(instance) - - def crud_flow(): - try: - if action == "post_clear": - changed_fields = [] - else: - changed_fields = json.dumps({get_m2m_field_name(model, instance): list(pk_set)}, cls=DjangoJSONEncoder) - with transaction.atomic(using=DATABASE_ALIAS): - crud_event = audit_logger.crud({ - 'event_type': event_type, - 'object_repr': str(instance), - 'object_json_repr': object_json_repr, - 'changed_fields': changed_fields, - 'content_type_id': c_t.id, - 'object_id': instance.pk, - 'user_id': user_id, - 'datetime': timezone.now(), - 'user_pk_as_string': user_pk_as_string - }) - except Exception as e: - try: - logger.exception( - "easy audit had a m2m_changed exception on CRUDEvent creation. instance: {}, instance pk: {}".format( - instance, instance.pk)) - except Exception: - pass - - if should_propagate_exceptions(): - raise e - + forward_actions = { + "post_add": CRUDEvent.M2M_ADD, + "post_remove": CRUDEvent.M2M_REMOVE, + "post_clear": CRUDEvent.M2M_CLEAR, + } + event_type = forward_actions.get(action, CRUDEvent.M2M_CHANGE) + + crud_flow = partial( + m2m_changed_crud_flow, + action=action, + model=model, + instance=instance, + pk_set=pk_set, + event_type=event_type, + object_json_repr=object_json_repr, + ) if getattr(settings, "TEST", False): crud_flow() else: transaction.on_commit(crud_flow, using=using) except Exception: - logger.exception('easy audit had an m2m-changed exception.') - - if should_propagate_exceptions(): - raise + handle_signal_exception("m2m-changed") def post_delete(sender, instance, using, **kwargs): - """https://docs.djangoproject.com/es/1.10/ref/signals/#post-delete""" try: if not should_audit(instance): return False @@ -322,53 +242,24 @@ def post_delete(sender, instance, using, **kwargs): with transaction.atomic(using=using): object_json_repr = serializers.serialize("json", [instance]) - # user - user_id, user_pk_as_string = get_current_user_details() - - c_t = ContentType.objects.get_for_model(instance) - - # object id to be used later - obj_id = instance.pk - - def crud_flow(): - try: - with transaction.atomic(using=DATABASE_ALIAS): - # crud event - crud_event = audit_logger.crud({ - 'event_type': CRUDEvent.DELETE, - 'object_repr': str(instance), - 'object_json_repr': object_json_repr, - 'content_type_id': c_t.id, - 'object_id': obj_id, - 'user_id': user_id, - 'datetime': timezone.now(), - 'user_pk_as_string': user_pk_as_string - }) - - except Exception as e: - try: - logger.exception( - "easy audit had a post_delete exception on CRUDEvent creation. instance: {}, instance pk: {}".format( - instance, instance.pk)) - except Exception: - pass - - if should_propagate_exceptions(): - raise e - + crud_flow = partial( + post_delete_crud_flow, + instance=instance, + object_json_repr=object_json_repr, + ) if getattr(settings, "TEST", False): crud_flow() else: - transaction.on_commit(crud_flow, using=using) + transaction.on_commit( + crud_flow, + using=using, + ) except Exception: - logger.exception('easy audit had a post-delete exception.') - - if should_propagate_exceptions(): - raise + handle_signal_exception("post-delete") if WATCH_MODEL_EVENTS: - signals.post_save.connect(post_save, dispatch_uid='easy_audit_signals_post_save') - signals.pre_save.connect(pre_save, dispatch_uid='easy_audit_signals_pre_save') - signals.m2m_changed.connect(m2m_changed, dispatch_uid='easy_audit_signals_m2m_changed') - signals.post_delete.connect(post_delete, dispatch_uid='easy_audit_signals_post_delete') + signals.post_save.connect(post_save, dispatch_uid="easy_audit_signals_post_save") + signals.pre_save.connect(pre_save, dispatch_uid="easy_audit_signals_pre_save") + signals.m2m_changed.connect(m2m_changed, dispatch_uid="easy_audit_signals_m2m_changed") + signals.post_delete.connect(post_delete, dispatch_uid="easy_audit_signals_post_delete") diff --git a/easyaudit/signals/request_signals.py b/easyaudit/signals/request_signals.py index 9901c90..04fec60 100644 --- a/easyaudit/signals/request_signals.py +++ b/easyaudit/signals/request_signals.py @@ -1,22 +1,24 @@ +import re from importlib import import_module -from django.contrib.auth import get_user_model, SESSION_KEY as AUTH_SESSION_KEY +from django.conf import settings +from django.contrib.auth import SESSION_KEY as AUTH_SESSION_KEY +from django.contrib.auth import get_user_model from django.contrib.sessions.models import Session from django.core.signals import request_started from django.http.cookie import SimpleCookie from django.utils import timezone -from django.conf import settings from django.utils.module_loading import import_string -session_engine = import_module(settings.SESSION_ENGINE) - -# try and get the user from the request; commented for now, may have a bug in this flow. -# from easyaudit.middleware.easyaudit import get_current_user -from easyaudit.settings import REMOTE_ADDR_HEADER, UNREGISTERED_URLS, REGISTERED_URLS, WATCH_REQUEST_EVENTS, \ - LOGGING_BACKEND - -import re +from easyaudit.settings import ( + LOGGING_BACKEND, + REGISTERED_URLS, + REMOTE_ADDR_HEADER, + UNREGISTERED_URLS, + WATCH_REQUEST_EVENTS, +) +session_engine = import_module(settings.SESSION_ENGINE) audit_logger = import_string(LOGGING_BACKEND)() @@ -35,7 +37,7 @@ def should_log_url(url): return True return False - # all good + # all good return True @@ -44,26 +46,24 @@ def request_started_handler(sender, **kwargs): scope = kwargs.get("scope") if environ: path = environ["PATH_INFO"] - cookie_string = environ.get('HTTP_COOKIE') + cookie_string = environ.get("HTTP_COOKIE") remote_ip = environ.get(REMOTE_ADDR_HEADER, None) - method = environ['REQUEST_METHOD'] + method = environ["REQUEST_METHOD"] query_string = environ["QUERY_STRING"] else: - method = scope.get('method') + method = scope.get("method") path = scope.get("path") - headers = dict(scope.get('headers')) - cookie_string = headers.get(b'cookie') + headers = dict(scope.get("headers")) + cookie_string = headers.get(b"cookie") if isinstance(cookie_string, bytes): cookie_string = cookie_string.decode("utf-8") - remote_ip = list(scope.get('client', ('0.0.0.0', 0)))[0] + remote_ip = next(iter(scope.get("client", ("0.0.0.0", 0)))) # noqa: S104 query_string = scope.get("query_string") if not should_log_url(path): return - # try and get the user from the request; commented for now, may have a bug in this flow. - # user = get_current_user() user = None # get the user from cookies if not user and cookie_string: @@ -82,20 +82,23 @@ def request_started_handler(sender, **kwargs): user_id = session.get(AUTH_SESSION_KEY) try: user = get_user_model().objects.get(id=user_id) - except: + except Exception: user = None - # may want to wrap this in an atomic transaction later - request_event = audit_logger.request({ - 'url': path, - 'method': method, - 'query_string': query_string, - 'user_id': getattr(user, 'id', None), - 'remote_ip': remote_ip, - 'datetime': timezone.now() - }) + audit_logger.request( + { + "url": path, + "method": method, + "query_string": query_string, + "user_id": getattr(user, "id", None), + "remote_ip": remote_ip, + "datetime": timezone.now(), + } + ) if WATCH_REQUEST_EVENTS: - request_started.connect(request_started_handler, dispatch_uid='easy_audit_signals_request_started') + request_started.connect( + request_started_handler, dispatch_uid="easy_audit_signals_request_started" + ) diff --git a/easyaudit/templates/admin/easyaudit/change_list.html b/easyaudit/templates/admin/easyaudit/change_list.html index 4b95da4..6229fca 100644 --- a/easyaudit/templates/admin/easyaudit/change_list.html +++ b/easyaudit/templates/admin/easyaudit/change_list.html @@ -1,15 +1,14 @@ {% extends "admin/change_list.html" %} {% load i18n %} - {% block object-tools-items %} {{ block.super }} {% if request.user.is_superuser %} -
  • - {% with purge_url='admin:'|add:cl.opts.app_label|add:'_'|add:cl.opts.model_name|add:'_purge' %} - - {% blocktrans with cl.opts.verbose_name_plural as name %}Purge {{ name }}{% endblocktrans %} - - {% endwith %} -
  • +
  • + {% with purge_url="admin:"|add:cl.opts.app_label|add:"_"|add:cl.opts.model_name|add:"_purge" %} + + {% blocktrans with cl.opts.verbose_name_plural as name %}Purge {{ name }}{% endblocktrans %} + + {% endwith %} +
  • {% endif %} -{% endblock %} +{% endblock object-tools-items %} diff --git a/easyaudit/templates/admin/easyaudit/purge_confirmation.html b/easyaudit/templates/admin/easyaudit/purge_confirmation.html index 74a64e0..88334a6 100644 --- a/easyaudit/templates/admin/easyaudit/purge_confirmation.html +++ b/easyaudit/templates/admin/easyaudit/purge_confirmation.html @@ -1,31 +1,33 @@ {% extends "admin/base_site.html" %} {% load i18n admin_urls static admin_list %} - - {% block breadcrumbs %} - -{% endblock %} - - + +{% endblock breadcrumbs %} {% block content %} - -
    {% csrf_token %} -
    -

    {% trans 'Please confirm deletion' %}.

    -

    {% blocktrans %}This operation is destructive, cannot be undone and may require some minutes.{% endblocktrans %}

    -

    {% blocktrans %}Are you sure you want to permanently remove all objects ?{% endblocktrans %}

    -
    - -
    - - + + {% csrf_token %} +
    +

    {% trans "Please confirm deletion" %}.

    +

    {% blocktrans %}This operation is destructive, cannot be undone and may require some minutes.{% endblocktrans %}

    +

    {% blocktrans %}Are you sure you want to permanently remove all objects ?{% endblocktrans %}

    +
    +
    + {# djlint: off #} + + + {# djlint: on #} +
    -
    - - -{% endblock %} + +{% endblock content %} diff --git a/easyaudit/tests/test_app/admin.py b/easyaudit/tests/test_app/admin.py deleted file mode 100644 index 13be29d..0000000 --- a/easyaudit/tests/test_app/admin.py +++ /dev/null @@ -1,6 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.contrib import admin - -# Register your models here. diff --git a/easyaudit/tests/test_app/backup_apps.py b/easyaudit/tests/test_app/backup_apps.py deleted file mode 100644 index 4e43254..0000000 --- a/easyaudit/tests/test_app/backup_apps.py +++ /dev/null @@ -1,8 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.apps import AppConfig - - -class TestAppConfig(AppConfig): - name = 'test_app' diff --git a/easyaudit/tests/test_app/migrations/0001_initial.py b/easyaudit/tests/test_app/migrations/0001_initial.py deleted file mode 100644 index 6895e0f..0000000 --- a/easyaudit/tests/test_app/migrations/0001_initial.py +++ /dev/null @@ -1,48 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.2 on 2017-07-25 15:56 -from __future__ import unicode_literals - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ] - - operations = [ - migrations.CreateModel( - name='TestForeignKey', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=50)), - ], - ), - migrations.CreateModel( - name='TestM2M', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=50)), - ], - ), - migrations.CreateModel( - name='TestModel', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(default=b'test data', max_length=50)), - ], - ), - migrations.AddField( - model_name='testm2m', - name='test_m2m', - field=models.ManyToManyField(to='test_app.TestModel'), - ), - migrations.AddField( - model_name='testforeignkey', - name='test_fk', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='test_app.TestModel'), - ), - ] diff --git a/easyaudit/tests/test_app/migrations/0002_auto_20180220_1533.py b/easyaudit/tests/test_app/migrations/0002_auto_20180220_1533.py deleted file mode 100644 index 52c6742..0000000 --- a/easyaudit/tests/test_app/migrations/0002_auto_20180220_1533.py +++ /dev/null @@ -1,20 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.8 on 2018-02-20 15:33 -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('test_app', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='testmodel', - name='name', - field=models.CharField(default='test data', max_length=50), - ), - ] diff --git a/easyaudit/tests/test_app/models.py b/easyaudit/tests/test_app/models.py deleted file mode 100644 index 519306c..0000000 --- a/easyaudit/tests/test_app/models.py +++ /dev/null @@ -1,57 +0,0 @@ -import uuid - -from django.db import models - - -class TestModel(models.Model): - name = models.CharField(max_length=50, default='test data') - - -class TestForeignKey(models.Model): - name = models.CharField(max_length=50) - test_fk = models.ForeignKey(TestModel, on_delete=models.CASCADE) - - -class TestM2M(models.Model): - name = models.CharField(max_length=50) - test_m2m = models.ManyToManyField(TestModel) - - -class TestUUIDModel(models.Model): - id = models.UUIDField( - primary_key=True, unique=True, editable=False, default=uuid.uuid4 - ) - name = models.CharField(max_length=50, default='test data') - - -class TestUUIDForeignKey(models.Model): - id = models.UUIDField( - primary_key=True, unique=True, editable=False, default=uuid.uuid4 - ) - name = models.CharField(max_length=50) - test_fk = models.ForeignKey(TestUUIDModel, on_delete=models.CASCADE) - - -class TestUUIDM2M(models.Model): - id = models.UUIDField( - primary_key=True, unique=True, editable=False, default=uuid.uuid4 - ) - name = models.CharField(max_length=50) - test_m2m = models.ManyToManyField(TestUUIDModel) - - -class TestBigIntModel(models.Model): - id = models.BigAutoField(primary_key=True) - name = models.CharField(max_length=50, default='test data') - - -class TestBigIntForeignKey(models.Model): - id = models.BigAutoField(primary_key=True) - name = models.CharField(max_length=50) - test_fk = models.ForeignKey(TestBigIntModel, on_delete=models.CASCADE) - - -class TestBigIntM2M(models.Model): - id = models.BigAutoField(primary_key=True) - name = models.CharField(max_length=50) - test_m2m = models.ManyToManyField(TestBigIntModel) diff --git a/easyaudit/tests/test_app/tests.py b/easyaudit/tests/test_app/tests.py deleted file mode 100644 index 4ef76c3..0000000 --- a/easyaudit/tests/test_app/tests.py +++ /dev/null @@ -1,288 +0,0 @@ -# -*- coding: utf-8 -*- -import json -import re -from unittest import skip, skipIf, mock - -import django - -asgi_views_supported = django.VERSION >= (3, 1) -if asgi_views_supported: - from asgiref.sync import sync_to_async -from django.test import TestCase, override_settings, tag, TransactionTestCase, SimpleTestCase - -from django.urls import reverse, reverse_lazy - -from django.contrib.auth.models import User -from django.contrib.contenttypes.models import ContentType -import bs4 -from test_app.models import ( - TestModel, TestForeignKey, TestM2M, - TestBigIntModel, TestBigIntForeignKey, TestBigIntM2M, - TestUUIDModel, TestUUIDForeignKey, TestUUIDM2M -) -from easyaudit.models import CRUDEvent, RequestEvent -from easyaudit.middleware.easyaudit import set_current_user, clear_request - - -class WithUserInfoMixin: - def setUp(self): - self.username = 'joe@example.com' - self.email = 'joe@example.com' - self.password = 'password' - - -class TestDjangoCompat(SimpleTestCase): - - def test_model_state(self): - """Ensures models have the internal `_state` object.""" - inst = TestModel() - self.assertTrue(hasattr(inst, '_state')) - - -@override_settings(TEST=True) -class TestAuditModels(TestCase): - Model = TestModel - FKModel = TestForeignKey - M2MModel = TestM2M - - def test_create_model(self): - obj = self.Model.objects.create() - crud_event_qs = CRUDEvent.objects.filter(object_id=obj.id, content_type=ContentType.objects.get_for_model(obj)) - self.assertEqual(1, crud_event_qs.count()) - crud_event = crud_event_qs[0] - data = json.loads(crud_event.object_json_repr)[0] - self.assertEqual(data['fields']['name'], obj.name) - - def test_fk_model(self): - obj = self.Model.objects.create() - obj_fk = self.FKModel(name='test', test_fk=obj) - obj_fk.save() - crud_event = CRUDEvent.objects.filter(object_id=obj_fk.id, content_type=ContentType.objects.get_for_model(obj_fk))[0] - data = json.loads(crud_event.object_json_repr)[0] - self.assertEqual(str(data['fields']['test_fk']), str(obj.id)) - - def test_m2m_model(self): - obj = self.Model.objects.create() - obj_m2m = self.M2MModel(name='test') - obj_m2m.save() - obj_m2m.test_m2m.add(obj) - crud_event = CRUDEvent.objects.filter(object_id=obj_m2m.id, content_type=ContentType.objects.get_for_model(obj_m2m))[0] - data = json.loads(crud_event.object_json_repr)[0] - self.assertEqual([str(d) for d in data['fields']['test_m2m']], [str(obj.id)]) - - def test_m2m_clear(self): - obj = self.Model.objects.create() - obj_m2m = self.M2MModel(name='test') - obj_m2m.save() - obj_m2m.test_m2m.add(obj) - obj_m2m.test_m2m.clear() - crud_event = CRUDEvent.objects.filter(object_id=obj_m2m.id, content_type=ContentType.objects.get_for_model(obj_m2m))[0] - data = json.loads(crud_event.object_json_repr)[0] - self.assertEqual([str(d) for d in data['fields']['test_m2m']], []) - - @override_settings(DJANGO_EASY_AUDIT_CRUD_EVENT_NO_CHANGED_FIELDS_SKIP=True) - def test_update_skip_no_changed_fields(self): - obj = self.Model.objects.create() - crud_event_qs = CRUDEvent.objects.filter(object_id=obj.id, content_type=ContentType.objects.get_for_model(obj)) - self.assertEqual(1, crud_event_qs.count()) - obj.name = 'changed name' - obj.save() - self.assertEqual(2, crud_event_qs.count()) - last_change = crud_event_qs.first() - self.assertIn('name', last_change.changed_fields) - - def test_update(self): - obj = self.Model.objects.create() - crud_event_qs = CRUDEvent.objects.filter(object_id=obj.id, content_type=ContentType.objects.get_for_model(obj)) - self.assertEqual(1, crud_event_qs.count()) - obj.name = 'changed name' - obj.save() - self.assertEqual(2, crud_event_qs.count()) - last_change = crud_event_qs.first() - self.assertIn('name', last_change.changed_fields) - - @override_settings(DJANGO_EASY_AUDIT_CRUD_EVENT_NO_CHANGED_FIELDS_SKIP=True) - def test_fake_update_skip_no_changed_fields(self): - obj = self.Model.objects.create() - crud_event_qs = CRUDEvent.objects.filter(object_id=obj.id, content_type=ContentType.objects.get_for_model(obj)) - obj.save() - self.assertEqual(1, crud_event_qs.count()) - - def test_fake_update(self): - obj = self.Model.objects.create() - crud_event_qs = CRUDEvent.objects.filter(object_id=obj.id, content_type=ContentType.objects.get_for_model(obj)) - obj.save() - self.assertEqual(2, crud_event_qs.count()) - - def test_delete(self): - obj = self.Model.objects.create() - crud_event_qs = CRUDEvent.objects.filter(object_id=obj.id, content_type=ContentType.objects.get_for_model(obj)) - self.assertEqual(1, crud_event_qs.count()) - - obj_id = obj.pk - obj.delete() - crud_event_qs = CRUDEvent.objects.filter(object_id=obj_id, content_type=ContentType.objects.get_for_model(obj)) - self.assertEqual(2, crud_event_qs.count()) - - @mock.patch('easyaudit.signals.model_signals.audit_logger') - def test_propagate_exceptions(self, mocked_audit_logger): - mocked_audit_logger.crud.side_effect = ValueError - - # By default, it should catch exceptions - _ = self.Model.objects.create() - - with override_settings(DJANGO_EASY_AUDIT_PROPAGATE_EXCEPTIONS=True): - with self.assertRaises(ValueError): - _ = self.Model.objects.create() - - -class TestAuditUUIDModels(TestAuditModels): - Model = TestUUIDModel - FKModel = TestUUIDForeignKey - M2MModel = TestUUIDM2M - - -class TestAuditBigIntModels(TestAuditModels): - Model = TestBigIntModel - FKModel = TestBigIntForeignKey - M2MModel = TestBigIntM2M - - -@override_settings(TEST=True) -class TestMiddleware(WithUserInfoMixin, TestCase): - - def test_middleware_logged_in(self): - user = User.objects.create_user(self.username, self.email, self.password) - self.client.login(username=self.username, password=self.password) - create_obj_url = reverse("test_app:create-obj") - self.client.post(create_obj_url) - self.assertEqual(TestModel.objects.count(), 1) - obj = TestModel.objects.all()[0] - crud_event = CRUDEvent.objects.filter(object_id=obj.id, content_type=ContentType.objects.get_for_model(obj))[0] - self.assertEqual(crud_event.user, user) - - def test_middleware_not_logged_in(self): - create_obj_url = reverse("test_app:create-obj") - self.client.post(create_obj_url) - self.assertEqual(TestModel.objects.count(), 1) - obj = TestModel.objects.all()[0] - crud_event = CRUDEvent.objects.filter(object_id=obj.id, content_type=ContentType.objects.get_for_model(obj))[0] - self.assertEqual(crud_event.user, None) - - def test_manual_set_user(self): - user = User.objects.create_user(self.username, self.email, self.password) - - # set user/request - set_current_user(user) - obj = TestModel.objects.create() - self.assertEqual(obj.id, 1) - crud_event_qs = CRUDEvent.objects.filter(object_id=obj.id, content_type=ContentType.objects.get_for_model(obj)) - self.assertEqual(crud_event_qs.count(), 1) - crud_event = crud_event_qs[0] - self.assertEqual(crud_event.user, user) - - # clear request - clear_request() - obj = TestModel.objects.create() - self.assertEqual(obj.id, 2) - crud_event_qs = CRUDEvent.objects.filter(object_id=obj.id, content_type=ContentType.objects.get_for_model(obj)) - self.assertEqual(crud_event_qs.count(), 1) - crud_event = crud_event_qs[0] - self.assertEqual(crud_event.user, None) - - @skip("Test may need a rewrite but the library logic has been rolled back.") - def test_middleware_logged_in_user_in_request(self): - user = User.objects.create_user(self.username, self.email, self.password) - self.client.force_login(user) - create_obj_url = reverse("test_app:create-obj") - self.client.post(create_obj_url) - self.assertEqual(TestModel.objects.count(), 1) - obj = TestModel.objects.all()[0] - crud_event = CRUDEvent.objects.filter(object_id=obj.id, content_type=ContentType.objects.get_for_model(obj))[0] - self.assertEqual(crud_event.user, user) - - -@tag("asgi") -@override_settings(TEST=True) -@skipIf(not asgi_views_supported, "Testing ASGI is easier with Django 3.1") -class TestASGIRequestEvent(WithUserInfoMixin, TransactionTestCase): - - async def test_login(self): - user = await sync_to_async(User.objects.create_user)(self.username, self.email, self.password) - await sync_to_async(self.async_client.login)(username=self.username, password=self.password) - self.assertEqual((await sync_to_async(RequestEvent.objects.count)()), 0) - resp = await self.async_client.get(reverse_lazy("test_app:index")) - self.assertEqual(resp.status_code, 200) - assert (await sync_to_async(RequestEvent.objects.get)(user=user)) - - async def test_remote_addr_default(self): - self.assertEqual((await sync_to_async(RequestEvent.objects.count)()), 0) - resp = await self.async_client.request( - method='GET', path=str(reverse_lazy("test_app:index")), - server=('127.0.0.1', '80'), - scheme='http', - headers=[(b'host', b'testserver')], - query_string='', - ) - self.assertEqual(resp.status_code, 200) - r = await sync_to_async(RequestEvent.objects.get)(url=reverse_lazy("test_app:index")) - i = await sync_to_async(getattr)(r, 'remote_ip') - self.assertEqual(i, '127.0.0.1') - - async def test_remote_addr_another(self): - self.assertEqual((await sync_to_async(RequestEvent.objects.count)()), 0) - resp = await self.async_client.request( - method='GET', path=str(reverse_lazy("test_app:index")), - server=('127.0.0.1', '80'), - client=('10.0.0.1', 111), - scheme='http', - headers=[(b'host', b'testserver')], - query_string='', - ) - self.assertEqual(resp.status_code, 200) - r = await sync_to_async(RequestEvent.objects.get)(url=reverse_lazy("test_app:index")) - i = await sync_to_async(getattr)(r, 'remote_ip') - self.assertEqual(i, '10.0.0.1') - - -@override_settings(TEST=True) -class TestWSGIRequestEvent(WithUserInfoMixin, TestCase): - - def test_login(self): - user = User.objects.create_user(self.username, self.email, self.password) - self.client.login(username=self.username, password=self.password) - self.assertEqual(RequestEvent.objects.count(), 0) - resp = self.client.get(reverse_lazy("test_app:index")) - self.assertEqual(resp.status_code, 200) - assert RequestEvent.objects.get(user=user) - - -@override_settings(TEST=True) -class TestAuditAdmin(WithUserInfoMixin, TestCase): - - def _list_filters(self, content): - """ - Extract filters from response content; - example: - -
    -

    Filter

    -

    By method

    - ... -

    By datetime

    - ... -
    - - returns: - ['method', 'datetime', ] - """ - html = str(bs4.BeautifulSoup(content, features="html.parser").find(id="changelist-filter")) - filters = re.findall('

    \s*By\s*(.*?)\s*

    ', html) - return filters - - def test_request_event_admin_no_users(self): - User.objects.create_superuser(self.username, self.email, self.password) - self.client.login(username=self.username, password=self.password) - response = self.client.get(reverse('admin:easyaudit_requestevent_changelist')) - self.assertEqual(200, response.status_code) - filters = self._list_filters(response.content) diff --git a/easyaudit/tests/test_app/views.py b/easyaudit/tests/test_app/views.py deleted file mode 100644 index 0195b67..0000000 --- a/easyaudit/tests/test_app/views.py +++ /dev/null @@ -1,53 +0,0 @@ -from datetime import datetime - -from django.http import HttpResponse -from test_app.models import TestModel, TestUUIDModel, TestBigIntModel - - -def create_obj(Model): - return Model.objects.create() - - -def update_obj(Model, pk, name): - tm = Model.objects.get(pk=pk) - tm.name = name - tm.save() - return tm - - -def create_obj_view(request): - obj = create_obj(TestModel) - return HttpResponse(obj.id) - - -def index(request): - return HttpResponse() - - -def update_obj_view(request): - name = datetime.now().isoformat() - return HttpResponse(update_obj( - TestModel, request.GET['id'], name - ).id) - - -def create_uuid_obj_view(request): - return HttpResponse(create_obj(TestUUIDModel).id) - - -def update_uuid_obj_view(request): - name = datetime.now().isoformat() - return HttpResponse(update_obj( - TestUUIDModel, request.GET['id'], name - ).id) - - -def create_big_obj_view(request): - return HttpResponse(create_obj(TestBigIntModel).id) - - -def update_big_obj_view(request): - name = datetime.now().isoformat() - return HttpResponse(update_obj( - TestBigIntModel, request.GET['id'], name - ).id) diff --git a/easyaudit/tests/test_project/__init__.py b/easyaudit/tests/test_project/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/easyaudit/tests/test_project/settings.py b/easyaudit/tests/test_project/settings.py deleted file mode 100644 index f4c2549..0000000 --- a/easyaudit/tests/test_project/settings.py +++ /dev/null @@ -1,126 +0,0 @@ -""" -Django settings for test_project project. - -Generated by 'django-admin startproject' using Django 1.11.2. - -For more information on this file, see -https://docs.djangoproject.com/en/1.11/topics/settings/ - -For the full list of settings and their values, see -https://docs.djangoproject.com/en/1.11/ref/settings/ -""" - -import os - -# Build paths inside the project like this: os.path.join(BASE_DIR, ...) -BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - - -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/ - -# SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'ox!t9_6#yvbpd3y9m$hk0))4k#&@c2^k8sc6mkuslpye8ija0p' - -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True - -ALLOWED_HOSTS = [] - - -# Application definition - -INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'easyaudit', - 'test_app', -] - -MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', - 'easyaudit.middleware.easyaudit.EasyAuditMiddleware', -] - -ROOT_URLCONF = 'test_project.urls' - -TEMPLATES = [ - { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', - ], - }, - }, -] - -WSGI_APPLICATION = 'test_project.wsgi.application' -ASGI_APPLICATION = 'test_project.asgi.application' - - -# Database -# https://docs.djangoproject.com/en/1.11/ref/settings/#databases - -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), - } -} - - -# Password validation -# https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators - -AUTH_PASSWORD_VALIDATORS = [ - { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', - }, -] - - -# Internationalization -# https://docs.djangoproject.com/en/1.11/topics/i18n/ - -LANGUAGE_CODE = 'en-us' - -TIME_ZONE = 'UTC' - -USE_I18N = True - -USE_L10N = True - -USE_TZ = True - - -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/1.11/howto/static-files/ - -STATIC_URL = '/static/' - -DJANGO_EASY_AUDIT_REQUEST_EVENT_LIST_FILTER = ['method', 'datetime', ] diff --git a/easyaudit/utils.py b/easyaudit/utils.py index b66b11d..806567f 100644 --- a/easyaudit/utils.py +++ b/easyaudit/utils.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import datetime as dt from django.conf import settings @@ -10,8 +8,8 @@ def get_field_value(obj, field): - """ - Gets the value of a given model instance field. + """Get the value of a given model instance field. + :param obj: The model instance. :type obj: Model :param field: The field you want to find the value of. @@ -38,8 +36,8 @@ def get_field_value(obj, field): def model_delta(old_model, new_model): - """ - Provides delta/difference between two models + """Provide delta/difference between two models. + :param old: The old state of the model instance. :type old: Model :param new: The new state of the model instance. @@ -49,15 +47,13 @@ def model_delta(old_model, new_model): as value. :rtype: dict """ - delta = {} fields = new_model._meta.fields for field in fields: old_value = get_field_value(old_model, field) new_value = get_field_value(new_model, field) if old_value != new_value: - delta[field.name] = [smart_str(old_value), - smart_str(new_value)] + delta[field.name] = [smart_str(old_value), smart_str(new_value)] if len(delta) == 0: delta = None @@ -66,8 +62,8 @@ def model_delta(old_model, new_model): def get_m2m_field_name(model, instance): - """ - Finds M2M field name on instance + """Find M2M field name on instance. + Called from m2m_changed signal :param model: m2m_changed signal model. :type model: Model @@ -79,11 +75,12 @@ def get_m2m_field_name(model, instance): for x in model._meta.related_objects: if x.related_model().__class__ == instance.__class__: return x.remote_field.name + return None def should_propagate_exceptions(): - """ - Should Django Easy Audit propagate signal handler exceptions. + """Whether Django Easy Audit should propagate signal handler exceptions. + :rtype: bool """ - return getattr(settings, 'DJANGO_EASY_AUDIT_PROPAGATE_EXCEPTIONS', False) + return getattr(settings, "DJANGO_EASY_AUDIT_PROPAGATE_EXCEPTIONS", False) diff --git a/easyaudit/views.py b/easyaudit/views.py deleted file mode 100644 index 91ea44a..0000000 --- a/easyaudit/views.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here. diff --git a/easyaudit/tests/manage.py b/manage.py similarity index 69% rename from easyaudit/tests/manage.py rename to manage.py index 0fc36a3..dc935d6 100755 --- a/easyaudit/tests/manage.py +++ b/manage.py @@ -3,7 +3,7 @@ import sys if __name__ == "__main__": - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings") + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") from django.core.management import execute_from_command_line diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..f19928b --- /dev/null +++ b/poetry.lock @@ -0,0 +1,823 @@ +# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. + +[[package]] +name = "asgiref" +version = "3.7.2" +description = "ASGI specs, helper code, and adapters" +optional = false +python-versions = ">=3.7" +files = [ + {file = "asgiref-3.7.2-py3-none-any.whl", hash = "sha256:89b2ef2247e3b562a16eef663bc0e2e703ec6468e2fa8a5cd61cd449786d4f6e"}, + {file = "asgiref-3.7.2.tar.gz", hash = "sha256:9e0ce3aa93a819ba5b45120216b23878cf6e8525eb3848653452b4192b92afed"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4", markers = "python_version < \"3.11\""} + +[package.extras] +tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] + +[[package]] +name = "backports-zoneinfo" +version = "0.2.1" +description = "Backport of the standard library zoneinfo module" +optional = false +python-versions = ">=3.6" +files = [ + {file = "backports.zoneinfo-0.2.1-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:da6013fd84a690242c310d77ddb8441a559e9cb3d3d59ebac9aca1a57b2e18bc"}, + {file = "backports.zoneinfo-0.2.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:89a48c0d158a3cc3f654da4c2de1ceba85263fafb861b98b59040a5086259722"}, + {file = "backports.zoneinfo-0.2.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:1c5742112073a563c81f786e77514969acb58649bcdf6cdf0b4ed31a348d4546"}, + {file = "backports.zoneinfo-0.2.1-cp36-cp36m-win32.whl", hash = "sha256:e8236383a20872c0cdf5a62b554b27538db7fa1bbec52429d8d106effbaeca08"}, + {file = "backports.zoneinfo-0.2.1-cp36-cp36m-win_amd64.whl", hash = "sha256:8439c030a11780786a2002261569bdf362264f605dfa4d65090b64b05c9f79a7"}, + {file = "backports.zoneinfo-0.2.1-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:f04e857b59d9d1ccc39ce2da1021d196e47234873820cbeaad210724b1ee28ac"}, + {file = "backports.zoneinfo-0.2.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:17746bd546106fa389c51dbea67c8b7c8f0d14b5526a579ca6ccf5ed72c526cf"}, + {file = "backports.zoneinfo-0.2.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5c144945a7752ca544b4b78c8c41544cdfaf9786f25fe5ffb10e838e19a27570"}, + {file = "backports.zoneinfo-0.2.1-cp37-cp37m-win32.whl", hash = "sha256:e55b384612d93be96506932a786bbcde5a2db7a9e6a4bb4bffe8b733f5b9036b"}, + {file = "backports.zoneinfo-0.2.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a76b38c52400b762e48131494ba26be363491ac4f9a04c1b7e92483d169f6582"}, + {file = "backports.zoneinfo-0.2.1-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:8961c0f32cd0336fb8e8ead11a1f8cd99ec07145ec2931122faaac1c8f7fd987"}, + {file = "backports.zoneinfo-0.2.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e81b76cace8eda1fca50e345242ba977f9be6ae3945af8d46326d776b4cf78d1"}, + {file = "backports.zoneinfo-0.2.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7b0a64cda4145548fed9efc10322770f929b944ce5cee6c0dfe0c87bf4c0c8c9"}, + {file = "backports.zoneinfo-0.2.1-cp38-cp38-win32.whl", hash = "sha256:1b13e654a55cd45672cb54ed12148cd33628f672548f373963b0bff67b217328"}, + {file = "backports.zoneinfo-0.2.1-cp38-cp38-win_amd64.whl", hash = "sha256:4a0f800587060bf8880f954dbef70de6c11bbe59c673c3d818921f042f9954a6"}, + {file = "backports.zoneinfo-0.2.1.tar.gz", hash = "sha256:fadbfe37f74051d024037f223b8e001611eac868b5c5b06144ef4d8b799862f2"}, +] + +[package.extras] +tzdata = ["tzdata"] + +[[package]] +name = "cfgv" +version = "3.4.0" +description = "Validate configuration and produce human readable error messages." +optional = false +python-versions = ">=3.8" +files = [ + {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, + {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, +] + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "coverage" +version = "7.4.4" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "coverage-7.4.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0be5efd5127542ef31f165de269f77560d6cdef525fffa446de6f7e9186cfb2"}, + {file = "coverage-7.4.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ccd341521be3d1b3daeb41960ae94a5e87abe2f46f17224ba5d6f2b8398016cf"}, + {file = "coverage-7.4.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09fa497a8ab37784fbb20ab699c246053ac294d13fc7eb40ec007a5043ec91f8"}, + {file = "coverage-7.4.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b1a93009cb80730c9bca5d6d4665494b725b6e8e157c1cb7f2db5b4b122ea562"}, + {file = "coverage-7.4.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:690db6517f09336559dc0b5f55342df62370a48f5469fabf502db2c6d1cffcd2"}, + {file = "coverage-7.4.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:09c3255458533cb76ef55da8cc49ffab9e33f083739c8bd4f58e79fecfe288f7"}, + {file = "coverage-7.4.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:8ce1415194b4a6bd0cdcc3a1dfbf58b63f910dcb7330fe15bdff542c56949f87"}, + {file = "coverage-7.4.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b91cbc4b195444e7e258ba27ac33769c41b94967919f10037e6355e998af255c"}, + {file = "coverage-7.4.4-cp310-cp310-win32.whl", hash = "sha256:598825b51b81c808cb6f078dcb972f96af96b078faa47af7dfcdf282835baa8d"}, + {file = "coverage-7.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:09ef9199ed6653989ebbcaacc9b62b514bb63ea2f90256e71fea3ed74bd8ff6f"}, + {file = "coverage-7.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0f9f50e7ef2a71e2fae92774c99170eb8304e3fdf9c8c3c7ae9bab3e7229c5cf"}, + {file = "coverage-7.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:623512f8ba53c422fcfb2ce68362c97945095b864cda94a92edbaf5994201083"}, + {file = "coverage-7.4.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0513b9508b93da4e1716744ef6ebc507aff016ba115ffe8ecff744d1322a7b63"}, + {file = "coverage-7.4.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40209e141059b9370a2657c9b15607815359ab3ef9918f0196b6fccce8d3230f"}, + {file = "coverage-7.4.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a2b2b78c78293782fd3767d53e6474582f62443d0504b1554370bde86cc8227"}, + {file = "coverage-7.4.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:73bfb9c09951125d06ee473bed216e2c3742f530fc5acc1383883125de76d9cd"}, + {file = "coverage-7.4.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:1f384c3cc76aeedce208643697fb3e8437604b512255de6d18dae3f27655a384"}, + {file = "coverage-7.4.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:54eb8d1bf7cacfbf2a3186019bcf01d11c666bd495ed18717162f7eb1e9dd00b"}, + {file = "coverage-7.4.4-cp311-cp311-win32.whl", hash = "sha256:cac99918c7bba15302a2d81f0312c08054a3359eaa1929c7e4b26ebe41e9b286"}, + {file = "coverage-7.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:b14706df8b2de49869ae03a5ccbc211f4041750cd4a66f698df89d44f4bd30ec"}, + {file = "coverage-7.4.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:201bef2eea65e0e9c56343115ba3814e896afe6d36ffd37bab783261db430f76"}, + {file = "coverage-7.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41c9c5f3de16b903b610d09650e5e27adbfa7f500302718c9ffd1c12cf9d6818"}, + {file = "coverage-7.4.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d898fe162d26929b5960e4e138651f7427048e72c853607f2b200909794ed978"}, + {file = "coverage-7.4.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ea79bb50e805cd6ac058dfa3b5c8f6c040cb87fe83de10845857f5535d1db70"}, + {file = "coverage-7.4.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce4b94265ca988c3f8e479e741693d143026632672e3ff924f25fab50518dd51"}, + {file = "coverage-7.4.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:00838a35b882694afda09f85e469c96367daa3f3f2b097d846a7216993d37f4c"}, + {file = "coverage-7.4.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:fdfafb32984684eb03c2d83e1e51f64f0906b11e64482df3c5db936ce3839d48"}, + {file = "coverage-7.4.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:69eb372f7e2ece89f14751fbcbe470295d73ed41ecd37ca36ed2eb47512a6ab9"}, + {file = "coverage-7.4.4-cp312-cp312-win32.whl", hash = "sha256:137eb07173141545e07403cca94ab625cc1cc6bc4c1e97b6e3846270e7e1fea0"}, + {file = "coverage-7.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:d71eec7d83298f1af3326ce0ff1d0ea83c7cb98f72b577097f9083b20bdaf05e"}, + {file = "coverage-7.4.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d5ae728ff3b5401cc320d792866987e7e7e880e6ebd24433b70a33b643bb0384"}, + {file = "coverage-7.4.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cc4f1358cb0c78edef3ed237ef2c86056206bb8d9140e73b6b89fbcfcbdd40e1"}, + {file = "coverage-7.4.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8130a2aa2acb8788e0b56938786c33c7c98562697bf9f4c7d6e8e5e3a0501e4a"}, + {file = "coverage-7.4.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf271892d13e43bc2b51e6908ec9a6a5094a4df1d8af0bfc360088ee6c684409"}, + {file = "coverage-7.4.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4cdc86d54b5da0df6d3d3a2f0b710949286094c3a6700c21e9015932b81447e"}, + {file = "coverage-7.4.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ae71e7ddb7a413dd60052e90528f2f65270aad4b509563af6d03d53e979feafd"}, + {file = "coverage-7.4.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:38dd60d7bf242c4ed5b38e094baf6401faa114fc09e9e6632374388a404f98e7"}, + {file = "coverage-7.4.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:aa5b1c1bfc28384f1f53b69a023d789f72b2e0ab1b3787aae16992a7ca21056c"}, + {file = "coverage-7.4.4-cp38-cp38-win32.whl", hash = "sha256:dfa8fe35a0bb90382837b238fff375de15f0dcdb9ae68ff85f7a63649c98527e"}, + {file = "coverage-7.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:b2991665420a803495e0b90a79233c1433d6ed77ef282e8e152a324bbbc5e0c8"}, + {file = "coverage-7.4.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3b799445b9f7ee8bf299cfaed6f5b226c0037b74886a4e11515e569b36fe310d"}, + {file = "coverage-7.4.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b4d33f418f46362995f1e9d4f3a35a1b6322cb959c31d88ae56b0298e1c22357"}, + {file = "coverage-7.4.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aadacf9a2f407a4688d700e4ebab33a7e2e408f2ca04dbf4aef17585389eff3e"}, + {file = "coverage-7.4.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7c95949560050d04d46b919301826525597f07b33beba6187d04fa64d47ac82e"}, + {file = "coverage-7.4.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff7687ca3d7028d8a5f0ebae95a6e4827c5616b31a4ee1192bdfde697db110d4"}, + {file = "coverage-7.4.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5fc1de20b2d4a061b3df27ab9b7c7111e9a710f10dc2b84d33a4ab25065994ec"}, + {file = "coverage-7.4.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c74880fc64d4958159fbd537a091d2a585448a8f8508bf248d72112723974cbd"}, + {file = "coverage-7.4.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:742a76a12aa45b44d236815d282b03cfb1de3b4323f3e4ec933acfae08e54ade"}, + {file = "coverage-7.4.4-cp39-cp39-win32.whl", hash = "sha256:d89d7b2974cae412400e88f35d86af72208e1ede1a541954af5d944a8ba46c57"}, + {file = "coverage-7.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:9ca28a302acb19b6af89e90f33ee3e1906961f94b54ea37de6737b7ca9d8827c"}, + {file = "coverage-7.4.4-pp38.pp39.pp310-none-any.whl", hash = "sha256:b2c5edc4ac10a7ef6605a966c58929ec6c1bd0917fb8c15cb3363f65aa40e677"}, + {file = "coverage-7.4.4.tar.gz", hash = "sha256:c901df83d097649e257e803be22592aedfd5182f07b3cc87d640bbb9afd50f49"}, +] + +[package.dependencies] +tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} + +[package.extras] +toml = ["tomli"] + +[[package]] +name = "cssbeautifier" +version = "1.15.1" +description = "CSS unobfuscator and beautifier." +optional = false +python-versions = "*" +files = [ + {file = "cssbeautifier-1.15.1.tar.gz", hash = "sha256:9f7064362aedd559c55eeecf6b6bed65e05f33488dcbe39044f0403c26e1c006"}, +] + +[package.dependencies] +editorconfig = ">=0.12.2" +jsbeautifier = "*" +six = ">=1.13.0" + +[[package]] +name = "distlib" +version = "0.3.8" +description = "Distribution utilities" +optional = false +python-versions = "*" +files = [ + {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"}, + {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, +] + +[[package]] +name = "django" +version = "4.2.11" +description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." +optional = false +python-versions = ">=3.8" +files = [ + {file = "Django-4.2.11-py3-none-any.whl", hash = "sha256:ddc24a0a8280a0430baa37aff11f28574720af05888c62b7cfe71d219f4599d3"}, + {file = "Django-4.2.11.tar.gz", hash = "sha256:6e6ff3db2d8dd0c986b4eec8554c8e4f919b5c1ff62a5b4390c17aff2ed6e5c4"}, +] + +[package.dependencies] +asgiref = ">=3.6.0,<4" +"backports.zoneinfo" = {version = "*", markers = "python_version < \"3.9\""} +sqlparse = ">=0.3.1" +tzdata = {version = "*", markers = "sys_platform == \"win32\""} + +[package.extras] +argon2 = ["argon2-cffi (>=19.1.0)"] +bcrypt = ["bcrypt"] + +[[package]] +name = "djlint" +version = "1.34.1" +description = "HTML Template Linter and Formatter" +optional = false +python-versions = ">=3.8.0,<4.0.0" +files = [ + {file = "djlint-1.34.1-py3-none-any.whl", hash = "sha256:96ff1c464fb6f061130ebc88663a2ea524d7ec51f4b56221a2b3f0320a3cfce8"}, + {file = "djlint-1.34.1.tar.gz", hash = "sha256:db93fa008d19eaadb0454edf1704931d14469d48508daba2df9941111f408346"}, +] + +[package.dependencies] +click = ">=8.0.1,<9.0.0" +colorama = ">=0.4.4,<0.5.0" +cssbeautifier = ">=1.14.4,<2.0.0" +html-tag-names = ">=0.1.2,<0.2.0" +html-void-elements = ">=0.1.0,<0.2.0" +jsbeautifier = ">=1.14.4,<2.0.0" +json5 = ">=0.9.11,<0.10.0" +pathspec = ">=0.12.0,<0.13.0" +PyYAML = ">=6.0,<7.0" +regex = ">=2023.0.0,<2024.0.0" +tomli = {version = ">=2.0.1,<3.0.0", markers = "python_version < \"3.11\""} +tqdm = ">=4.62.2,<5.0.0" + +[[package]] +name = "editorconfig" +version = "0.12.4" +description = "EditorConfig File Locator and Interpreter for Python" +optional = false +python-versions = "*" +files = [ + {file = "EditorConfig-0.12.4.tar.gz", hash = "sha256:24857fa1793917dd9ccf0c7810a07e05404ce9b823521c7dce22a4fb5d125f80"}, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.0" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"}, + {file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"}, +] + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "filelock" +version = "3.13.1" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.8" +files = [ + {file = "filelock-3.13.1-py3-none-any.whl", hash = "sha256:57dbda9b35157b05fb3e58ee91448612eb674172fab98ee235ccb0b5bee19a1c"}, + {file = "filelock-3.13.1.tar.gz", hash = "sha256:521f5f56c50f8426f5e03ad3b281b490a87ef15bc6c526f168290f0c7148d44e"}, +] + +[package.extras] +docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.24)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"] +typing = ["typing-extensions (>=4.8)"] + +[[package]] +name = "html-tag-names" +version = "0.1.2" +description = "List of known HTML tag names" +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "html-tag-names-0.1.2.tar.gz", hash = "sha256:04924aca48770f36b5a41c27e4d917062507be05118acb0ba869c97389084297"}, + {file = "html_tag_names-0.1.2-py3-none-any.whl", hash = "sha256:eeb69ef21078486b615241f0393a72b41352c5219ee648e7c61f5632d26f0420"}, +] + +[[package]] +name = "html-void-elements" +version = "0.1.0" +description = "List of HTML void tag names." +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "html-void-elements-0.1.0.tar.gz", hash = "sha256:931b88f84cd606fee0b582c28fcd00e41d7149421fb673e1e1abd2f0c4f231f0"}, + {file = "html_void_elements-0.1.0-py3-none-any.whl", hash = "sha256:784cf39db03cdeb017320d9301009f8f3480f9d7b254d0974272e80e0cb5e0d2"}, +] + +[[package]] +name = "identify" +version = "2.5.35" +description = "File identification library for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "identify-2.5.35-py2.py3-none-any.whl", hash = "sha256:c4de0081837b211594f8e877a6b4fad7ca32bbfc1a9307fdd61c28bfe923f13e"}, + {file = "identify-2.5.35.tar.gz", hash = "sha256:10a7ca245cfcd756a554a7288159f72ff105ad233c7c4b9c6f0f4d108f5f6791"}, +] + +[package.extras] +license = ["ukkonen"] + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "jsbeautifier" +version = "1.15.1" +description = "JavaScript unobfuscator and beautifier." +optional = false +python-versions = "*" +files = [ + {file = "jsbeautifier-1.15.1.tar.gz", hash = "sha256:ebd733b560704c602d744eafc839db60a1ee9326e30a2a80c4adb8718adc1b24"}, +] + +[package.dependencies] +editorconfig = ">=0.12.2" +six = ">=1.13.0" + +[[package]] +name = "json5" +version = "0.9.24" +description = "A Python implementation of the JSON5 data format." +optional = false +python-versions = ">=3.8" +files = [ + {file = "json5-0.9.24-py3-none-any.whl", hash = "sha256:4ca101fd5c7cb47960c055ef8f4d0e31e15a7c6c48c3b6f1473fc83b6c462a13"}, + {file = "json5-0.9.24.tar.gz", hash = "sha256:0c638399421da959a20952782800e5c1a78c14e08e1dc9738fa10d8ec14d58c8"}, +] + +[[package]] +name = "nodeenv" +version = "1.8.0" +description = "Node.js virtual environment builder" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" +files = [ + {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"}, + {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"}, +] + +[package.dependencies] +setuptools = "*" + +[[package]] +name = "packaging" +version = "24.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.7" +files = [ + {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"}, + {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, +] + +[[package]] +name = "platformdirs" +version = "4.2.0" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +optional = false +python-versions = ">=3.8" +files = [ + {file = "platformdirs-4.2.0-py3-none-any.whl", hash = "sha256:0614df2a2f37e1a662acbd8e2b25b92ccf8632929bc6d43467e17fe89c75e068"}, + {file = "platformdirs-4.2.0.tar.gz", hash = "sha256:ef0cc731df711022c174543cb70a9b5bd22e5a9337c8624ef2c2ceb8ddad8768"}, +] + +[package.extras] +docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] + +[[package]] +name = "pluggy" +version = "1.4.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pluggy-1.4.0-py3-none-any.whl", hash = "sha256:7db9f7b503d67d1c5b95f59773ebb58a8c1c288129a88665838012cfb07b8981"}, + {file = "pluggy-1.4.0.tar.gz", hash = "sha256:8c85c2876142a764e5b7548e7d9a0e0ddb46f5185161049a79b7e974454223be"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pre-commit" +version = "3.5.0" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pre_commit-3.5.0-py2.py3-none-any.whl", hash = "sha256:841dc9aef25daba9a0238cd27984041fa0467b4199fc4852e27950664919f660"}, + {file = "pre_commit-3.5.0.tar.gz", hash = "sha256:5804465c675b659b0862f07907f96295d490822a450c4c40e747d0b1c6ebcb32"}, +] + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +virtualenv = ">=20.10.0" + +[[package]] +name = "pre-commit" +version = "3.6.2" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +optional = false +python-versions = ">=3.9" +files = [ + {file = "pre_commit-3.6.2-py2.py3-none-any.whl", hash = "sha256:ba637c2d7a670c10daedc059f5c49b5bd0aadbccfcd7ec15592cf9665117532c"}, + {file = "pre_commit-3.6.2.tar.gz", hash = "sha256:c3ef34f463045c88658c5b99f38c1e297abdcc0ff13f98d3370055fbbfabc67e"}, +] + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +virtualenv = ">=20.10.0" + +[[package]] +name = "pytest" +version = "7.4.4" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, + {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} + +[package.extras] +testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-asyncio" +version = "0.23.5.post1" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-asyncio-0.23.5.post1.tar.gz", hash = "sha256:b9a8806bea78c21276bc34321bbf234ba1b2ea5b30d9f0ce0f2dea45e4685813"}, + {file = "pytest_asyncio-0.23.5.post1-py3-none-any.whl", hash = "sha256:30f54d27774e79ac409778889880242b0403d09cabd65b727ce90fe92dd5d80e"}, +] + +[package.dependencies] +pytest = ">=7.0.0,<9" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + +[[package]] +name = "pytest-cov" +version = "4.1.0" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, + {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, +] + +[package.dependencies] +coverage = {version = ">=5.2.1", extras = ["toml"]} +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] + +[[package]] +name = "pytest-django" +version = "4.8.0" +description = "A Django plugin for pytest." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-django-4.8.0.tar.gz", hash = "sha256:5d054fe011c56f3b10f978f41a8efb2e5adfc7e680ef36fb571ada1f24779d90"}, + {file = "pytest_django-4.8.0-py3-none-any.whl", hash = "sha256:ca1ddd1e0e4c227cf9e3e40a6afc6d106b3e70868fd2ac5798a22501271cd0c7"}, +] + +[package.dependencies] +pytest = ">=7.0.0" + +[package.extras] +docs = ["sphinx", "sphinx-rtd-theme"] +testing = ["Django", "django-configurations (>=2.0)"] + +[[package]] +name = "pytest-ruff" +version = "0.2.1" +description = "pytest plugin to check ruff requirements." +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "pytest_ruff-0.2.1-py3-none-any.whl", hash = "sha256:f586bbd7978cb5782b673c8e55fa069d83430139931b918bd72232ba3f71eb67"}, + {file = "pytest_ruff-0.2.1.tar.gz", hash = "sha256:078ad696bfa347b466991ed4f9cc5ec807f5a171d7f06091660d8f16ba03a5dc"}, +] + +[package.dependencies] +ruff = ">=0.0.242" + +[[package]] +name = "pyyaml" +version = "6.0.1" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.6" +files = [ + {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, + {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, + {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, + {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, + {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, + {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, + {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, + {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, + {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, +] + +[[package]] +name = "regex" +version = "2023.12.25" +description = "Alternative regular expression module, to replace re." +optional = false +python-versions = ">=3.7" +files = [ + {file = "regex-2023.12.25-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0694219a1d54336fd0445ea382d49d36882415c0134ee1e8332afd1529f0baa5"}, + {file = "regex-2023.12.25-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b014333bd0217ad3d54c143de9d4b9a3ca1c5a29a6d0d554952ea071cff0f1f8"}, + {file = "regex-2023.12.25-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d865984b3f71f6d0af64d0d88f5733521698f6c16f445bb09ce746c92c97c586"}, + {file = "regex-2023.12.25-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e0eabac536b4cc7f57a5f3d095bfa557860ab912f25965e08fe1545e2ed8b4c"}, + {file = "regex-2023.12.25-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c25a8ad70e716f96e13a637802813f65d8a6760ef48672aa3502f4c24ea8b400"}, + {file = "regex-2023.12.25-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a9b6d73353f777630626f403b0652055ebfe8ff142a44ec2cf18ae470395766e"}, + {file = "regex-2023.12.25-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9cc99d6946d750eb75827cb53c4371b8b0fe89c733a94b1573c9dd16ea6c9e4"}, + {file = "regex-2023.12.25-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88d1f7bef20c721359d8675f7d9f8e414ec5003d8f642fdfd8087777ff7f94b5"}, + {file = "regex-2023.12.25-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cb3fe77aec8f1995611f966d0c656fdce398317f850d0e6e7aebdfe61f40e1cd"}, + {file = "regex-2023.12.25-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:7aa47c2e9ea33a4a2a05f40fcd3ea36d73853a2aae7b4feab6fc85f8bf2c9704"}, + {file = "regex-2023.12.25-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:df26481f0c7a3f8739fecb3e81bc9da3fcfae34d6c094563b9d4670b047312e1"}, + {file = "regex-2023.12.25-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:c40281f7d70baf6e0db0c2f7472b31609f5bc2748fe7275ea65a0b4601d9b392"}, + {file = "regex-2023.12.25-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:d94a1db462d5690ebf6ae86d11c5e420042b9898af5dcf278bd97d6bda065423"}, + {file = "regex-2023.12.25-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ba1b30765a55acf15dce3f364e4928b80858fa8f979ad41f862358939bdd1f2f"}, + {file = "regex-2023.12.25-cp310-cp310-win32.whl", hash = "sha256:150c39f5b964e4d7dba46a7962a088fbc91f06e606f023ce57bb347a3b2d4630"}, + {file = "regex-2023.12.25-cp310-cp310-win_amd64.whl", hash = "sha256:09da66917262d9481c719599116c7dc0c321ffcec4b1f510c4f8a066f8768105"}, + {file = "regex-2023.12.25-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1b9d811f72210fa9306aeb88385b8f8bcef0dfbf3873410413c00aa94c56c2b6"}, + {file = "regex-2023.12.25-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d902a43085a308cef32c0d3aea962524b725403fd9373dea18110904003bac97"}, + {file = "regex-2023.12.25-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d166eafc19f4718df38887b2bbe1467a4f74a9830e8605089ea7a30dd4da8887"}, + {file = "regex-2023.12.25-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7ad32824b7f02bb3c9f80306d405a1d9b7bb89362d68b3c5a9be53836caebdb"}, + {file = "regex-2023.12.25-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:636ba0a77de609d6510235b7f0e77ec494d2657108f777e8765efc060094c98c"}, + {file = "regex-2023.12.25-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fda75704357805eb953a3ee15a2b240694a9a514548cd49b3c5124b4e2ad01b"}, + {file = "regex-2023.12.25-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f72cbae7f6b01591f90814250e636065850c5926751af02bb48da94dfced7baa"}, + {file = "regex-2023.12.25-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db2a0b1857f18b11e3b0e54ddfefc96af46b0896fb678c85f63fb8c37518b3e7"}, + {file = "regex-2023.12.25-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:7502534e55c7c36c0978c91ba6f61703faf7ce733715ca48f499d3dbbd7657e0"}, + {file = "regex-2023.12.25-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:e8c7e08bb566de4faaf11984af13f6bcf6a08f327b13631d41d62592681d24fe"}, + {file = "regex-2023.12.25-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:283fc8eed679758de38fe493b7d7d84a198b558942b03f017b1f94dda8efae80"}, + {file = "regex-2023.12.25-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:f44dd4d68697559d007462b0a3a1d9acd61d97072b71f6d1968daef26bc744bd"}, + {file = "regex-2023.12.25-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:67d3ccfc590e5e7197750fcb3a2915b416a53e2de847a728cfa60141054123d4"}, + {file = "regex-2023.12.25-cp311-cp311-win32.whl", hash = "sha256:68191f80a9bad283432385961d9efe09d783bcd36ed35a60fb1ff3f1ec2efe87"}, + {file = "regex-2023.12.25-cp311-cp311-win_amd64.whl", hash = "sha256:7d2af3f6b8419661a0c421584cfe8aaec1c0e435ce7e47ee2a97e344b98f794f"}, + {file = "regex-2023.12.25-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8a0ccf52bb37d1a700375a6b395bff5dd15c50acb745f7db30415bae3c2b0715"}, + {file = "regex-2023.12.25-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c3c4a78615b7762740531c27cf46e2f388d8d727d0c0c739e72048beb26c8a9d"}, + {file = "regex-2023.12.25-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ad83e7545b4ab69216cef4cc47e344d19622e28aabec61574b20257c65466d6a"}, + {file = "regex-2023.12.25-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7a635871143661feccce3979e1727c4e094f2bdfd3ec4b90dfd4f16f571a87a"}, + {file = "regex-2023.12.25-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d498eea3f581fbe1b34b59c697512a8baef88212f92e4c7830fcc1499f5b45a5"}, + {file = "regex-2023.12.25-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:43f7cd5754d02a56ae4ebb91b33461dc67be8e3e0153f593c509e21d219c5060"}, + {file = "regex-2023.12.25-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51f4b32f793812714fd5307222a7f77e739b9bc566dc94a18126aba3b92b98a3"}, + {file = "regex-2023.12.25-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba99d8077424501b9616b43a2d208095746fb1284fc5ba490139651f971d39d9"}, + {file = "regex-2023.12.25-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4bfc2b16e3ba8850e0e262467275dd4d62f0d045e0e9eda2bc65078c0110a11f"}, + {file = "regex-2023.12.25-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8c2c19dae8a3eb0ea45a8448356ed561be843b13cbc34b840922ddf565498c1c"}, + {file = "regex-2023.12.25-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:60080bb3d8617d96f0fb7e19796384cc2467447ef1c491694850ebd3670bc457"}, + {file = "regex-2023.12.25-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b77e27b79448e34c2c51c09836033056a0547aa360c45eeeb67803da7b0eedaf"}, + {file = "regex-2023.12.25-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:518440c991f514331f4850a63560321f833979d145d7d81186dbe2f19e27ae3d"}, + {file = "regex-2023.12.25-cp312-cp312-win32.whl", hash = "sha256:e2610e9406d3b0073636a3a2e80db05a02f0c3169b5632022b4e81c0364bcda5"}, + {file = "regex-2023.12.25-cp312-cp312-win_amd64.whl", hash = "sha256:cc37b9aeebab425f11f27e5e9e6cf580be7206c6582a64467a14dda211abc232"}, + {file = "regex-2023.12.25-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:da695d75ac97cb1cd725adac136d25ca687da4536154cdc2815f576e4da11c69"}, + {file = "regex-2023.12.25-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d126361607b33c4eb7b36debc173bf25d7805847346dd4d99b5499e1fef52bc7"}, + {file = "regex-2023.12.25-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4719bb05094d7d8563a450cf8738d2e1061420f79cfcc1fa7f0a44744c4d8f73"}, + {file = "regex-2023.12.25-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5dd58946bce44b53b06d94aa95560d0b243eb2fe64227cba50017a8d8b3cd3e2"}, + {file = "regex-2023.12.25-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22a86d9fff2009302c440b9d799ef2fe322416d2d58fc124b926aa89365ec482"}, + {file = "regex-2023.12.25-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2aae8101919e8aa05ecfe6322b278f41ce2994c4a430303c4cd163fef746e04f"}, + {file = "regex-2023.12.25-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e692296c4cc2873967771345a876bcfc1c547e8dd695c6b89342488b0ea55cd8"}, + {file = "regex-2023.12.25-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:263ef5cc10979837f243950637fffb06e8daed7f1ac1e39d5910fd29929e489a"}, + {file = "regex-2023.12.25-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:d6f7e255e5fa94642a0724e35406e6cb7001c09d476ab5fce002f652b36d0c39"}, + {file = "regex-2023.12.25-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:88ad44e220e22b63b0f8f81f007e8abbb92874d8ced66f32571ef8beb0643b2b"}, + {file = "regex-2023.12.25-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:3a17d3ede18f9cedcbe23d2daa8a2cd6f59fe2bf082c567e43083bba3fb00347"}, + {file = "regex-2023.12.25-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d15b274f9e15b1a0b7a45d2ac86d1f634d983ca40d6b886721626c47a400bf39"}, + {file = "regex-2023.12.25-cp37-cp37m-win32.whl", hash = "sha256:ed19b3a05ae0c97dd8f75a5d8f21f7723a8c33bbc555da6bbe1f96c470139d3c"}, + {file = "regex-2023.12.25-cp37-cp37m-win_amd64.whl", hash = "sha256:a6d1047952c0b8104a1d371f88f4ab62e6275567d4458c1e26e9627ad489b445"}, + {file = "regex-2023.12.25-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:b43523d7bc2abd757119dbfb38af91b5735eea45537ec6ec3a5ec3f9562a1c53"}, + {file = "regex-2023.12.25-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:efb2d82f33b2212898f1659fb1c2e9ac30493ac41e4d53123da374c3b5541e64"}, + {file = "regex-2023.12.25-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b7fca9205b59c1a3d5031f7e64ed627a1074730a51c2a80e97653e3e9fa0d415"}, + {file = "regex-2023.12.25-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:086dd15e9435b393ae06f96ab69ab2d333f5d65cbe65ca5a3ef0ec9564dfe770"}, + {file = "regex-2023.12.25-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e81469f7d01efed9b53740aedd26085f20d49da65f9c1f41e822a33992cb1590"}, + {file = "regex-2023.12.25-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:34e4af5b27232f68042aa40a91c3b9bb4da0eeb31b7632e0091afc4310afe6cb"}, + {file = "regex-2023.12.25-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9852b76ab558e45b20bf1893b59af64a28bd3820b0c2efc80e0a70a4a3ea51c1"}, + {file = "regex-2023.12.25-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff100b203092af77d1a5a7abe085b3506b7eaaf9abf65b73b7d6905b6cb76988"}, + {file = "regex-2023.12.25-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cc038b2d8b1470364b1888a98fd22d616fba2b6309c5b5f181ad4483e0017861"}, + {file = "regex-2023.12.25-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:094ba386bb5c01e54e14434d4caabf6583334090865b23ef58e0424a6286d3dc"}, + {file = "regex-2023.12.25-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:5cd05d0f57846d8ba4b71d9c00f6f37d6b97d5e5ef8b3c3840426a475c8f70f4"}, + {file = "regex-2023.12.25-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:9aa1a67bbf0f957bbe096375887b2505f5d8ae16bf04488e8b0f334c36e31360"}, + {file = "regex-2023.12.25-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:98a2636994f943b871786c9e82bfe7883ecdaba2ef5df54e1450fa9869d1f756"}, + {file = "regex-2023.12.25-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:37f8e93a81fc5e5bd8db7e10e62dc64261bcd88f8d7e6640aaebe9bc180d9ce2"}, + {file = "regex-2023.12.25-cp38-cp38-win32.whl", hash = "sha256:d78bd484930c1da2b9679290a41cdb25cc127d783768a0369d6b449e72f88beb"}, + {file = "regex-2023.12.25-cp38-cp38-win_amd64.whl", hash = "sha256:b521dcecebc5b978b447f0f69b5b7f3840eac454862270406a39837ffae4e697"}, + {file = "regex-2023.12.25-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:f7bc09bc9c29ebead055bcba136a67378f03d66bf359e87d0f7c759d6d4ffa31"}, + {file = "regex-2023.12.25-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e14b73607d6231f3cc4622809c196b540a6a44e903bcfad940779c80dffa7be7"}, + {file = "regex-2023.12.25-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9eda5f7a50141291beda3edd00abc2d4a5b16c29c92daf8d5bd76934150f3edc"}, + {file = "regex-2023.12.25-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc6bb9aa69aacf0f6032c307da718f61a40cf970849e471254e0e91c56ffca95"}, + {file = "regex-2023.12.25-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:298dc6354d414bc921581be85695d18912bea163a8b23cac9a2562bbcd5088b1"}, + {file = "regex-2023.12.25-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f4e475a80ecbd15896a976aa0b386c5525d0ed34d5c600b6d3ebac0a67c7ddf"}, + {file = "regex-2023.12.25-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:531ac6cf22b53e0696f8e1d56ce2396311254eb806111ddd3922c9d937151dae"}, + {file = "regex-2023.12.25-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:22f3470f7524b6da61e2020672df2f3063676aff444db1daa283c2ea4ed259d6"}, + {file = "regex-2023.12.25-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:89723d2112697feaa320c9d351e5f5e7b841e83f8b143dba8e2d2b5f04e10923"}, + {file = "regex-2023.12.25-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0ecf44ddf9171cd7566ef1768047f6e66975788258b1c6c6ca78098b95cf9a3d"}, + {file = "regex-2023.12.25-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:905466ad1702ed4acfd67a902af50b8db1feeb9781436372261808df7a2a7bca"}, + {file = "regex-2023.12.25-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:4558410b7a5607a645e9804a3e9dd509af12fb72b9825b13791a37cd417d73a5"}, + {file = "regex-2023.12.25-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:7e316026cc1095f2a3e8cc012822c99f413b702eaa2ca5408a513609488cb62f"}, + {file = "regex-2023.12.25-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3b1de218d5375cd6ac4b5493e0b9f3df2be331e86520f23382f216c137913d20"}, + {file = "regex-2023.12.25-cp39-cp39-win32.whl", hash = "sha256:11a963f8e25ab5c61348d090bf1b07f1953929c13bd2309a0662e9ff680763c9"}, + {file = "regex-2023.12.25-cp39-cp39-win_amd64.whl", hash = "sha256:e693e233ac92ba83a87024e1d32b5f9ab15ca55ddd916d878146f4e3406b5c91"}, + {file = "regex-2023.12.25.tar.gz", hash = "sha256:29171aa128da69afdf4bde412d5bedc335f2ca8fcfe4489038577d05f16181e5"}, +] + +[[package]] +name = "ruff" +version = "0.1.15" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +files = [ + {file = "ruff-0.1.15-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:5fe8d54df166ecc24106db7dd6a68d44852d14eb0729ea4672bb4d96c320b7df"}, + {file = "ruff-0.1.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6f0bfbb53c4b4de117ac4d6ddfd33aa5fc31beeaa21d23c45c6dd249faf9126f"}, + {file = "ruff-0.1.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e0d432aec35bfc0d800d4f70eba26e23a352386be3a6cf157083d18f6f5881c8"}, + {file = "ruff-0.1.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9405fa9ac0e97f35aaddf185a1be194a589424b8713e3b97b762336ec79ff807"}, + {file = "ruff-0.1.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c66ec24fe36841636e814b8f90f572a8c0cb0e54d8b5c2d0e300d28a0d7bffec"}, + {file = "ruff-0.1.15-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:6f8ad828f01e8dd32cc58bc28375150171d198491fc901f6f98d2a39ba8e3ff5"}, + {file = "ruff-0.1.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86811954eec63e9ea162af0ffa9f8d09088bab51b7438e8b6488b9401863c25e"}, + {file = "ruff-0.1.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fd4025ac5e87d9b80e1f300207eb2fd099ff8200fa2320d7dc066a3f4622dc6b"}, + {file = "ruff-0.1.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b17b93c02cdb6aeb696effecea1095ac93f3884a49a554a9afa76bb125c114c1"}, + {file = "ruff-0.1.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ddb87643be40f034e97e97f5bc2ef7ce39de20e34608f3f829db727a93fb82c5"}, + {file = "ruff-0.1.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:abf4822129ed3a5ce54383d5f0e964e7fef74a41e48eb1dfad404151efc130a2"}, + {file = "ruff-0.1.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6c629cf64bacfd136c07c78ac10a54578ec9d1bd2a9d395efbee0935868bf852"}, + {file = "ruff-0.1.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1bab866aafb53da39c2cadfb8e1c4550ac5340bb40300083eb8967ba25481447"}, + {file = "ruff-0.1.15-py3-none-win32.whl", hash = "sha256:2417e1cb6e2068389b07e6fa74c306b2810fe3ee3476d5b8a96616633f40d14f"}, + {file = "ruff-0.1.15-py3-none-win_amd64.whl", hash = "sha256:3837ac73d869efc4182d9036b1405ef4c73d9b1f88da2413875e34e0d6919587"}, + {file = "ruff-0.1.15-py3-none-win_arm64.whl", hash = "sha256:9a933dfb1c14ec7a33cceb1e49ec4a16b51ce3c20fd42663198746efc0427360"}, + {file = "ruff-0.1.15.tar.gz", hash = "sha256:f6dfa8c1b21c913c326919056c390966648b680966febcb796cc9d1aaab8564e"}, +] + +[[package]] +name = "setuptools" +version = "69.2.0" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "setuptools-69.2.0-py3-none-any.whl", hash = "sha256:c21c49fb1042386df081cb5d86759792ab89efca84cf114889191cd09aacc80c"}, + {file = "setuptools-69.2.0.tar.gz", hash = "sha256:0ff4183f8f42cd8fa3acea16c45205521a4ef28f73c6391d8a25e92893134f2e"}, +] + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mypy (==1.9)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + +[[package]] +name = "sqlparse" +version = "0.4.4" +description = "A non-validating SQL parser." +optional = false +python-versions = ">=3.5" +files = [ + {file = "sqlparse-0.4.4-py3-none-any.whl", hash = "sha256:5430a4fe2ac7d0f93e66f1efc6e1338a41884b7ddf2a350cedd20ccc4d9d28f3"}, + {file = "sqlparse-0.4.4.tar.gz", hash = "sha256:d446183e84b8349fa3061f0fe7f06ca94ba65b426946ffebe6e3e8295332420c"}, +] + +[package.extras] +dev = ["build", "flake8"] +doc = ["sphinx"] +test = ["pytest", "pytest-cov"] + +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] + +[[package]] +name = "tqdm" +version = "4.66.2" +description = "Fast, Extensible Progress Meter" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tqdm-4.66.2-py3-none-any.whl", hash = "sha256:1ee4f8a893eb9bef51c6e35730cebf234d5d0b6bd112b0271e10ed7c24a02bd9"}, + {file = "tqdm-4.66.2.tar.gz", hash = "sha256:6cd52cdf0fef0e0f543299cfc96fec90d7b8a7e88745f411ec33eb44d5ed3531"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +dev = ["pytest (>=6)", "pytest-cov", "pytest-timeout", "pytest-xdist"] +notebook = ["ipywidgets (>=6)"] +slack = ["slack-sdk"] +telegram = ["requests"] + +[[package]] +name = "typing-extensions" +version = "4.10.0" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.10.0-py3-none-any.whl", hash = "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475"}, + {file = "typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb"}, +] + +[[package]] +name = "tzdata" +version = "2024.1" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +files = [ + {file = "tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252"}, + {file = "tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd"}, +] + +[[package]] +name = "virtualenv" +version = "20.25.1" +description = "Virtual Python Environment builder" +optional = false +python-versions = ">=3.7" +files = [ + {file = "virtualenv-20.25.1-py3-none-any.whl", hash = "sha256:961c026ac520bac5f69acb8ea063e8a4f071bcc9457b9c1f28f6b085c511583a"}, + {file = "virtualenv-20.25.1.tar.gz", hash = "sha256:e08e13ecdca7a0bd53798f356d5831434afa5b07b93f0abdf0797b7a06ffe197"}, +] + +[package.dependencies] +distlib = ">=0.3.7,<1" +filelock = ">=3.12.2,<4" +platformdirs = ">=3.9.1,<5" + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] + +[metadata] +lock-version = "2.0" +python-versions = "^3.8" +content-hash = "7983f8fe6297b702a07fc4a9cf119a89cafca32b93b2ea2c497342be3011a120" diff --git a/poetry.toml b/poetry.toml new file mode 100644 index 0000000..ab1033b --- /dev/null +++ b/poetry.toml @@ -0,0 +1,2 @@ +[virtualenvs] +in-project = true diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..827d8f3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,109 @@ +[tool.poetry] +name = "django-easy-audit" +version = "1.3.6-a2" +description = "Yet another Django audit log app, hopefully the simplest one." +license = "GPL3" +authors = ["Natán Calzolari "] +readme = "README.md" +homepage = "https://github.com/soynatan/django-easy-audit" +repository = "https://github.com/soynatan/django-easy-audit" +documentation = "https://github.com/soynatan/django-easy-audit/wiki" +classifiers = [ + "Environment :: Plugins", + "Framework :: Django :: 4.2", + "Framework :: Django :: 5.0", + "Framework :: Django", + "Intended Audience :: Developers", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Libraries :: Python Modules", +] +packages = [{include = "easyaudit"}] + +[tool.poetry.dependencies] +python = "^3.8" +django = "^4.2" + +[tool.poetry.group.dev.dependencies] +djlint = "^1.34.1" +pre-commit = [ + {version = "~3.5", python = "<3.9"}, + {version = "^3.5", python = ">=3.9"}, +] +ruff = "^0.1.11" + +[tool.poetry.group.test.dependencies] +pytest = "^7.4.4" +pytest-asyncio = "^0.23.3" +pytest-cov = "^4.1.0" +pytest-django = "^4.7.0" +pytest-ruff = "^0.2.1" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.pytest.ini_options] +asyncio_mode = "auto" +addopts = [ + "--ds=tests.settings", # Forces pytest-django to use test settings + "--ruff", + "--ruff-format", + "--ignore-glob='*/models.py'", +] + +[tool.ruff] +extend-exclude = ["migrations"] +ignore = [ + "D1", # Missing docstrings + "D203", # Docstrings on class definitions not preceded by a blank line + "D213", # Multi-line docstring summary should start at the second line + "D407", # Dashed underline after doc section (not compatible with google style) + "DJ008", # Model does not define `__str__` method + "RUF012", # Mutable default values in class attributes +] +line-length = 92 +select = [ # https://docs.astral.sh/ruff/rules + "F", # pyflakes + "E", # pycodestyle + "W", # pycodestyle + "C90", # mccabe + "I", # isort + "N", # pep8-naming + "D", # pydocstyle + "UP", # pyupgrade + "B", # flake8-bugbear + "S", # flake8-bandit + "A", # flake8-builtins + "C4", # flake8-comprehensions + "DTZ", # flake8-datetimez + "DJ", # flake8-django + "ISC", # flake8-implicit-str-concat + "EXE", # flake8-executable + "PT", # flake8-pytest-style + "Q", # flake8-quotes + "RET", # flake8-return + "TCH", # flake8-type-checking + "SIM", # flake8-simplify + "T20", # flake8-print + "TID", # flake8-tidy-imports + "ERA", # eradicate + "PL", # pylint + "RUF", # ruff +] +show-fixes = true +target-version = "py38" + +[tool.ruff.lint.flake8-pytest-style] +fixture-parentheses = false +mark-parentheses = false + +[tool.ruff.per-file-ignores] +"**/test_*.py" = ["PLR2004", "S101", "S106"] diff --git a/setup.py b/setup.py deleted file mode 100644 index a32a505..0000000 --- a/setup.py +++ /dev/null @@ -1,44 +0,0 @@ -# -*- encoding: utf-8 -*- -import os -from setuptools import find_packages, setup - -with open(os.path.join(os.path.dirname(__file__), 'README.rst')) as readme: - README = readme.read() - -# allow setup.py to be run from any path -os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) - -setup( - name='django-easy-audit', - version='1.3.6', - packages=find_packages(), - include_package_data=True, - install_requires=[ - "beautifulsoup4", - "django>=4.2" - ], - python_requires=">=3.8", - license='GPL3', - description='Yet another Django audit log app, hopefully the simplest one.', - long_description=README, - url='https://github.com/soynatan/django-easy-audit', - author='Natán Calzolari', - author_email='natancalzolari@gmail.com', - classifiers=[ - 'Environment :: Plugins', - 'Framework :: Django', - "Framework :: Django :: 4.2", - "Framework :: Django :: 5.0", - 'Intended Audience :: Developers', - 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', - 'Operating System :: OS Independent', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3 :: Only', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', - 'Programming Language :: Python :: 3.11', - 'Programming Language :: Python :: 3.12', - 'Topic :: Software Development :: Libraries :: Python Modules', - ], -) diff --git a/easyaudit/tests/test_project/asgi.py b/tests/asgi.py similarity index 71% rename from easyaudit/tests/test_project/asgi.py rename to tests/asgi.py index 13c5888..d681775 100644 --- a/easyaudit/tests/test_project/asgi.py +++ b/tests/asgi.py @@ -1,5 +1,4 @@ -""" -ASGI config for test_projject project. +"""ASGI config for test_projject project. It exposes the ASGI callable as a module-level variable named ``application``. @@ -11,6 +10,6 @@ from django.core.asgi import get_asgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'test_project.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") application = get_asgi_application() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..646ab85 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from asgiref.sync import sync_to_async + +if TYPE_CHECKING: + from django.contrib.auth.models import User + from pytest_django.fixtures import SettingsWrapper + + +@pytest.fixture(autouse=True) +def test_settings(settings: SettingsWrapper) -> SettingsWrapper: + settings.TEST = True + + return settings + + +@pytest.fixture +def no_changed_fields_skip(settings: SettingsWrapper) -> SettingsWrapper: + settings.DJANGO_EASY_AUDIT_CRUD_EVENT_NO_CHANGED_FIELDS_SKIP = True + + return settings + + +@pytest.fixture +def username() -> str: + return "joe@example.com" + + +@pytest.fixture +def password() -> str: + return "password" + + +@pytest.fixture +def email() -> str: + return "joe@example.com" + + +@pytest.fixture +def user(django_user_model: User, username: str, password: str, email: str) -> User: + return django_user_model.objects.create_user(username, email, password) + + +@pytest.fixture +async def async_user( + django_user_model: User, username: str, email: str, password: str +) -> User: + return await sync_to_async(django_user_model.objects.create_user)( + username, email, password + ) diff --git a/tests/settings.py b/tests/settings.py new file mode 100644 index 0000000..5343512 --- /dev/null +++ b/tests/settings.py @@ -0,0 +1,129 @@ +"""Django settings for tests project. + +Generated by 'django-admin startproject' using Django 1.11.2. + +For more information on this file, see +https://docs.djangoproject.com/en/1.11/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/1.11/ref/settings/ +""" + +from pathlib import Path + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +BASE_DIR = Path(__file__).parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = "ox!t9_6#yvbpd3y9m$hk0))4k#&@c2^k8sc6mkuslpye8ija0p" # noqa: S105 + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "easyaudit", + "tests.test_app", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "easyaudit.middleware.easyaudit.EasyAuditMiddleware", +] + +ROOT_URLCONF = "tests.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "tests.wsgi.application" +ASGI_APPLICATION = "tests.asgi.application" + + +# Database +# https://docs.djangoproject.com/en/1.11/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} + + +# Password validation +# https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/1.11/topics/i18n/ + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "UTC" + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/1.11/howto/static-files/ + +STATIC_URL = "/static/" + +DJANGO_EASY_AUDIT_REQUEST_EVENT_LIST_FILTER = [ + "method", + "datetime", +] +DJANGO_EASY_AUDIT_PROPAGATE_EXCEPTIONS = True diff --git a/easyaudit/tests/test_app/__init__.py b/tests/test_app/__init__.py similarity index 100% rename from easyaudit/tests/test_app/__init__.py rename to tests/test_app/__init__.py diff --git a/tests/test_app/apps.py b/tests/test_app/apps.py new file mode 100644 index 0000000..f7adde9 --- /dev/null +++ b/tests/test_app/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class TestAppConfig(AppConfig): + name = "tests.test_app" + default_auto_field = "django.db.models.AutoField" diff --git a/easyaudit/tests/test_app/migrations/0003_testbigintforeignkey_testbigintm2m_testbigintmodel_testuuidforeignkey_testuuidm2m_testuuidmodel.py b/tests/test_app/migrations/0001_initial.py similarity index 57% rename from easyaudit/tests/test_app/migrations/0003_testbigintforeignkey_testbigintm2m_testbigintmodel_testuuidforeignkey_testuuidm2m_testuuidmodel.py rename to tests/test_app/migrations/0001_initial.py index cba9cec..51cd148 100644 --- a/easyaudit/tests/test_app/migrations/0003_testbigintforeignkey_testbigintm2m_testbigintmodel_testuuidforeignkey_testuuidm2m_testuuidmodel.py +++ b/tests/test_app/migrations/0001_initial.py @@ -1,61 +1,85 @@ -# Generated by Django 3.0.6 on 2020-05-14 17:28 +# Generated by Django 5.0.1 on 2024-01-05 00:49 -from django.db import migrations, models import django.db.models.deletion import uuid +from django.db import migrations, models class Migration(migrations.Migration): + initial = True + dependencies = [ - ('test_app', '0002_auto_20180220_1533'), ] operations = [ migrations.CreateModel( - name='TestBigIntModel', + name='BigIntModel', fields=[ ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(default='test data', max_length=50)), ], ), migrations.CreateModel( - name='TestUUIDModel', + name='Model', fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('id', models.AutoField(primary_key=True, serialize=False)), ('name', models.CharField(default='test data', max_length=50)), ], ), migrations.CreateModel( - name='TestUUIDM2M', + name='UUIDModel', fields=[ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('name', models.CharField(max_length=50)), - ('test_m2m', models.ManyToManyField(to='test_app.TestUUIDModel')), + ('name', models.CharField(default='test data', max_length=50)), ], ), migrations.CreateModel( - name='TestUUIDForeignKey', + name='BigIntM2MModel', fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=50)), - ('test_fk', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='test_app.TestUUIDModel')), + ('test_m2m', models.ManyToManyField(to='test_app.bigintmodel')), ], ), migrations.CreateModel( - name='TestBigIntM2M', + name='BigIntForeignKeyModel', fields=[ ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=50)), - ('test_m2m', models.ManyToManyField(to='test_app.TestBigIntModel')), + ('test_fk', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='test_app.bigintmodel')), ], ), migrations.CreateModel( - name='TestBigIntForeignKey', + name='M2MModel', fields=[ - ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50)), + ('test_m2m', models.ManyToManyField(to='test_app.model')), + ], + ), + migrations.CreateModel( + name='ForeignKeyModel', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50)), + ('test_fk', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='test_app.model')), + ], + ), + migrations.CreateModel( + name='UUIDM2MModel', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('name', models.CharField(max_length=50)), + ('test_m2m', models.ManyToManyField(to='test_app.uuidmodel')), + ], + ), + migrations.CreateModel( + name='UUIDForeignKeyModel', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), ('name', models.CharField(max_length=50)), - ('test_fk', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='test_app.TestBigIntModel')), + ('test_fk', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='test_app.uuidmodel')), ], ), ] diff --git a/easyaudit/tests/test_app/migrations/__init__.py b/tests/test_app/migrations/__init__.py similarity index 100% rename from easyaudit/tests/test_app/migrations/__init__.py rename to tests/test_app/migrations/__init__.py diff --git a/tests/test_app/models.py b/tests/test_app/models.py new file mode 100644 index 0000000..959c9da --- /dev/null +++ b/tests/test_app/models.py @@ -0,0 +1,53 @@ +# ruff: noqa: A003 +import uuid + +from django.db import models + + +class Model(models.Model): + id = models.AutoField(primary_key=True) + name = models.CharField(max_length=50, default="test data") + + +class ForeignKeyModel(models.Model): + name = models.CharField(max_length=50) + test_fk = models.ForeignKey(Model, on_delete=models.CASCADE) + + +class M2MModel(models.Model): + name = models.CharField(max_length=50) + test_m2m = models.ManyToManyField(Model) + + +class UUIDModel(models.Model): + id = models.UUIDField(primary_key=True, unique=True, editable=False, default=uuid.uuid4) + name = models.CharField(max_length=50, default="test data") + + +class UUIDForeignKeyModel(models.Model): + id = models.UUIDField(primary_key=True, unique=True, editable=False, default=uuid.uuid4) + name = models.CharField(max_length=50) + test_fk = models.ForeignKey(UUIDModel, on_delete=models.CASCADE) + + +class UUIDM2MModel(models.Model): + id = models.UUIDField(primary_key=True, unique=True, editable=False, default=uuid.uuid4) + name = models.CharField(max_length=50) + test_m2m = models.ManyToManyField(UUIDModel) + + +class BigIntModel(models.Model): + id = models.BigAutoField(primary_key=True) + name = models.CharField(max_length=50, default="test data") + + +class BigIntForeignKeyModel(models.Model): + id = models.BigAutoField(primary_key=True) + name = models.CharField(max_length=50) + test_fk = models.ForeignKey(BigIntModel, on_delete=models.CASCADE) + + +class BigIntM2MModel(models.Model): + id = models.BigAutoField(primary_key=True) + name = models.CharField(max_length=50) + test_m2m = models.ManyToManyField(BigIntModel) diff --git a/easyaudit/tests/test_app/urls.py b/tests/test_app/urls.py similarity index 90% rename from easyaudit/tests/test_app/urls.py rename to tests/test_app/urls.py index c1bcb00..d401255 100644 --- a/easyaudit/tests/test_app/urls.py +++ b/tests/test_app/urls.py @@ -1,16 +1,15 @@ from django.urls import re_path -from test_app import views -app_name = 'test_easyaudit' +from tests.test_app import views + +app_name = "test_easyaudit" urlpatterns = [ re_path("index", views.index, name="index"), re_path("create-obj", views.create_obj_view, name="create-obj"), re_path("update-obj", views.update_obj_view, name="update-obj"), - re_path("create-uuid-obj", views.create_uuid_obj_view, name="create-uuid-obj"), re_path("update-uuid-obj", views.update_uuid_obj_view, name="update-uuid-obj"), - re_path("create-big-obj", views.create_big_obj_view, name="create-big-obj"), re_path("update-big-obj", views.update_big_obj_view, name="update-big-obj"), ] diff --git a/tests/test_app/views.py b/tests/test_app/views.py new file mode 100644 index 0000000..c65267a --- /dev/null +++ b/tests/test_app/views.py @@ -0,0 +1,48 @@ +from datetime import datetime, timezone + +from django.http import HttpResponse + +from tests.test_app.models import BigIntModel, Model, UUIDModel + + +def create_obj(model): + return model.objects.create() + + +def update_obj(model, pk, name): + tm = model.objects.get(pk=pk) + tm.name = name + tm.save() + return tm + + +def create_obj_view(request): + obj = create_obj(Model) + return HttpResponse(obj.id) + + +def index(request): + return HttpResponse() + + +def update_obj_view(request): + name = datetime.now(timezone.utc).isoformat() + return HttpResponse(update_obj(Model, request.GET["id"], name).id) + + +def create_uuid_obj_view(request): + return HttpResponse(create_obj(UUIDModel).id) + + +def update_uuid_obj_view(request): + name = datetime.now(timezone.utc).isoformat() + return HttpResponse(update_obj(UUIDModel, request.GET["id"], name).id) + + +def create_big_obj_view(request): + return HttpResponse(create_obj(BigIntModel).id) + + +def update_big_obj_view(request): + name = datetime.now(timezone.utc).isoformat() + return HttpResponse(update_obj(BigIntModel, request.GET["id"], name).id) diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..b938fd2 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,371 @@ +import json + +import pytest +from asgiref.sync import sync_to_async +from django.contrib.contenttypes.models import ContentType +from django.core import management +from django.urls import reverse +from django.utils.version import get_version +from pytest_django.asserts import assertInHTML + +from easyaudit.middleware.easyaudit import clear_request, set_current_user +from easyaudit.models import CRUDEvent, RequestEvent +from tests.test_app.models import ( + BigIntForeignKeyModel, + BigIntM2MModel, + BigIntModel, + ForeignKeyModel, + M2MModel, + Model, + UUIDForeignKeyModel, + UUIDM2MModel, + UUIDModel, +) + + +@pytest.mark.django_db +def test_no_migrations(capsys: pytest.CaptureFixture): + management.call_command("makemigrations", dry_run=True) + + captured = capsys.readouterr().out + assert "No changes detected" in captured + + +def test_no_issues(capsys: pytest.CaptureFixture): + management.call_command("check", fail_level="WARNING") + + captured: str = capsys.readouterr().out + assert "System check identified no issues" in captured + + +@pytest.mark.parametrize( + "model", + [ + BigIntForeignKeyModel, + BigIntM2MModel, + BigIntModel, + ForeignKeyModel, + M2MModel, + Model, + UUIDForeignKeyModel, + UUIDM2MModel, + UUIDModel, + ], +) +class TestDjangoCompat: + def test_model_state(self, model): + """Ensures models have the internal `_state` object.""" + model_instances = model() + assert hasattr(model_instances, "_state") + + +@pytest.mark.django_db +class TestAuditModels: + @pytest.fixture + def model(self): + return Model + + @pytest.fixture + def fk_model(self): + return ForeignKeyModel + + @pytest.fixture + def m2m_model(self): + return M2MModel + + @pytest.fixture + def _audit_logger(self, monkeypatch): + def _crud(*args, **kwargs): + raise ValueError("Test exception") + + monkeypatch.setattr("easyaudit.signals.crud_flows.audit_logger.crud", _crud) + + def test_create_model(self, model): + obj = model.objects.create() + crud_event_qs = CRUDEvent.objects.filter( + object_id=obj.id, content_type=ContentType.objects.get_for_model(obj) + ) + assert crud_event_qs.count() == 1 + + crud_event = crud_event_qs.first() + data = json.loads(crud_event.object_json_repr)[0] + assert data["fields"]["name"] == obj.name + + def test_fk_model(self, model, fk_model): + obj = model.objects.create() + obj_fk = fk_model(name="test", test_fk=obj) + obj_fk.save() + + crud_event = CRUDEvent.objects.filter( + object_id=obj_fk.id, content_type=ContentType.objects.get_for_model(obj_fk) + ).first() + data = json.loads(crud_event.object_json_repr)[0] + assert str(data["fields"]["test_fk"]) == str(obj.id) + + def test_m2m_model(self, model, m2m_model): + obj = model.objects.create() + obj_m2m = m2m_model(name="test") + obj_m2m.save() + obj_m2m.test_m2m.add(obj) + + crud_event = CRUDEvent.objects.filter( + object_id=obj_m2m.id, + content_type=ContentType.objects.get_for_model(obj_m2m), + ).first() + data = json.loads(crud_event.object_json_repr)[0] + assert [str(d) for d in data["fields"]["test_m2m"]] == [str(obj.id)] + + def test_m2m_clear(self, model, m2m_model): + obj = model.objects.create() + obj_m2m = m2m_model(name="test") + obj_m2m.save() + obj_m2m.test_m2m.add(obj) + obj_m2m.test_m2m.clear() + + crud_event = CRUDEvent.objects.filter( + object_id=obj_m2m.id, + content_type=ContentType.objects.get_for_model(obj_m2m), + ).first() + data = json.loads(crud_event.object_json_repr)[0] + assert [str(d) for d in data["fields"]["test_m2m"]] == [] + + @pytest.mark.usefixtures("no_changed_fields_skip") + def test_update_skip_no_changed_fields(self, model): + obj = model.objects.create() + crud_event_qs = CRUDEvent.objects.filter( + object_id=obj.id, content_type=ContentType.objects.get_for_model(obj) + ) + assert crud_event_qs.count() == 1 + + obj.name = "changed name" + obj.save() + assert crud_event_qs.count() == 2 + + last_change = crud_event_qs.first() + assert "name" in last_change.changed_fields + + def test_update(self, model): + obj = model.objects.create() + crud_event_qs = CRUDEvent.objects.filter( + object_id=obj.id, content_type=ContentType.objects.get_for_model(obj) + ) + assert crud_event_qs.count() == 1 + + obj.name = "changed name" + obj.save() + assert crud_event_qs.count() == 2 + + last_change = crud_event_qs.first() + assert "name" in last_change.changed_fields + + @pytest.mark.usefixtures("no_changed_fields_skip") + def test_fake_update_skip_no_changed_fields(self, model): + obj = model.objects.create() + crud_event_qs = CRUDEvent.objects.filter( + object_id=obj.id, content_type=ContentType.objects.get_for_model(obj) + ) + obj.save() + assert crud_event_qs.count() == 1 + + def test_fake_update(self, model): + obj = model.objects.create() + crud_event_qs = CRUDEvent.objects.filter( + object_id=obj.id, content_type=ContentType.objects.get_for_model(obj) + ) + obj.save() + assert crud_event_qs.count() == 2 + + def test_delete(self, model): + obj = model.objects.create() + crud_event_qs = CRUDEvent.objects.filter( + object_id=obj.id, content_type=ContentType.objects.get_for_model(obj) + ) + assert crud_event_qs.count() == 1 + + obj_id = obj.pk + obj.delete() + crud_event_qs = CRUDEvent.objects.filter( + object_id=obj_id, content_type=ContentType.objects.get_for_model(obj) + ) + assert crud_event_qs.count() == 2 + + @pytest.mark.usefixtures("_audit_logger") + def test_propagate_exceptions(self, model, settings): + with pytest.raises(ValueError, match="Test exception"): + model.objects.create() + + settings.DJANGO_EASY_AUDIT_PROPAGATE_EXCEPTIONS = False + try: + model.objects.create() + except ValueError: + pytest.fail("Unexpected ValueError") + + +class TestAuditUUIDModels(TestAuditModels): + @pytest.fixture + def model(self): + return UUIDModel + + @pytest.fixture + def fk_model(self): + return UUIDForeignKeyModel + + @pytest.fixture + def m2m_model(self): + return UUIDM2MModel + + +class TestAuditBigIntModels(TestAuditModels): + @pytest.fixture + def model(self): + return BigIntModel + + @pytest.fixture + def fk_model(self): + return BigIntForeignKeyModel + + @pytest.fixture + def m2m_model(self): + return BigIntM2MModel + + +@pytest.mark.django_db +class TestMiddleware: + def test_middleware_logged_in(self, user, client, username, password): + client.login(username=username, password=password) + client.post(reverse("test_app:create-obj")) + assert Model.objects.count() == 1 + + obj = Model.objects.all().first() + crud_event = CRUDEvent.objects.filter( + object_id=obj.id, content_type=ContentType.objects.get_for_model(obj) + ).first() + assert crud_event.user == user + + def test_middleware_not_logged_in(self, client): + create_obj_url = reverse("test_app:create-obj") + client.post(create_obj_url) + assert Model.objects.count() == 1 + + obj = Model.objects.all().first() + crud_event = CRUDEvent.objects.filter( + object_id=obj.id, content_type=ContentType.objects.get_for_model(obj) + ).first() + assert crud_event.user is None + + def test_manual_set_user(self, django_user_model, username, email, password): + user = django_user_model.objects.create_user(username, email, password) + set_current_user(user) + obj = Model.objects.create() + assert obj.id == 1 + + crud_event_qs = CRUDEvent.objects.filter( + object_id=obj.id, content_type=ContentType.objects.get_for_model(obj) + ) + assert crud_event_qs.count() == 1 + + crud_event = crud_event_qs.first() + assert crud_event.user == user + + clear_request() + obj = Model.objects.create() + assert obj.id == 2 + + crud_event_qs = CRUDEvent.objects.filter( + object_id=obj.id, content_type=ContentType.objects.get_for_model(obj) + ) + assert crud_event_qs.count() == 1 + + crud_event = crud_event_qs.first() + assert crud_event.user is None + + def test_middleware_logged_in_user_in_request(self, user, client): + client.force_login(user) + create_obj_url = reverse("test_app:create-obj") + client.post(create_obj_url) + assert Model.objects.count() == 1 + + obj = Model.objects.first() + crud_event = CRUDEvent.objects.filter( + object_id=obj.id, content_type=ContentType.objects.get_for_model(obj) + ).first() + assert crud_event.user == user + + +@pytest.mark.asyncio +@pytest.mark.django_db(transaction=True) +class TestASGIRequestEvent: + async def test_login(self, async_user, async_client, username, password): + await sync_to_async(async_client.login)(username=username, password=password) + assert await sync_to_async(RequestEvent.objects.count)() == 0 + + resp = await async_client.get(reverse("test_app:index")) + assert resp.status_code == 200 + + qs = await sync_to_async(RequestEvent.objects.filter)(user=async_user) + assert await sync_to_async(qs.exists)() + + async def test_remote_addr_default(self, async_client): + assert await sync_to_async(RequestEvent.objects.count)() == 0 + + resp = await async_client.request( + method="GET", + path=str(reverse("test_app:index")), + server=("127.0.0.1", "80"), + scheme="http", + headers=[(b"host", b"testserver")], + query_string="", + ) + assert resp.status_code == 200 + + event = await sync_to_async(RequestEvent.objects.get)(url=reverse("test_app:index")) + assert event.remote_ip == "127.0.0.1" + + async def test_remote_addr_another(self, async_client): + assert await sync_to_async(RequestEvent.objects.count)() == 0 + + resp = await async_client.request( + method="GET", + path=str(reverse("test_app:index")), + server=("127.0.0.1", "80"), + client=("10.0.0.1", 111), + scheme="http", + headers=[(b"host", b"testserver")], + query_string="", + ) + assert resp.status_code == 200 + + event = await sync_to_async(RequestEvent.objects.get)(url=reverse("test_app:index")) + assert event.remote_ip == "10.0.0.1" + + +@pytest.mark.django_db +class TestWSGIRequestEvent: + def test_login(self, user, client, username, password): + client.login(username=username, password=password) + assert RequestEvent.objects.count() == 0 + + resp = client.get(reverse("test_app:index")) + assert resp.status_code == 200 + + assert RequestEvent.objects.get(user=user) + + +@pytest.mark.django_db +class TestAuditAdmin: + @pytest.fixture + def tag_name(self): + return "summary" if get_version() >= "4.1" else "h3" + + def test_request_event_admin_no_users(self, admin_client, settings, tag_name): + response = admin_client.get(reverse("admin:easyaudit_requestevent_changelist")) + assert response.status_code == 200 + + decoded_content = response.content.decode() + for f in settings.DJANGO_EASY_AUDIT_REQUEST_EVENT_LIST_FILTER: + assertInHTML( + f"<{tag_name}>" + f"By {RequestEvent._meta.get_field(f).verbose_name}" + f"", + decoded_content, + ) diff --git a/easyaudit/tests/test_project/urls.py b/tests/urls.py similarity index 80% rename from easyaudit/tests/test_project/urls.py rename to tests/urls.py index 6a34c4b..01b85bb 100644 --- a/easyaudit/tests/test_project/urls.py +++ b/tests/urls.py @@ -1,8 +1,10 @@ -"""test_project URL Configuration +"""tests URL Configuration. The `urlpatterns` list routes URLs to views. For more information please see: https://docs.djangoproject.com/en/1.11/topics/http/urls/ -Examples: + +Examples +-------- Function views 1. Add an import: from my_app import views 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') @@ -12,11 +14,12 @@ Including another URLconf 1. Import the include() function: from django.conf.urls import url, include 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) + """ -from django.urls import include, re_path from django.contrib import admin +from django.urls import include, re_path urlpatterns = [ - re_path(r'^admin/', admin.site.urls), - re_path(r'^test_app/', include('test_app.urls', namespace="test_app")), + re_path(r"^admin/", admin.site.urls), + re_path(r"^test_app/", include("tests.test_app.urls", namespace="test_app")), ] diff --git a/easyaudit/tests/test_project/wsgi.py b/tests/wsgi.py similarity index 71% rename from easyaudit/tests/test_project/wsgi.py rename to tests/wsgi.py index 4c57701..3fd028f 100644 --- a/easyaudit/tests/test_project/wsgi.py +++ b/tests/wsgi.py @@ -1,5 +1,4 @@ -""" -WSGI config for test_project project. +"""WSGI config for tests project. It exposes the WSGI callable as a module-level variable named ``application``. @@ -11,6 +10,6 @@ from django.core.wsgi import get_wsgi_application -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings") +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") application = get_wsgi_application()