├── .circleci └── config.yml ├── .coveragerc ├── .github └── workflows │ ├── codeql-analysis.yml │ ├── python-publish.yml │ ├── pythonpublish.yml │ └── run_ci.yml ├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── Pipfile ├── Pipfile.lock ├── README.md ├── SECURITY.md ├── changelog.md ├── drf_api_sample ├── Pipfile ├── Pipfile.lock ├── db.sqlite3 ├── drf_api_sample │ ├── __init__.py │ ├── asgi.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── drfapi │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── migrations │ │ └── __init__.py │ ├── models.py │ ├── tests.py │ └── views.py ├── manage.py └── readme.md ├── mkdocs.yml ├── requirements.txt ├── rest_framework_tracking ├── __init__.py ├── admin.py ├── app_settings.py ├── apps.py ├── base_mixins.py ├── base_models.py ├── management │ └── commands │ │ └── clearapilogs.py ├── managers.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20170118_1713.py │ ├── 0003_add_errors.py │ ├── 0004_add_verbose_name.py │ ├── 0005_auto_20171219_1537.py │ ├── 0006_auto_20180315_1442.py │ ├── 0006_view_and_view_method_nullable.py │ ├── 0007_merge_20180419_1646.py │ ├── 0008_auto_20200201_2048.py │ ├── 0009_view_method_max_length_200.py │ ├── 0010_auto_20200609_1404.py │ ├── 0011_auto_20201117_2016.py │ ├── 0012_auto_20210930_0713.py │ ├── 0013_apirequestlog_user_agent.py │ └── __init__.py ├── mixins.py ├── models.py └── templates │ └── admin │ └── rest_framework_tracking │ └── apirequestlog │ └── change_list.html ├── ruff.toml ├── runtests.py ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── conftest.py ├── models.py ├── test_mixins.py ├── test_models.py ├── test_serializers.py ├── urls.py └── views.py └── tox.ini /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | working_directory: ~/repo 5 | docker: 6 | - image: circleci/python:3.8 7 | environment: 8 | PIPENV_VENV_IN_PROJECT: true 9 | steps: 10 | - checkout 11 | - restore_cache: 12 | keys: 13 | - v1-dependencies-{{ checksum "Pipfile" }} 14 | - v1-dependencies- 15 | - run: 16 | name: install dependencies 17 | command: | 18 | python3 -m venv venv 19 | . venv/bin/activate 20 | pipenv install --skip-lock --dev 21 | pip install tox 22 | 23 | - save_cache: 24 | paths: 25 | - ./venv 26 | - run: 27 | name: run tests 28 | command: | 29 | . venv/bin/activate 30 | tox -v 31 | 32 | - store_artifacts: 33 | path: test-reports 34 | destination: test-reports 35 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | include = rest_framework_tracking/* 4 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [master] 9 | schedule: 10 | - cron: '0 6 * * 5' 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | # Override automatic language detection by changing the below list 21 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 22 | language: ['python'] 23 | # Learn more... 24 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 25 | 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v2 29 | with: 30 | # We must fetch at least the immediate parents so that if this is 31 | # a pull request then we can checkout the head. 32 | fetch-depth: 2 33 | 34 | # If this run was triggered by a pull request event, then checkout 35 | # the head of the pull request instead of the merge commit. 36 | - run: git checkout HEAD^2 37 | if: ${{ github.event_name == 'pull_request' }} 38 | 39 | # Initializes the CodeQL tools for scanning. 40 | - name: Initialize CodeQL 41 | uses: github/codeql-action/init@v1 42 | with: 43 | languages: ${{ matrix.language }} 44 | 45 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 46 | # If this step fails, then you should remove it and run the build manually (see below) 47 | - name: Autobuild 48 | uses: github/codeql-action/autobuild@v1 49 | 50 | # ℹ️ Command-line programs to run using the OS shell. 51 | # 📚 https://git.io/JvXDl 52 | 53 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 54 | # and modify them (or add more) to build your code if your project 55 | # uses a compiled language 56 | 57 | #- run: | 58 | # make bootstrap 59 | # make release 60 | 61 | - name: Perform CodeQL Analysis 62 | uses: github/codeql-action/analyze@v1 63 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | # release: 8 | # types: [created] 9 | push: 10 | tags: 11 | - '**' 12 | 13 | jobs: 14 | deploy: 15 | 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: Set up Python 21 | uses: actions/setup-python@v2 22 | with: 23 | python-version: '3.x' 24 | - name: Install dependencies 25 | run: | 26 | python -m pip install --upgrade pip 27 | pip install setuptools wheel twine 28 | - uses: olegtarasov/get-tag@v2.1 29 | id: tagName 30 | with: 31 | tagRegex: "v(.*)" 32 | - name: Build and publish 33 | env: 34 | PACKAGE_VERSION: ${{ steps.tagName.outputs.tag }} 35 | # TWINE_REPOSITORY: pypitest 36 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 37 | TWINE_PASSWORD: ${{ secrets.PYPI_API_KEY }} 38 | # TWINE_PASSWORD: ${{ secrets.TEST_PYPI_TOKEN }} 39 | run: | 40 | python setup.py sdist 41 | twine upload dist/* 42 | -------------------------------------------------------------------------------- /.github/workflows/pythonpublish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Publish 🐍 📦 to PyPI 5 | 6 | 7 | on: 8 | release: 9 | types: [created] 10 | 11 | jobs: 12 | deploy: 13 | name: Build and publish Python 🐍 📦 to PyPI 🚀 14 | runs-on: ubuntu-latest 15 | permissions: 16 | # IMPORTANT: this permission is mandatory for trusted publishing 17 | id-token: write 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: Set up Python 21 | uses: actions/setup-python@v2 22 | with: 23 | python-version: '3.8' 24 | - name: Install dependencies 25 | run: | 26 | python -m pip install --upgrade pip pipenv 27 | pipenv install --dev 28 | pip install setuptools wheel twine 29 | - name: Build 30 | run: | 31 | python setup.py sdist bdist_wheel 32 | - name: Publish Package 33 | uses: pypa/gh-action-pypi-publish@release/v1 34 | -------------------------------------------------------------------------------- /.github/workflows/run_ci.yml: -------------------------------------------------------------------------------- 1 | name: Python package 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python-version: [3.7, 3.8, 3.9] 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip pipenv 22 | pipenv install --dev --skip-lock --system 23 | - name: Lint with flake8 24 | run: | 25 | # stop the build if there are Python syntax errors or undefined names 26 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 27 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 28 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 29 | - name: Test with runtests.py 30 | run: | 31 | python runtests.py 32 | - name: Test with tox 33 | run: | 34 | pip install tox tox-gh-actions 35 | tox -v 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.db 3 | *~ 4 | .* 5 | 6 | html/ 7 | htmlcov/ 8 | coverage/ 9 | build/ 10 | dist/ 11 | *.egg-info/ 12 | MANIFEST 13 | 14 | bin/ 15 | include/ 16 | lib/ 17 | local/ 18 | 19 | !.gitignore 20 | !.travis.yml 21 | !.coveragerc 22 | !.circleci 23 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | cache: pip 3 | sudo: false 4 | matrix: 5 | fast_finish: true 6 | include: 7 | - python: '3.6' 8 | env: DJANGO=2.2 DRF=3.9 9 | - python: '3.6' 10 | env: DJANGO=2.2 DRF=3.10 11 | - python: '3.6' 12 | env: DJANGO=2.2 DRF=3.11 13 | - python: '3.6' 14 | env: DJANGO=3.1 DRF=3.10 15 | - python: '3.6' 16 | env: DJANGO=3.1 DRF=3.11 17 | - python: '3.6' 18 | env: DJANGO=3.1 DRF=3.12 19 | - python: '3.7' 20 | env: DJANGO=2.2 DRF=3.9 21 | dist: xenial 22 | sudo: true 23 | - python: '3.7' 24 | env: DJANGO=2.2 DRF=3.10 25 | dist: xenial 26 | sudo: true 27 | - python: '3.7' 28 | env: DJANGO=2.2 DRF=3.11 29 | dist: xenial 30 | sudo: true 31 | - python: '3.7' 32 | env: DJANGO=3.1 DRF=3.10 33 | dist: xenial 34 | sudo: true 35 | - python: '3.7' 36 | env: DJANGO=3.1 DRF=3.11 37 | dist: xenial 38 | sudo: true 39 | - python: '3.7' 40 | env: DJANGO=3.1 DRF=3.12 41 | dist: xenial 42 | sudo: true 43 | - python: '3.8' 44 | env: TOXENV=py38-flake8 45 | - python: '3.8' 46 | env: DJANGO=2.2 DRF=3.9 47 | dist: xenial 48 | sudo: true 49 | - python: '3.8' 50 | env: DJANGO=2.2 DRF=3.10 51 | dist: xenial 52 | sudo: true 53 | - python: '3.8' 54 | env: DJANGO=2.2 DRF=3.11 55 | dist: xenial 56 | sudo: true 57 | - python: '3.8' 58 | env: DJANGO=3.1 DRF=3.11 59 | dist: xenial 60 | sudo: true 61 | - python: '3.8' 62 | env: DJANGO=3.2 DRF=3.11 63 | dist: xenial 64 | sudo: true 65 | - python: '3.9' 66 | env: DJANGO=3.1 DRF=3.11 67 | dist: xenial 68 | sudo: true 69 | - python: '3.9' 70 | env: DJANGO=3.2 DRF=3.11 71 | dist: xenial 72 | sudo: true 73 | - python: '3.9' 74 | env: DJANGO=3.1 DRF=3.12 75 | dist: xenial 76 | sudo: true 77 | - python: '3.9' 78 | env: DJANGO=3.2 DRF=3.12 79 | dist: xenial 80 | sudo: true 81 | install: 82 | - pip install tox-travis python-coveralls 83 | - pip install -r requirements.txt 84 | script: 85 | - tox 86 | after_success: 87 | - coverage run runtests.py --nolint 88 | - coveralls 89 | deploy: 90 | provider: pypi 91 | twine_version: 3.1.1 92 | user: __token__ 93 | password: 94 | secure: RY3i1vCr64c34Ps7XAzHJxbdMQDJB6kPOdML9mceq9AM2HYWEz2vJG925h8s1+MEKx3kKbjuoqlxNt/FiyDfQCcQe/NUH5FfI1ZQ6Y1T/AerIS3XcmSEid/IYPRAOVT7qJzV0dmnP9Kba6piOK0soTrkkddYyVQ3jwlHM0RQjySX1f0CdC99Rn2ROwPXResmnBvbvaD8jzws0xoOP7OND6sekozs47ro3UQdYx6Iz9IkJx3M0olUofPzt1JDX5O/arE4R482sAHyUykBITkOFQQ4DGrOF6CEDJCYp8cSdsEXjbKKQHYPoFjykJdePYOX7YvJe6W9Lrrr8huFflKQMK2cBFXsB5dYx3WSAKk9xjvuoD5PlKKn/LDmQIMZTVZbRN0K4jvb7SGF1LyvaYBkW0ck3NHL8DtH9NW7FD/tBC60+3qoQqMvDRtbpC26R5CvM2aM7IgqqF4Lx9bGsNEqHYZkn9WIH04rOpCxYcmT19LHKpJvnAHlyTt7gnMGAbjqdoLPD/iD2ZVH2Bf6BflydBDuszY6hpeIjtkazhiW+Ah40d5TUr/nr9IFSDGFLgIx7BUJApeUi7PtP43yRwaQDQWtEVH4YAAcusjOwfjXBF1DuWplnhQg3sJeMMmU3Ub5uf1z642WFctqGRpDRkPdyQ8TZuSTm9VRJmuS+FSNApM= 95 | distributions: sdist bdist_wheel 96 | on: 97 | tags: true 98 | repo: lingster/drf-api-tracking 99 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015, Anna Schneider 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-exclude * __pycache__ 2 | recursive-exclude * *.py[co] 3 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | tox-pyenv = "*" 8 | flaky = "*" 9 | flake8 = "*" 10 | pytest = ">=3.6" 11 | pytest-cov = ">=2.6" 12 | coveralls = "*" 13 | tox = "*" 14 | mock = "*" 15 | twine = "*" 16 | ruff = "*" 17 | pre-commit = "*" 18 | 19 | [packages] 20 | djangorestframework = ">=3.5" 21 | pytest-django = "*" 22 | django-environ = "*" 23 | django = ">=3.2.15" 24 | six = ">=1.13.0" 25 | 26 | [requires] 27 | python_version = "3.8" 28 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "1c9a4278f2ee80e95fee7acd43a14e66e5623b89ea51fa6e54e25e05ac0cbbb7" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.8" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "asgiref": { 20 | "hashes": [ 21 | "sha256:89b2ef2247e3b562a16eef663bc0e2e703ec6468e2fa8a5cd61cd449786d4f6e", 22 | "sha256:9e0ce3aa93a819ba5b45120216b23878cf6e8525eb3848653452b4192b92afed" 23 | ], 24 | "markers": "python_version >= '3.7'", 25 | "version": "==3.7.2" 26 | }, 27 | "backports.zoneinfo": { 28 | "hashes": [ 29 | "sha256:17746bd546106fa389c51dbea67c8b7c8f0d14b5526a579ca6ccf5ed72c526cf", 30 | "sha256:1b13e654a55cd45672cb54ed12148cd33628f672548f373963b0bff67b217328", 31 | "sha256:1c5742112073a563c81f786e77514969acb58649bcdf6cdf0b4ed31a348d4546", 32 | "sha256:4a0f800587060bf8880f954dbef70de6c11bbe59c673c3d818921f042f9954a6", 33 | "sha256:5c144945a7752ca544b4b78c8c41544cdfaf9786f25fe5ffb10e838e19a27570", 34 | "sha256:7b0a64cda4145548fed9efc10322770f929b944ce5cee6c0dfe0c87bf4c0c8c9", 35 | "sha256:8439c030a11780786a2002261569bdf362264f605dfa4d65090b64b05c9f79a7", 36 | "sha256:8961c0f32cd0336fb8e8ead11a1f8cd99ec07145ec2931122faaac1c8f7fd987", 37 | "sha256:89a48c0d158a3cc3f654da4c2de1ceba85263fafb861b98b59040a5086259722", 38 | "sha256:a76b38c52400b762e48131494ba26be363491ac4f9a04c1b7e92483d169f6582", 39 | "sha256:da6013fd84a690242c310d77ddb8441a559e9cb3d3d59ebac9aca1a57b2e18bc", 40 | "sha256:e55b384612d93be96506932a786bbcde5a2db7a9e6a4bb4bffe8b733f5b9036b", 41 | "sha256:e81b76cace8eda1fca50e345242ba977f9be6ae3945af8d46326d776b4cf78d1", 42 | "sha256:e8236383a20872c0cdf5a62b554b27538db7fa1bbec52429d8d106effbaeca08", 43 | "sha256:f04e857b59d9d1ccc39ce2da1021d196e47234873820cbeaad210724b1ee28ac", 44 | "sha256:fadbfe37f74051d024037f223b8e001611eac868b5c5b06144ef4d8b799862f2" 45 | ], 46 | "markers": "python_version < '3.9'", 47 | "version": "==0.2.1" 48 | }, 49 | "django": { 50 | "hashes": [ 51 | "sha256:066b6debb5ac335458d2a713ed995570536c8b59a580005acb0732378d5eb1ee", 52 | "sha256:7efa6b1f781a6119a10ac94b4794ded90db8accbe7802281cd26f8664ffed59c" 53 | ], 54 | "index": "pypi", 55 | "version": "==4.2.1" 56 | }, 57 | "django-environ": { 58 | "hashes": [ 59 | "sha256:510f8c9c1d0a38b0815f91504270c29440a0cf44fab07f55942fa8d31bbb9be6", 60 | "sha256:b3559a91439c9d774a9e0c1ced872364772c612cdf6dc919506a2b13f7a77225" 61 | ], 62 | "index": "pypi", 63 | "version": "==0.10.0" 64 | }, 65 | "djangorestframework": { 66 | "hashes": [ 67 | "sha256:579a333e6256b09489cbe0a067e66abe55c6595d8926be6b99423786334350c8", 68 | "sha256:eb63f58c9f218e1a7d064d17a70751f528ed4e1d35547fdade9aaf4cd103fd08" 69 | ], 70 | "index": "pypi", 71 | "version": "==3.14.0" 72 | }, 73 | "exceptiongroup": { 74 | "hashes": [ 75 | "sha256:232c37c63e4f682982c8b6459f33a8981039e5fb8756b2074364e5055c498c9e", 76 | "sha256:d484c3090ba2889ae2928419117447a14daf3c1231d5e30d0aae34f354f01785" 77 | ], 78 | "markers": "python_version < '3.11'", 79 | "version": "==1.1.1" 80 | }, 81 | "iniconfig": { 82 | "hashes": [ 83 | "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", 84 | "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" 85 | ], 86 | "markers": "python_version >= '3.7'", 87 | "version": "==2.0.0" 88 | }, 89 | "packaging": { 90 | "hashes": [ 91 | "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61", 92 | "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f" 93 | ], 94 | "markers": "python_version >= '3.7'", 95 | "version": "==23.1" 96 | }, 97 | "pluggy": { 98 | "hashes": [ 99 | "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159", 100 | "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3" 101 | ], 102 | "markers": "python_version >= '3.6'", 103 | "version": "==1.0.0" 104 | }, 105 | "pytest": { 106 | "hashes": [ 107 | "sha256:3799fa815351fea3a5e96ac7e503a96fa51cc9942c3753cda7651b93c1cfa362", 108 | "sha256:434afafd78b1d78ed0addf160ad2b77a30d35d4bdf8af234fe621919d9ed15e3" 109 | ], 110 | "markers": "python_version >= '3.7'", 111 | "version": "==7.3.1" 112 | }, 113 | "pytest-django": { 114 | "hashes": [ 115 | "sha256:c60834861933773109334fe5a53e83d1ef4828f2203a1d6a0fa9972f4f75ab3e", 116 | "sha256:d9076f759bb7c36939dbdd5ae6633c18edfc2902d1a69fdbefd2426b970ce6c2" 117 | ], 118 | "index": "pypi", 119 | "version": "==4.5.2" 120 | }, 121 | "pytz": { 122 | "hashes": [ 123 | "sha256:1d8ce29db189191fb55338ee6d0387d82ab59f3d00eac103412d64e0ebd0c588", 124 | "sha256:a151b3abb88eda1d4e34a9814df37de2a80e301e68ba0fd856fb9b46bfbbbffb" 125 | ], 126 | "version": "==2023.3" 127 | }, 128 | "six": { 129 | "hashes": [ 130 | "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", 131 | "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" 132 | ], 133 | "index": "pypi", 134 | "version": "==1.16.0" 135 | }, 136 | "sqlparse": { 137 | "hashes": [ 138 | "sha256:5430a4fe2ac7d0f93e66f1efc6e1338a41884b7ddf2a350cedd20ccc4d9d28f3", 139 | "sha256:d446183e84b8349fa3061f0fe7f06ca94ba65b426946ffebe6e3e8295332420c" 140 | ], 141 | "markers": "python_version >= '3.5'", 142 | "version": "==0.4.4" 143 | }, 144 | "tomli": { 145 | "hashes": [ 146 | "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", 147 | "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" 148 | ], 149 | "markers": "python_version < '3.11'", 150 | "version": "==2.0.1" 151 | }, 152 | "typing-extensions": { 153 | "hashes": [ 154 | "sha256:06006244c70ac8ee83fa8282cb188f697b8db25bc8b4df07be1873c43897060c", 155 | "sha256:3a8b36f13dd5fdc5d1b16fe317f5668545de77fa0b8e02006381fd49d731ab98" 156 | ], 157 | "markers": "python_version < '3.11'", 158 | "version": "==4.6.2" 159 | } 160 | }, 161 | "develop": { 162 | "bleach": { 163 | "hashes": [ 164 | "sha256:1a1a85c1595e07d8db14c5f09f09e6433502c51c595970edc090551f0db99414", 165 | "sha256:33c16e3353dbd13028ab4799a0f89a83f113405c766e9c122df8a06f5b85b3f4" 166 | ], 167 | "markers": "python_version >= '3.7'", 168 | "version": "==6.0.0" 169 | }, 170 | "cachetools": { 171 | "hashes": [ 172 | "sha256:95ef631eeaea14ba2e36f06437f36463aac3a096799e876ee55e5cdccb102590", 173 | "sha256:dce83f2d9b4e1f732a8cd44af8e8fab2dbe46201467fc98b3ef8f269092bf62b" 174 | ], 175 | "markers": "python_version >= '3.7'", 176 | "version": "==5.3.1" 177 | }, 178 | "certifi": { 179 | "hashes": [ 180 | "sha256:0f0d56dc5a6ad56fd4ba36484d6cc34451e1c6548c61daad8c320169f91eddc7", 181 | "sha256:c6c2e98f5c7869efca1f8916fed228dd91539f9f1b444c314c06eef02980c716" 182 | ], 183 | "markers": "python_version >= '3.6'", 184 | "version": "==2023.5.7" 185 | }, 186 | "cffi": { 187 | "hashes": [ 188 | "sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5", 189 | "sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef", 190 | "sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104", 191 | "sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426", 192 | "sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405", 193 | "sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375", 194 | "sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a", 195 | "sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e", 196 | "sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc", 197 | "sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf", 198 | "sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185", 199 | "sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497", 200 | "sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3", 201 | "sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35", 202 | "sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c", 203 | "sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83", 204 | "sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21", 205 | "sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca", 206 | "sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984", 207 | "sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac", 208 | "sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd", 209 | "sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee", 210 | "sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a", 211 | "sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2", 212 | "sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192", 213 | "sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7", 214 | "sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585", 215 | "sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f", 216 | "sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e", 217 | "sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27", 218 | "sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b", 219 | "sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e", 220 | "sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e", 221 | "sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d", 222 | "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c", 223 | "sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415", 224 | "sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82", 225 | "sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02", 226 | "sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314", 227 | "sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325", 228 | "sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c", 229 | "sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3", 230 | "sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914", 231 | "sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045", 232 | "sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d", 233 | "sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9", 234 | "sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5", 235 | "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2", 236 | "sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c", 237 | "sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3", 238 | "sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2", 239 | "sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8", 240 | "sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d", 241 | "sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d", 242 | "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9", 243 | "sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162", 244 | "sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76", 245 | "sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4", 246 | "sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e", 247 | "sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9", 248 | "sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6", 249 | "sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b", 250 | "sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01", 251 | "sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0" 252 | ], 253 | "version": "==1.15.1" 254 | }, 255 | "cfgv": { 256 | "hashes": [ 257 | "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426", 258 | "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736" 259 | ], 260 | "markers": "python_full_version >= '3.6.1'", 261 | "version": "==3.3.1" 262 | }, 263 | "chardet": { 264 | "hashes": [ 265 | "sha256:0d62712b956bc154f85fb0a266e2a3c5913c2967e00348701b32411d6def31e5", 266 | "sha256:362777fb014af596ad31334fde1e8c327dfdb076e1960d1694662d46a6917ab9" 267 | ], 268 | "markers": "python_version >= '3.7'", 269 | "version": "==5.1.0" 270 | }, 271 | "charset-normalizer": { 272 | "hashes": [ 273 | "sha256:04afa6387e2b282cf78ff3dbce20f0cc071c12dc8f685bd40960cc68644cfea6", 274 | "sha256:04eefcee095f58eaabe6dc3cc2262f3bcd776d2c67005880894f447b3f2cb9c1", 275 | "sha256:0be65ccf618c1e7ac9b849c315cc2e8a8751d9cfdaa43027d4f6624bd587ab7e", 276 | "sha256:0c95f12b74681e9ae127728f7e5409cbbef9cd914d5896ef238cc779b8152373", 277 | "sha256:0ca564606d2caafb0abe6d1b5311c2649e8071eb241b2d64e75a0d0065107e62", 278 | "sha256:10c93628d7497c81686e8e5e557aafa78f230cd9e77dd0c40032ef90c18f2230", 279 | "sha256:11d117e6c63e8f495412d37e7dc2e2fff09c34b2d09dbe2bee3c6229577818be", 280 | "sha256:11d3bcb7be35e7b1bba2c23beedac81ee893ac9871d0ba79effc7fc01167db6c", 281 | "sha256:12a2b561af122e3d94cdb97fe6fb2bb2b82cef0cdca131646fdb940a1eda04f0", 282 | "sha256:12d1a39aa6b8c6f6248bb54550efcc1c38ce0d8096a146638fd4738e42284448", 283 | "sha256:1435ae15108b1cb6fffbcea2af3d468683b7afed0169ad718451f8db5d1aff6f", 284 | "sha256:1c60b9c202d00052183c9be85e5eaf18a4ada0a47d188a83c8f5c5b23252f649", 285 | "sha256:1e8fcdd8f672a1c4fc8d0bd3a2b576b152d2a349782d1eb0f6b8e52e9954731d", 286 | "sha256:20064ead0717cf9a73a6d1e779b23d149b53daf971169289ed2ed43a71e8d3b0", 287 | "sha256:21fa558996782fc226b529fdd2ed7866c2c6ec91cee82735c98a197fae39f706", 288 | "sha256:22908891a380d50738e1f978667536f6c6b526a2064156203d418f4856d6e86a", 289 | "sha256:3160a0fd9754aab7d47f95a6b63ab355388d890163eb03b2d2b87ab0a30cfa59", 290 | "sha256:322102cdf1ab682ecc7d9b1c5eed4ec59657a65e1c146a0da342b78f4112db23", 291 | "sha256:34e0a2f9c370eb95597aae63bf85eb5e96826d81e3dcf88b8886012906f509b5", 292 | "sha256:3573d376454d956553c356df45bb824262c397c6e26ce43e8203c4c540ee0acb", 293 | "sha256:3747443b6a904001473370d7810aa19c3a180ccd52a7157aacc264a5ac79265e", 294 | "sha256:38e812a197bf8e71a59fe55b757a84c1f946d0ac114acafaafaf21667a7e169e", 295 | "sha256:3a06f32c9634a8705f4ca9946d667609f52cf130d5548881401f1eb2c39b1e2c", 296 | "sha256:3a5fc78f9e3f501a1614a98f7c54d3969f3ad9bba8ba3d9b438c3bc5d047dd28", 297 | "sha256:3d9098b479e78c85080c98e1e35ff40b4a31d8953102bb0fd7d1b6f8a2111a3d", 298 | "sha256:3dc5b6a8ecfdc5748a7e429782598e4f17ef378e3e272eeb1340ea57c9109f41", 299 | "sha256:4155b51ae05ed47199dc5b2a4e62abccb274cee6b01da5b895099b61b1982974", 300 | "sha256:49919f8400b5e49e961f320c735388ee686a62327e773fa5b3ce6721f7e785ce", 301 | "sha256:53d0a3fa5f8af98a1e261de6a3943ca631c526635eb5817a87a59d9a57ebf48f", 302 | "sha256:5f008525e02908b20e04707a4f704cd286d94718f48bb33edddc7d7b584dddc1", 303 | "sha256:628c985afb2c7d27a4800bfb609e03985aaecb42f955049957814e0491d4006d", 304 | "sha256:65ed923f84a6844de5fd29726b888e58c62820e0769b76565480e1fdc3d062f8", 305 | "sha256:6734e606355834f13445b6adc38b53c0fd45f1a56a9ba06c2058f86893ae8017", 306 | "sha256:6baf0baf0d5d265fa7944feb9f7451cc316bfe30e8df1a61b1bb08577c554f31", 307 | "sha256:6f4f4668e1831850ebcc2fd0b1cd11721947b6dc7c00bf1c6bd3c929ae14f2c7", 308 | "sha256:6f5c2e7bc8a4bf7c426599765b1bd33217ec84023033672c1e9a8b35eaeaaaf8", 309 | "sha256:6f6c7a8a57e9405cad7485f4c9d3172ae486cfef1344b5ddd8e5239582d7355e", 310 | "sha256:7381c66e0561c5757ffe616af869b916c8b4e42b367ab29fedc98481d1e74e14", 311 | "sha256:73dc03a6a7e30b7edc5b01b601e53e7fc924b04e1835e8e407c12c037e81adbd", 312 | "sha256:74db0052d985cf37fa111828d0dd230776ac99c740e1a758ad99094be4f1803d", 313 | "sha256:75f2568b4189dda1c567339b48cba4ac7384accb9c2a7ed655cd86b04055c795", 314 | "sha256:78cacd03e79d009d95635e7d6ff12c21eb89b894c354bd2b2ed0b4763373693b", 315 | "sha256:80d1543d58bd3d6c271b66abf454d437a438dff01c3e62fdbcd68f2a11310d4b", 316 | "sha256:830d2948a5ec37c386d3170c483063798d7879037492540f10a475e3fd6f244b", 317 | "sha256:891cf9b48776b5c61c700b55a598621fdb7b1e301a550365571e9624f270c203", 318 | "sha256:8f25e17ab3039b05f762b0a55ae0b3632b2e073d9c8fc88e89aca31a6198e88f", 319 | "sha256:9a3267620866c9d17b959a84dd0bd2d45719b817245e49371ead79ed4f710d19", 320 | "sha256:a04f86f41a8916fe45ac5024ec477f41f886b3c435da2d4e3d2709b22ab02af1", 321 | "sha256:aaf53a6cebad0eae578f062c7d462155eada9c172bd8c4d250b8c1d8eb7f916a", 322 | "sha256:abc1185d79f47c0a7aaf7e2412a0eb2c03b724581139193d2d82b3ad8cbb00ac", 323 | "sha256:ac0aa6cd53ab9a31d397f8303f92c42f534693528fafbdb997c82bae6e477ad9", 324 | "sha256:ac3775e3311661d4adace3697a52ac0bab17edd166087d493b52d4f4f553f9f0", 325 | "sha256:b06f0d3bf045158d2fb8837c5785fe9ff9b8c93358be64461a1089f5da983137", 326 | "sha256:b116502087ce8a6b7a5f1814568ccbd0e9f6cfd99948aa59b0e241dc57cf739f", 327 | "sha256:b82fab78e0b1329e183a65260581de4375f619167478dddab510c6c6fb04d9b6", 328 | "sha256:bd7163182133c0c7701b25e604cf1611c0d87712e56e88e7ee5d72deab3e76b5", 329 | "sha256:c36bcbc0d5174a80d6cccf43a0ecaca44e81d25be4b7f90f0ed7bcfbb5a00909", 330 | "sha256:c3af8e0f07399d3176b179f2e2634c3ce9c1301379a6b8c9c9aeecd481da494f", 331 | "sha256:c84132a54c750fda57729d1e2599bb598f5fa0344085dbde5003ba429a4798c0", 332 | "sha256:cb7b2ab0188829593b9de646545175547a70d9a6e2b63bf2cd87a0a391599324", 333 | "sha256:cca4def576f47a09a943666b8f829606bcb17e2bc2d5911a46c8f8da45f56755", 334 | "sha256:cf6511efa4801b9b38dc5546d7547d5b5c6ef4b081c60b23e4d941d0eba9cbeb", 335 | "sha256:d16fd5252f883eb074ca55cb622bc0bee49b979ae4e8639fff6ca3ff44f9f854", 336 | "sha256:d2686f91611f9e17f4548dbf050e75b079bbc2a82be565832bc8ea9047b61c8c", 337 | "sha256:d7fc3fca01da18fbabe4625d64bb612b533533ed10045a2ac3dd194bfa656b60", 338 | "sha256:dd5653e67b149503c68c4018bf07e42eeed6b4e956b24c00ccdf93ac79cdff84", 339 | "sha256:de5695a6f1d8340b12a5d6d4484290ee74d61e467c39ff03b39e30df62cf83a0", 340 | "sha256:e0ac8959c929593fee38da1c2b64ee9778733cdf03c482c9ff1d508b6b593b2b", 341 | "sha256:e1b25e3ad6c909f398df8921780d6a3d120d8c09466720226fc621605b6f92b1", 342 | "sha256:e633940f28c1e913615fd624fcdd72fdba807bf53ea6925d6a588e84e1151531", 343 | "sha256:e89df2958e5159b811af9ff0f92614dabf4ff617c03a4c1c6ff53bf1c399e0e1", 344 | "sha256:ea9f9c6034ea2d93d9147818f17c2a0860d41b71c38b9ce4d55f21b6f9165a11", 345 | "sha256:f645caaf0008bacf349875a974220f1f1da349c5dbe7c4ec93048cdc785a3326", 346 | "sha256:f8303414c7b03f794347ad062c0516cee0e15f7a612abd0ce1e25caf6ceb47df", 347 | "sha256:fca62a8301b605b954ad2e9c3666f9d97f63872aa4efcae5492baca2056b74ab" 348 | ], 349 | "markers": "python_full_version >= '3.7.0'", 350 | "version": "==3.1.0" 351 | }, 352 | "colorama": { 353 | "hashes": [ 354 | "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", 355 | "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" 356 | ], 357 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6'", 358 | "version": "==0.4.6" 359 | }, 360 | "coverage": { 361 | "hashes": [ 362 | "sha256:027018943386e7b942fa832372ebc120155fd970837489896099f5cfa2890f79", 363 | "sha256:11b990d520ea75e7ee8dcab5bc908072aaada194a794db9f6d7d5cfd19661e5a", 364 | "sha256:12adf310e4aafddc58afdb04d686795f33f4d7a6fa67a7a9d4ce7d6ae24d949f", 365 | "sha256:1431986dac3923c5945271f169f59c45b8802a114c8f548d611f2015133df77a", 366 | "sha256:1ef221513e6f68b69ee9e159506d583d31aa3567e0ae84eaad9d6ec1107dddaa", 367 | "sha256:20c8ac5386253717e5ccc827caad43ed66fea0efe255727b1053a8154d952398", 368 | "sha256:2198ea6fc548de52adc826f62cb18554caedfb1d26548c1b7c88d8f7faa8f6ba", 369 | "sha256:255758a1e3b61db372ec2736c8e2a1fdfaf563977eedbdf131de003ca5779b7d", 370 | "sha256:265de0fa6778d07de30bcf4d9dc471c3dc4314a23a3c6603d356a3c9abc2dfcf", 371 | "sha256:33a7da4376d5977fbf0a8ed91c4dffaaa8dbf0ddbf4c8eea500a2486d8bc4d7b", 372 | "sha256:42eafe6778551cf006a7c43153af1211c3aaab658d4d66fa5fcc021613d02518", 373 | "sha256:4433b90fae13f86fafff0b326453dd42fc9a639a0d9e4eec4d366436d1a41b6d", 374 | "sha256:4a5375e28c5191ac38cca59b38edd33ef4cc914732c916f2929029b4bfb50795", 375 | "sha256:4a8dbc1f0fbb2ae3de73eb0bdbb914180c7abfbf258e90b311dcd4f585d44bd2", 376 | "sha256:59f53f1dc5b656cafb1badd0feb428c1e7bc19b867479ff72f7a9dd9b479f10e", 377 | "sha256:5dbec3b9095749390c09ab7c89d314727f18800060d8d24e87f01fb9cfb40b32", 378 | "sha256:633713d70ad6bfc49b34ead4060531658dc6dfc9b3eb7d8a716d5873377ab745", 379 | "sha256:6b07130585d54fe8dff3d97b93b0e20290de974dc8177c320aeaf23459219c0b", 380 | "sha256:6c4459b3de97b75e3bd6b7d4b7f0db13f17f504f3d13e2a7c623786289dd670e", 381 | "sha256:6d4817234349a80dbf03640cec6109cd90cba068330703fa65ddf56b60223a6d", 382 | "sha256:723e8130d4ecc8f56e9a611e73b31219595baa3bb252d539206f7bbbab6ffc1f", 383 | "sha256:784f53ebc9f3fd0e2a3f6a78b2be1bd1f5575d7863e10c6e12504f240fd06660", 384 | "sha256:7b6be138d61e458e18d8e6ddcddd36dd96215edfe5f1168de0b1b32635839b62", 385 | "sha256:7ccf362abd726b0410bf8911c31fbf97f09f8f1061f8c1cf03dfc4b6372848f6", 386 | "sha256:83516205e254a0cb77d2d7bb3632ee019d93d9f4005de31dca0a8c3667d5bc04", 387 | "sha256:851cf4ff24062c6aec510a454b2584f6e998cada52d4cb58c5e233d07172e50c", 388 | "sha256:8f830ed581b45b82451a40faabb89c84e1a998124ee4212d440e9c6cf70083e5", 389 | "sha256:94e2565443291bd778421856bc975d351738963071e9b8839ca1fc08b42d4bef", 390 | "sha256:95203854f974e07af96358c0b261f1048d8e1083f2de9b1c565e1be4a3a48cfc", 391 | "sha256:97117225cdd992a9c2a5515db1f66b59db634f59d0679ca1fa3fe8da32749cae", 392 | "sha256:98e8a10b7a314f454d9eff4216a9a94d143a7ee65018dd12442e898ee2310578", 393 | "sha256:a1170fa54185845505fbfa672f1c1ab175446c887cce8212c44149581cf2d466", 394 | "sha256:a6b7d95969b8845250586f269e81e5dfdd8ff828ddeb8567a4a2eaa7313460c4", 395 | "sha256:a8fb6cf131ac4070c9c5a3e21de0f7dc5a0fbe8bc77c9456ced896c12fcdad91", 396 | "sha256:af4fffaffc4067232253715065e30c5a7ec6faac36f8fc8d6f64263b15f74db0", 397 | "sha256:b4a5be1748d538a710f87542f22c2cad22f80545a847ad91ce45e77417293eb4", 398 | "sha256:b5604380f3415ba69de87a289a2b56687faa4fe04dbee0754bfcae433489316b", 399 | "sha256:b9023e237f4c02ff739581ef35969c3739445fb059b060ca51771e69101efffe", 400 | "sha256:bc8ef5e043a2af066fa8cbfc6e708d58017024dc4345a1f9757b329a249f041b", 401 | "sha256:c4ed2820d919351f4167e52425e096af41bfabacb1857186c1ea32ff9983ed75", 402 | "sha256:cca4435eebea7962a52bdb216dec27215d0df64cf27fc1dd538415f5d2b9da6b", 403 | "sha256:d900bb429fdfd7f511f868cedd03a6bbb142f3f9118c09b99ef8dc9bf9643c3c", 404 | "sha256:d9ecf0829c6a62b9b573c7bb6d4dcd6ba8b6f80be9ba4fc7ed50bf4ac9aecd72", 405 | "sha256:dbdb91cd8c048c2b09eb17713b0c12a54fbd587d79adcebad543bc0cd9a3410b", 406 | "sha256:de3001a203182842a4630e7b8d1a2c7c07ec1b45d3084a83d5d227a3806f530f", 407 | "sha256:e07f4a4a9b41583d6eabec04f8b68076ab3cd44c20bd29332c6572dda36f372e", 408 | "sha256:ef8674b0ee8cc11e2d574e3e2998aea5df5ab242e012286824ea3c6970580e53", 409 | "sha256:f4f05d88d9a80ad3cac6244d36dd89a3c00abc16371769f1340101d3cb899fc3", 410 | "sha256:f642e90754ee3e06b0e7e51bce3379590e76b7f76b708e1a71ff043f87025c84", 411 | "sha256:fc2af30ed0d5ae0b1abdb4ebdce598eafd5b35397d4d75deb341a614d333d987" 412 | ], 413 | "markers": "python_version >= '3.7'", 414 | "version": "==6.5.0" 415 | }, 416 | "coveralls": { 417 | "hashes": [ 418 | "sha256:b32a8bb5d2df585207c119d6c01567b81fba690c9c10a753bfe27a335bfc43ea", 419 | "sha256:f42015f31d386b351d4226389b387ae173207058832fbf5c8ec4b40e27b16026" 420 | ], 421 | "index": "pypi", 422 | "version": "==3.3.1" 423 | }, 424 | "cryptography": { 425 | "hashes": [ 426 | "sha256:0ddaee209d1cf1f180f1efa338a68c4621154de0afaef92b89486f5f96047c55", 427 | "sha256:14754bcdae909d66ff24b7b5f166d69340ccc6cb15731670435efd5719294895", 428 | "sha256:344c6de9f8bda3c425b3a41b319522ba3208551b70c2ae00099c205f0d9fd3be", 429 | "sha256:34d405ea69a8b34566ba3dfb0521379b210ea5d560fafedf9f800a9a94a41928", 430 | "sha256:3680248309d340fda9611498a5319b0193a8dbdb73586a1acf8109d06f25b92d", 431 | "sha256:3c5ef25d060c80d6d9f7f9892e1d41bb1c79b78ce74805b8cb4aa373cb7d5ec8", 432 | "sha256:4ab14d567f7bbe7f1cdff1c53d5324ed4d3fc8bd17c481b395db224fb405c237", 433 | "sha256:5c1f7293c31ebc72163a9a0df246f890d65f66b4a40d9ec80081969ba8c78cc9", 434 | "sha256:6b71f64beeea341c9b4f963b48ee3b62d62d57ba93eb120e1196b31dc1025e78", 435 | "sha256:7d92f0248d38faa411d17f4107fc0bce0c42cae0b0ba5415505df72d751bf62d", 436 | "sha256:8362565b3835ceacf4dc8f3b56471a2289cf51ac80946f9087e66dc283a810e0", 437 | "sha256:84a165379cb9d411d58ed739e4af3396e544eac190805a54ba2e0322feb55c46", 438 | "sha256:88ff107f211ea696455ea8d911389f6d2b276aabf3231bf72c8853d22db755c5", 439 | "sha256:9f65e842cb02550fac96536edb1d17f24c0a338fd84eaf582be25926e993dde4", 440 | "sha256:a4fc68d1c5b951cfb72dfd54702afdbbf0fb7acdc9b7dc4301bbf2225a27714d", 441 | "sha256:b7f2f5c525a642cecad24ee8670443ba27ac1fab81bba4cc24c7b6b41f2d0c75", 442 | "sha256:b846d59a8d5a9ba87e2c3d757ca019fa576793e8758174d3868aecb88d6fc8eb", 443 | "sha256:bf8fc66012ca857d62f6a347007e166ed59c0bc150cefa49f28376ebe7d992a2", 444 | "sha256:f5d0bf9b252f30a31664b6f64432b4730bb7038339bd18b1fafe129cfc2be9be" 445 | ], 446 | "markers": "python_version >= '3.7'", 447 | "version": "==41.0.0" 448 | }, 449 | "distlib": { 450 | "hashes": [ 451 | "sha256:14bad2d9b04d3a36127ac97f30b12a19268f211063d8f8ee4f47108896e11b46", 452 | "sha256:f35c4b692542ca110de7ef0bea44d73981caeb34ca0b9b6b2e6d7790dda8f80e" 453 | ], 454 | "version": "==0.3.6" 455 | }, 456 | "docopt": { 457 | "hashes": [ 458 | "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491" 459 | ], 460 | "version": "==0.6.2" 461 | }, 462 | "docutils": { 463 | "hashes": [ 464 | "sha256:96f387a2c5562db4476f09f13bbab2192e764cac08ebbf3a34a95d9b1e4a59d6", 465 | "sha256:f08a4e276c3a1583a86dce3e34aba3fe04d02bba2dd51ed16106244e8a923e3b" 466 | ], 467 | "markers": "python_version >= '3.7'", 468 | "version": "==0.20.1" 469 | }, 470 | "exceptiongroup": { 471 | "hashes": [ 472 | "sha256:232c37c63e4f682982c8b6459f33a8981039e5fb8756b2074364e5055c498c9e", 473 | "sha256:d484c3090ba2889ae2928419117447a14daf3c1231d5e30d0aae34f354f01785" 474 | ], 475 | "markers": "python_version < '3.11'", 476 | "version": "==1.1.1" 477 | }, 478 | "filelock": { 479 | "hashes": [ 480 | "sha256:ad98852315c2ab702aeb628412cbf7e95b7ce8c3bf9565670b4eaecf1db370a9", 481 | "sha256:fc03ae43288c013d2ea83c8597001b1129db351aad9c57fe2409327916b8e718" 482 | ], 483 | "markers": "python_version >= '3.7'", 484 | "version": "==3.12.0" 485 | }, 486 | "flake8": { 487 | "hashes": [ 488 | "sha256:3833794e27ff64ea4e9cf5d410082a8b97ff1a06c16aa3d2027339cd0f1195c7", 489 | "sha256:c61007e76655af75e6785a931f452915b371dc48f56efd765247c8fe68f2b181" 490 | ], 491 | "index": "pypi", 492 | "version": "==6.0.0" 493 | }, 494 | "flaky": { 495 | "hashes": [ 496 | "sha256:3ad100780721a1911f57a165809b7ea265a7863305acb66708220820caf8aa0d", 497 | "sha256:d6eda73cab5ae7364504b7c44670f70abed9e75f77dd116352f662817592ec9c" 498 | ], 499 | "index": "pypi", 500 | "version": "==3.7.0" 501 | }, 502 | "identify": { 503 | "hashes": [ 504 | "sha256:0aac67d5b4812498056d28a9a512a483f5085cc28640b02b258a59dac34301d4", 505 | "sha256:986dbfb38b1140e763e413e6feb44cd731faf72d1909543178aa79b0e258265d" 506 | ], 507 | "markers": "python_version >= '3.7'", 508 | "version": "==2.5.24" 509 | }, 510 | "idna": { 511 | "hashes": [ 512 | "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4", 513 | "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2" 514 | ], 515 | "markers": "python_version >= '3.5'", 516 | "version": "==3.4" 517 | }, 518 | "importlib-metadata": { 519 | "hashes": [ 520 | "sha256:43dd286a2cd8995d5eaef7fee2066340423b818ed3fd70adf0bad5f1fac53fed", 521 | "sha256:92501cdf9cc66ebd3e612f1b4f0c0765dfa42f0fa38ffb319b6bd84dd675d705" 522 | ], 523 | "markers": "python_version >= '3.7'", 524 | "version": "==6.6.0" 525 | }, 526 | "importlib-resources": { 527 | "hashes": [ 528 | "sha256:4be82589bf5c1d7999aedf2a45159d10cb3ca4f19b2271f8792bc8e6da7b22f6", 529 | "sha256:7b1deeebbf351c7578e09bf2f63fa2ce8b5ffec296e0d349139d43cca061a81a" 530 | ], 531 | "markers": "python_version < '3.9'", 532 | "version": "==5.12.0" 533 | }, 534 | "iniconfig": { 535 | "hashes": [ 536 | "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", 537 | "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" 538 | ], 539 | "markers": "python_version >= '3.7'", 540 | "version": "==2.0.0" 541 | }, 542 | "jaraco.classes": { 543 | "hashes": [ 544 | "sha256:2353de3288bc6b82120752201c6b1c1a14b058267fa424ed5ce5984e3b922158", 545 | "sha256:89559fa5c1d3c34eff6f631ad80bb21f378dbcbb35dd161fd2c6b93f5be2f98a" 546 | ], 547 | "markers": "python_version >= '3.7'", 548 | "version": "==3.2.3" 549 | }, 550 | "jeepney": { 551 | "hashes": [ 552 | "sha256:5efe48d255973902f6badc3ce55e2aa6c5c3b3bc642059ef3a91247bcfcc5806", 553 | "sha256:c0a454ad016ca575060802ee4d590dd912e35c122fa04e70306de3d076cce755" 554 | ], 555 | "markers": "sys_platform == 'linux'", 556 | "version": "==0.8.0" 557 | }, 558 | "keyring": { 559 | "hashes": [ 560 | "sha256:771ed2a91909389ed6148631de678f82ddc73737d85a927f382a8a1b157898cd", 561 | "sha256:ba2e15a9b35e21908d0aaf4e0a47acc52d6ae33444df0da2b49d41a46ef6d678" 562 | ], 563 | "markers": "python_version >= '3.7'", 564 | "version": "==23.13.1" 565 | }, 566 | "markdown-it-py": { 567 | "hashes": [ 568 | "sha256:5a35f8d1870171d9acc47b99612dc146129b631baf04970128b568f190d0cc30", 569 | "sha256:7c9a5e412688bc771c67432cbfebcdd686c93ce6484913dccf06cb5a0bea35a1" 570 | ], 571 | "markers": "python_version >= '3.7'", 572 | "version": "==2.2.0" 573 | }, 574 | "mccabe": { 575 | "hashes": [ 576 | "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", 577 | "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e" 578 | ], 579 | "markers": "python_version >= '3.6'", 580 | "version": "==0.7.0" 581 | }, 582 | "mdurl": { 583 | "hashes": [ 584 | "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", 585 | "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" 586 | ], 587 | "markers": "python_version >= '3.7'", 588 | "version": "==0.1.2" 589 | }, 590 | "mock": { 591 | "hashes": [ 592 | "sha256:06f18d7d65b44428202b145a9a36e99c2ee00d1eb992df0caf881d4664377891", 593 | "sha256:0e0bc5ba78b8db3667ad636d964eb963dc97a59f04c6f6214c5f0e4a8f726c56" 594 | ], 595 | "index": "pypi", 596 | "version": "==5.0.2" 597 | }, 598 | "more-itertools": { 599 | "hashes": [ 600 | "sha256:cabaa341ad0389ea83c17a94566a53ae4c9d07349861ecb14dc6d0345cf9ac5d", 601 | "sha256:d2bc7f02446e86a68911e58ded76d6561eea00cddfb2a91e7019bbb586c799f3" 602 | ], 603 | "markers": "python_version >= '3.7'", 604 | "version": "==9.1.0" 605 | }, 606 | "nodeenv": { 607 | "hashes": [ 608 | "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2", 609 | "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec" 610 | ], 611 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6'", 612 | "version": "==1.8.0" 613 | }, 614 | "packaging": { 615 | "hashes": [ 616 | "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61", 617 | "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f" 618 | ], 619 | "markers": "python_version >= '3.7'", 620 | "version": "==23.1" 621 | }, 622 | "pkginfo": { 623 | "hashes": [ 624 | "sha256:4b7a555a6d5a22169fcc9cf7bfd78d296b0361adad412a346c1226849af5e546", 625 | "sha256:8fd5896e8718a4372f0ea9cc9d96f6417c9b986e23a4d116dda26b62cc29d046" 626 | ], 627 | "markers": "python_version >= '3.6'", 628 | "version": "==1.9.6" 629 | }, 630 | "platformdirs": { 631 | "hashes": [ 632 | "sha256:412dae91f52a6f84830f39a8078cecd0e866cb72294a5c66808e74d5e88d251f", 633 | "sha256:e2378146f1964972c03c085bb5662ae80b2b8c06226c54b2ff4aa9483e8a13a5" 634 | ], 635 | "markers": "python_version >= '3.7'", 636 | "version": "==3.5.1" 637 | }, 638 | "pluggy": { 639 | "hashes": [ 640 | "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159", 641 | "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3" 642 | ], 643 | "markers": "python_version >= '3.6'", 644 | "version": "==1.0.0" 645 | }, 646 | "pre-commit": { 647 | "hashes": [ 648 | "sha256:66e37bec2d882de1f17f88075047ef8962581f83c234ac08da21a0c58953d1f0", 649 | "sha256:8056bc52181efadf4aac792b1f4f255dfd2fb5a350ded7335d251a68561e8cb6" 650 | ], 651 | "index": "pypi", 652 | "version": "==3.3.2" 653 | }, 654 | "pycodestyle": { 655 | "hashes": [ 656 | "sha256:347187bdb476329d98f695c213d7295a846d1152ff4fe9bacb8a9590b8ee7053", 657 | "sha256:8a4eaf0d0495c7395bdab3589ac2db602797d76207242c17d470186815706610" 658 | ], 659 | "markers": "python_version >= '3.6'", 660 | "version": "==2.10.0" 661 | }, 662 | "pycparser": { 663 | "hashes": [ 664 | "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9", 665 | "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206" 666 | ], 667 | "version": "==2.21" 668 | }, 669 | "pyflakes": { 670 | "hashes": [ 671 | "sha256:ec55bf7fe21fff7f1ad2f7da62363d749e2a470500eab1b555334b67aa1ef8cf", 672 | "sha256:ec8b276a6b60bd80defed25add7e439881c19e64850afd9b346283d4165fd0fd" 673 | ], 674 | "markers": "python_version >= '3.6'", 675 | "version": "==3.0.1" 676 | }, 677 | "pygments": { 678 | "hashes": [ 679 | "sha256:8ace4d3c1dd481894b2005f560ead0f9f19ee64fe983366be1a21e171d12775c", 680 | "sha256:db2db3deb4b4179f399a09054b023b6a586b76499d36965813c71aa8ed7b5fd1" 681 | ], 682 | "markers": "python_version >= '3.7'", 683 | "version": "==2.15.1" 684 | }, 685 | "pyproject-api": { 686 | "hashes": [ 687 | "sha256:435f46547a9ff22cf4208ee274fca3e2869aeb062a4834adfc99a4dd64af3cf9", 688 | "sha256:4698a3777c2e0f6b624f8a4599131e2a25376d90fe8d146d7ac74c67c6f97c43" 689 | ], 690 | "markers": "python_version >= '3.7'", 691 | "version": "==1.5.1" 692 | }, 693 | "pytest": { 694 | "hashes": [ 695 | "sha256:3799fa815351fea3a5e96ac7e503a96fa51cc9942c3753cda7651b93c1cfa362", 696 | "sha256:434afafd78b1d78ed0addf160ad2b77a30d35d4bdf8af234fe621919d9ed15e3" 697 | ], 698 | "markers": "python_version >= '3.7'", 699 | "version": "==7.3.1" 700 | }, 701 | "pytest-cov": { 702 | "hashes": [ 703 | "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6", 704 | "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a" 705 | ], 706 | "index": "pypi", 707 | "version": "==4.1.0" 708 | }, 709 | "pyyaml": { 710 | "hashes": [ 711 | "sha256:01b45c0191e6d66c470b6cf1b9531a771a83c1c4208272ead47a3ae4f2f603bf", 712 | "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293", 713 | "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b", 714 | "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57", 715 | "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b", 716 | "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4", 717 | "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07", 718 | "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba", 719 | "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9", 720 | "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287", 721 | "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513", 722 | "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0", 723 | "sha256:432557aa2c09802be39460360ddffd48156e30721f5e8d917f01d31694216782", 724 | "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0", 725 | "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92", 726 | "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f", 727 | "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2", 728 | "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc", 729 | "sha256:81957921f441d50af23654aa6c5e5eaf9b06aba7f0a19c18a538dc7ef291c5a1", 730 | "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c", 731 | "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86", 732 | "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4", 733 | "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c", 734 | "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34", 735 | "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b", 736 | "sha256:afa17f5bc4d1b10afd4466fd3a44dc0e245382deca5b3c353d8b757f9e3ecb8d", 737 | "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c", 738 | "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb", 739 | "sha256:bfaef573a63ba8923503d27530362590ff4f576c626d86a9fed95822a8255fd7", 740 | "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737", 741 | "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3", 742 | "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d", 743 | "sha256:d4b0ba9512519522b118090257be113b9468d804b19d63c71dbcf4a48fa32358", 744 | "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53", 745 | "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78", 746 | "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803", 747 | "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a", 748 | "sha256:dbad0e9d368bb989f4515da330b88a057617d16b6a8245084f1b05400f24609f", 749 | "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174", 750 | "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5" 751 | ], 752 | "markers": "python_version >= '3.6'", 753 | "version": "==6.0" 754 | }, 755 | "readme-renderer": { 756 | "hashes": [ 757 | "sha256:cd653186dfc73055656f090f227f5cb22a046d7f71a841dfa305f55c9a513273", 758 | "sha256:f67a16caedfa71eef48a31b39708637a6f4664c4394801a7b0d6432d13907343" 759 | ], 760 | "markers": "python_version >= '3.7'", 761 | "version": "==37.3" 762 | }, 763 | "requests": { 764 | "hashes": [ 765 | "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", 766 | "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1" 767 | ], 768 | "markers": "python_version >= '3.7'", 769 | "version": "==2.31.0" 770 | }, 771 | "requests-toolbelt": { 772 | "hashes": [ 773 | "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", 774 | "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06" 775 | ], 776 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 777 | "version": "==1.0.0" 778 | }, 779 | "rfc3986": { 780 | "hashes": [ 781 | "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", 782 | "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c" 783 | ], 784 | "markers": "python_version >= '3.7'", 785 | "version": "==2.0.0" 786 | }, 787 | "rich": { 788 | "hashes": [ 789 | "sha256:76f6b65ea7e5c5d924ba80e322231d7cb5b5981aa60bfc1e694f1bc097fe6fe1", 790 | "sha256:d204aadb50b936bf6b1a695385429d192bc1fdaf3e8b907e8e26f4c4e4b5bf75" 791 | ], 792 | "markers": "python_full_version >= '3.7.0'", 793 | "version": "==13.4.1" 794 | }, 795 | "ruff": { 796 | "hashes": [ 797 | "sha256:0012f9b7dc137ab7f1f0355e3c4ca49b562baf6c9fa1180948deeb6648c52957", 798 | "sha256:08188f8351f4c0b6216e8463df0a76eb57894ca59a3da65e4ed205db980fd3ae", 799 | "sha256:0827b074635d37984fc98d99316bfab5c8b1231bb83e60dacc83bd92883eedb4", 800 | "sha256:0bbfbf6fd2436165566ca85f6e57be03ed2f0a994faf40180cfbb3604c9232ef", 801 | "sha256:0d61ae4841313f6eeb8292dc349bef27b4ce426e62c36e80ceedc3824e408734", 802 | "sha256:0eb412f20e77529a01fb94d578b19dcb8331b56f93632aa0cce4a2ea27b7aeba", 803 | "sha256:21f00e47ab2308617c44435c8dfd9e2e03897461c9e647ec942deb2a235b4cfd", 804 | "sha256:3ed3b198768d2b3a2300fb18f730cd39948a5cc36ba29ae9d4639a11040880be", 805 | "sha256:643de865fd35cb76c4f0739aea5afe7b8e4d40d623df7e9e6ea99054e5cead0a", 806 | "sha256:739495d2dbde87cf4e3110c8d27bc20febf93112539a968a4e02c26f0deccd1d", 807 | "sha256:8af391ef81f7be960be10886a3c1aac0b298bde7cb9a86ec2b05faeb2081ce6b", 808 | "sha256:95db07b7850b30ebf32b27fe98bc39e0ab99db3985edbbf0754d399eb2f0e690", 809 | "sha256:9613456b0b375766244c25045e353bc8890c856431cd97893c97b10cc93bd28d", 810 | "sha256:b4c037fe2f75bcd9aed0c89c7c507cb7fa59abae2bd4c8b6fc331a28178655a4", 811 | "sha256:b775e2c5fc869359daf8c8b8aa0fd67240201ab2e8d536d14a0edf279af18786", 812 | "sha256:eca02e709b3308eb7255b5f74e779be23b5980fca3862eae28bb23069cd61ae4", 813 | "sha256:f74c4d550f7b8e808455ac77bbce38daafc458434815ba0bc21ae4bdb276509b" 814 | ], 815 | "index": "pypi", 816 | "version": "==0.0.270" 817 | }, 818 | "secretstorage": { 819 | "hashes": [ 820 | "sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77", 821 | "sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99" 822 | ], 823 | "markers": "sys_platform == 'linux'", 824 | "version": "==3.3.3" 825 | }, 826 | "setuptools": { 827 | "hashes": [ 828 | "sha256:5df61bf30bb10c6f756eb19e7c9f3b473051f48db77fddbe06ff2ca307df9a6f", 829 | "sha256:62642358adc77ffa87233bc4d2354c4b2682d214048f500964dbe760ccedf102" 830 | ], 831 | "markers": "python_version >= '3.7'", 832 | "version": "==67.8.0" 833 | }, 834 | "six": { 835 | "hashes": [ 836 | "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", 837 | "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" 838 | ], 839 | "index": "pypi", 840 | "version": "==1.16.0" 841 | }, 842 | "tomli": { 843 | "hashes": [ 844 | "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", 845 | "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" 846 | ], 847 | "markers": "python_version < '3.11'", 848 | "version": "==2.0.1" 849 | }, 850 | "tox": { 851 | "hashes": [ 852 | "sha256:ad87fb7a10ef476afb6eb7e408808057f42976ef0d30ad5fe023099ba493ce58", 853 | "sha256:f1a9541b292aa0449f6c7bb67dc0073f25f9086413c3922fe47f5168cbf7b2f4" 854 | ], 855 | "index": "pypi", 856 | "version": "==4.5.2" 857 | }, 858 | "tox-pyenv": { 859 | "hashes": [ 860 | "sha256:916c2213577aec0b3b5452c5bfb32fd077f3a3196f50a81ad57d7ef3fc2599e4", 861 | "sha256:e470c18af115fe52eeff95e7e3cdd0793613eca19709966fc2724b79d55246cb" 862 | ], 863 | "index": "pypi", 864 | "version": "==1.1.0" 865 | }, 866 | "twine": { 867 | "hashes": [ 868 | "sha256:929bc3c280033347a00f847236564d1c52a3e61b1ac2516c97c48f3ceab756d8", 869 | "sha256:9e102ef5fdd5a20661eb88fad46338806c3bd32cf1db729603fe3697b1bc83c8" 870 | ], 871 | "index": "pypi", 872 | "version": "==4.0.2" 873 | }, 874 | "typing-extensions": { 875 | "hashes": [ 876 | "sha256:06006244c70ac8ee83fa8282cb188f697b8db25bc8b4df07be1873c43897060c", 877 | "sha256:3a8b36f13dd5fdc5d1b16fe317f5668545de77fa0b8e02006381fd49d731ab98" 878 | ], 879 | "markers": "python_version < '3.11'", 880 | "version": "==4.6.2" 881 | }, 882 | "urllib3": { 883 | "hashes": [ 884 | "sha256:61717a1095d7e155cdb737ac7bb2f4324a858a1e2e6466f6d03ff630ca68d3cc", 885 | "sha256:d055c2f9d38dc53c808f6fdc8eab7360b6fdbbde02340ed25cfbcd817c62469e" 886 | ], 887 | "markers": "python_version >= '3.7'", 888 | "version": "==2.0.2" 889 | }, 890 | "virtualenv": { 891 | "hashes": [ 892 | "sha256:6abec7670e5802a528357fdc75b26b9f57d5d92f29c5462ba0fbe45feacc685e", 893 | "sha256:a85caa554ced0c0afbd0d638e7e2d7b5f92d23478d05d17a76daeac8f279f924" 894 | ], 895 | "markers": "python_version >= '3.7'", 896 | "version": "==20.23.0" 897 | }, 898 | "webencodings": { 899 | "hashes": [ 900 | "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", 901 | "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923" 902 | ], 903 | "version": "==0.5.1" 904 | }, 905 | "zipp": { 906 | "hashes": [ 907 | "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b", 908 | "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556" 909 | ], 910 | "markers": "python_version >= '3.7'", 911 | "version": "==3.15.0" 912 | } 913 | } 914 | } 915 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # drf-api-tracking 2 | 3 | [![build-status-image]][travis] 4 | [![pypi-version]][pypi] 5 | [![Requirements Status](https://requires.io/github/lingster/drf-api-tracking/requirements.svg?branch=master)](https://requires.io/github/lingster/drf-api-tracking/requirements/?branch=master) 6 | [![Coverage Status](https://coveralls.io/repos/github/lingster/drf-api-tracking/badge.svg?branch=master)](https://coveralls.io/github/lingster/drf-api-tracking?branch=master) 7 | 8 | ## Overview 9 | 10 | drf-api-tracking provides a Django model and DRF view mixin that work together to log Django Rest Framework requests to the database. You'll get these attributes for every request/response cycle to a view that uses the mixin: 11 | 12 | 13 | Model field name | Description | Model field type 14 | ------------------|-------------|----------------- 15 | `user` | User if authenticated, None if not | Foreign Key 16 | `username_persistent` | Static field that persists the username even if the User model object is deleted | CharField 17 | `requested_at` | Date-time that the request was made | DateTimeField 18 | `response_ms` | Number of milliseconds spent in view code | PositiveIntegerField 19 | `path` | Target URI of the request, e.g., `"/api/"` | CharField 20 | `view` | Target VIEW of the request, e.g., `"views.api.ApiView"` | CharField 21 | `view_method` | Target METHOD of the VIEW of the request, e.g., `"get"` | CharField 22 | `remote_addr` | IP address where the request originated (X_FORWARDED_FOR if available, REMOTE_ADDR if not), e.g., `"127.0.0.1"` | GenericIPAddressField 23 | `host` | Originating host of the request, e.g., `"example.com"` | URLField 24 | `method` | HTTP method, e.g., `"GET"` | CharField 25 | `query_params` | Dictionary of request query parameters, as text | TextField 26 | `data` | Dictionary of POST data (JSON or form), as text | TextField 27 | `response` | JSON response data | TextField 28 | `status_code` | HTTP status code, e.g., `200` or `404` | PositiveIntegerField 29 | 30 | 31 | ## Requirements 32 | 33 | * Django 1.11, 2.0, 2.1, 2.2, 3.0 34 | * Django REST Framework and Python release supporting the version of Django you are using 35 | 36 | Django | Python | DRF 37 | -------|--------|---- 38 | 1.11 | 2.7, 3.5, 3.6 | 3.5, 3.6, 3.7, 3.8, 3.9 39 | 2.0 | 3.5, 3.6, 3.7 | 3.7, 3.8, 3.9 40 | 2.1 | 3.5, 3.6, 3.7, 3.8 | 3.7, 3.8, 3.9 41 | 2.2 | 3.5, 3.6, 3.7, 3.8 | 3.7, 3.8, 3.9 42 | 3.0 | 3.5, 3.6, 3.7, 3.8 | 3.7, 3.8, 3.9 43 | 44 | ## Installation 45 | 46 | Install using `pip`... 47 | 48 | ``` bash 49 | $ pip install drf-api-tracking 50 | ``` 51 | 52 | Register with your Django project by adding `rest_framework_tracking` 53 | to the `INSTALLED_APPS` list in your project's `settings.py` file. 54 | Then run the migrations for the `APIRequestLog` model: 55 | 56 | ``` bash 57 | $ python manage.py migrate 58 | ``` 59 | 60 | ## Usage 61 | 62 | Add the `rest_framework_tracking.mixins.LoggingMixin` to any DRF view 63 | to create an instance of `APIRequestLog` every time the view is called. 64 | 65 | For instance: 66 | ``` python 67 | # views.py 68 | from rest_framework import generics 69 | from rest_framework.response import Response 70 | from rest_framework_tracking.mixins import LoggingMixin 71 | 72 | class LoggingView(LoggingMixin, generics.GenericAPIView): 73 | def get(self, request): 74 | return Response('with logging') 75 | ``` 76 | 77 | For performance enhancement, explicitly choose methods to be logged using `logging_methods` attribute: 78 | 79 | ``` python 80 | class LoggingView(LoggingMixin, generics.CreateModelMixin, generics.GenericAPIView): 81 | logging_methods = ['POST', 'PUT'] 82 | model = ... 83 | ``` 84 | 85 | Moreover, you could define your own rules by overriding `should_log` method. 86 | If `should_log` evaluates to True a log is created. 87 | 88 | ``` python 89 | class LoggingView(LoggingMixin, generics.GenericAPIView): 90 | def should_log(self, request, response): 91 | """Log only errors""" 92 | return response.status_code >= 400 93 | ``` 94 | 95 | At the example above, `logging_methods` attribute will be ignored. If you want to provide some extra rules 96 | on top of the http method filtering you should rewrite the `should_log` method. 97 | 98 | ``` python 99 | class LoggingView(LoggingMixin, generics.GenericAPIView): 100 | def should_log(self, request, response): 101 | """Log only errors with respect on `logging_methods` attributes""" 102 | should_log_method = super(LoggingView, self).should_log(request, response) 103 | if not should_log_method: 104 | return False 105 | return response.status_code >= 400 106 | ``` 107 | 108 | A bit simpler. 109 | ``` python 110 | class LoggingView(LoggingMixin, generics.GenericAPIView): 111 | def should_log(self, request, response): 112 | """Log only errors with respect on `logging_methods` attributes""" 113 | if not request.method in self.logging_methods: 114 | return False 115 | return response.status_code >= 400 116 | ``` 117 | 118 | Finally, you can also apply your customizations by overriding `handle_log` method. 119 | By default, all requests that satisfy `should_log` method are saved on the database. 120 | ``` python 121 | class LoggingView(LoggingMixin, generics.GenericAPIView): 122 | def handle_log(self): 123 | # Do some stuff before saving. 124 | super(MockCustomLogHandlerView, self).handle_log() 125 | # Do some stuff after saving. 126 | ``` 127 | 128 | 129 | Though, you could define your own handling. For example save on an in-memory data structure store, remote logging system etc. 130 | ``` python 131 | class LoggingView(LoggingMixin, generics.GenericAPIView): 132 | 133 | def handle_log(self): 134 | cache.set('my_key', self.log, 86400) 135 | ``` 136 | 137 | Or you could omit save a request to the database. For example, 138 | ``` python 139 | class LoggingView(LoggingMixin, generics.GenericAPIView): 140 | def handle_log(self): 141 | """ 142 | Save only very slow requests. Requests that took more than a second. 143 | """ 144 | if self.log['response_ms'] > 1000: 145 | super(MockCustomLogHandlerView, self).handle_log() 146 | ``` 147 | 148 | If your endpoint accepts large file uploads, drf-api-tracking's default behavior to decode the request body may cause a `RequestDataTooBig` exception. This behavior can be disabled globally by setting `DRF_TRACKING_DECODE_REQUEST_BODY = False` in your `settings.py`file. 149 | 150 | You can also customize this behavior for individual views by setting the `decode_request_body` attribute: 151 | 152 | ``` python 153 | class LoggingView(LoggingMixin, generics.GenericAPIView): 154 | decode_request_body = False 155 | ``` 156 | 157 | ## Security 158 | 159 | By default drf-api-tracking is hiding the values of those fields `{'api', 'token', 'key', 'secret', 'password', 'signature'}`. 160 | The default list hast been taken from Django itself ([https://github.com/django/django/blob/stable/1.11.x/django/contrib/auth/__init__.py#L50](https://github.com/django/django/blob/stable/1.11.x/django/contrib/auth/__init__.py#L50)). 161 | 162 | You can complete this list with your own list by putting the fields you want to be hidden in the `sensitive_fields` parameter of your view. 163 | 164 | ``` python 165 | class LoggingView(LoggingMixin, generics.CreateModelMixin, generics.GenericAPIView): 166 | sensitive_fields = {'my_secret_key', 'my_secret_recipe'} 167 | ``` 168 | 169 | By default drf-tracking allows API request log entries to be modified from Django admin. This can present a data integrity issue in production environments. In order to change this behavior, you can set `DRF_TRACKING_ADMIN_LOG_READONLY` to `True` in your `settings.py` file. 170 | 171 | ## Development 172 | In the folder there is a sample drf project: `drf_api_sample` if changes are made to this packages models, use this project 173 | to help generate new migrations, which should be checked in. 174 | 175 | ## Testing 176 | 177 | Install testing requirements. 178 | 179 | ``` bash 180 | $ pip install -r requirements.txt 181 | ``` 182 | 183 | Run with runtests. 184 | 185 | ``` bash 186 | $ ./runtests.py 187 | ``` 188 | 189 | You can also use the excellent [tox](http://tox.readthedocs.org/en/latest/) testing tool to run the tests against all supported versions of Python and Django. Install tox globally, and then simply run: 190 | 191 | ``` bash 192 | $ tox 193 | ``` 194 | you can also use pyenv to install multiple versions of python and ensure they are found by tox by issuing: 195 | ``` bash 196 | pyenv install 3.8.4 197 | pyenv install 3.7.7 198 | pyenv install 3.6.11 199 | pyenv local 3.8.4 3.7.7 3.6.11 200 | pyenv global 3.8.4 3.7.7 3.6.11 201 | ``` 202 | Also ensure that you don't have a virtualenv activated when you run the tests else you might get the following error, or similar: 203 | ` 204 | ERROR: InterpreterNotFound: python3.6 205 | ` 206 | 207 | ## Contributing 208 | 209 | In order to make changes to the package itself, providing migrations or something else, 210 | make sure to install the current package with pip, otherwise using the `drf_api_sample` won't work. 211 | 212 | ``` bash 213 | pip install -e . 214 | ``` 215 | 216 | After this, you can edit models and creating migrations with 217 | 218 | ``` bash 219 | python drf_api_sample/manage.py makemigrations 220 | ``` 221 | 222 | ## Documentation 223 | 224 | To build the documentation, you'll need to install `mkdocs`. 225 | 226 | ``` bash 227 | $ pip install mkdocs 228 | ``` 229 | 230 | To preview the documentation: 231 | 232 | ``` bash 233 | $ mkdocs serve 234 | Running at: http://127.0.0.1:8000/ 235 | ``` 236 | 237 | To build the documentation: 238 | 239 | ``` bash 240 | $ mkdocs build 241 | ``` 242 | 243 | 244 | [build-status-image]: https://secure.travis-ci.org/lingster/drf-api-tracking.png?branch=master 245 | [travis]: http://travis-ci.org/lingster/drf-api-tracking?branch=master 246 | [pypi-version]: https://img.shields.io/pypi/v/drf-api-tracking.svg 247 | [pypi]: https://pypi.python.org/pypi/drf-api-tracking 248 | 249 | 250 | # travis 251 | Install RVM to have a local user version of ruby/gem: 252 | `https://rvm.io/rvm/install` 253 | Then install travis like this: 254 | `gem install travis` 255 | add your secret key as per the link below: 256 | `https://docs.travis-ci.com/user/encryption-keys/` 257 | 258 | pyenv 259 | --- 260 | using pyenv you can install multiple versions of python so that tox can run tests against all installed versions of python 261 | ``` bash 262 | pyenv global 3.6.8 3.7.7 3.8.2 263 | ``` 264 | ensure that before running tox you don't have a virtualenv created and tox has been installed globally or via pipx 265 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Use this section to tell people about which versions of your project are 6 | currently being supported with security updates. 7 | 8 | | Version | Supported | 9 | | ------- | ------------------ | 10 | | 5.1.x | :white_check_mark: | 11 | | 5.0.x | :x: | 12 | | 4.0.x | :white_check_mark: | 13 | | < 4.0 | :x: | 14 | 15 | ## Reporting a Vulnerability 16 | 17 | Use this section to tell people how to report a vulnerability. 18 | 19 | Tell them where to go, how often they can expect to get an update on a 20 | reported vulnerability, what to expect if the vulnerability is accepted or 21 | declined, etc. 22 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | ## [1.7.0] - 2020-05-25 10 | 11 | ### Added 12 | - Fix for Issue #12 13 | - Include README.md in the pypi description 14 | - Python 3.8 tox/travis ci tests 15 | - New changelog.md (this file) 16 | - PR #3: allow readonly view of logs 17 | ### Removed 18 | - Removed python 2.7 support and tests 19 | -------------------------------------------------------------------------------- /drf_api_sample/Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | 8 | [packages] 9 | django = ">=3.0.7" 10 | djangorestframework = "*" 11 | markdown = "*" 12 | django-filter = "*" 13 | drf-yasg = "*" 14 | drf-api-tracking = {editable = true,path = "./../../drf-api-tracking"} 15 | 16 | [requires] 17 | python_version = "3.8" 18 | -------------------------------------------------------------------------------- /drf_api_sample/Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "baf9bdd715c086af5ef72b298bbc1dd37fae7763fa02ef1941dcc245dda6f271" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.8" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "asgiref": { 20 | "hashes": [ 21 | "sha256:14087924af5be5d8103d6f2edffe45a0bf7ab1b2a771b6f00a6db8c302f21f34", 22 | "sha256:5d6c4a8a1c99f58eaa3bc392ee04e3587b693f09e3af1f3f16a09094f334eb52" 23 | ], 24 | "markers": "python_version >= '3.7'", 25 | "version": "==3.7.0" 26 | }, 27 | "backports.zoneinfo": { 28 | "hashes": [ 29 | "sha256:17746bd546106fa389c51dbea67c8b7c8f0d14b5526a579ca6ccf5ed72c526cf", 30 | "sha256:1b13e654a55cd45672cb54ed12148cd33628f672548f373963b0bff67b217328", 31 | "sha256:1c5742112073a563c81f786e77514969acb58649bcdf6cdf0b4ed31a348d4546", 32 | "sha256:4a0f800587060bf8880f954dbef70de6c11bbe59c673c3d818921f042f9954a6", 33 | "sha256:5c144945a7752ca544b4b78c8c41544cdfaf9786f25fe5ffb10e838e19a27570", 34 | "sha256:7b0a64cda4145548fed9efc10322770f929b944ce5cee6c0dfe0c87bf4c0c8c9", 35 | "sha256:8439c030a11780786a2002261569bdf362264f605dfa4d65090b64b05c9f79a7", 36 | "sha256:8961c0f32cd0336fb8e8ead11a1f8cd99ec07145ec2931122faaac1c8f7fd987", 37 | "sha256:89a48c0d158a3cc3f654da4c2de1ceba85263fafb861b98b59040a5086259722", 38 | "sha256:a76b38c52400b762e48131494ba26be363491ac4f9a04c1b7e92483d169f6582", 39 | "sha256:da6013fd84a690242c310d77ddb8441a559e9cb3d3d59ebac9aca1a57b2e18bc", 40 | "sha256:e55b384612d93be96506932a786bbcde5a2db7a9e6a4bb4bffe8b733f5b9036b", 41 | "sha256:e81b76cace8eda1fca50e345242ba977f9be6ae3945af8d46326d776b4cf78d1", 42 | "sha256:e8236383a20872c0cdf5a62b554b27538db7fa1bbec52429d8d106effbaeca08", 43 | "sha256:f04e857b59d9d1ccc39ce2da1021d196e47234873820cbeaad210724b1ee28ac", 44 | "sha256:fadbfe37f74051d024037f223b8e001611eac868b5c5b06144ef4d8b799862f2" 45 | ], 46 | "markers": "python_version < '3.9'", 47 | "version": "==0.2.1" 48 | }, 49 | "certifi": { 50 | "hashes": [ 51 | "sha256:0f0d56dc5a6ad56fd4ba36484d6cc34451e1c6548c61daad8c320169f91eddc7", 52 | "sha256:c6c2e98f5c7869efca1f8916fed228dd91539f9f1b444c314c06eef02980c716" 53 | ], 54 | "markers": "python_version >= '3.6'", 55 | "version": "==2023.5.7" 56 | }, 57 | "charset-normalizer": { 58 | "hashes": [ 59 | "sha256:04afa6387e2b282cf78ff3dbce20f0cc071c12dc8f685bd40960cc68644cfea6", 60 | "sha256:04eefcee095f58eaabe6dc3cc2262f3bcd776d2c67005880894f447b3f2cb9c1", 61 | "sha256:0be65ccf618c1e7ac9b849c315cc2e8a8751d9cfdaa43027d4f6624bd587ab7e", 62 | "sha256:0c95f12b74681e9ae127728f7e5409cbbef9cd914d5896ef238cc779b8152373", 63 | "sha256:0ca564606d2caafb0abe6d1b5311c2649e8071eb241b2d64e75a0d0065107e62", 64 | "sha256:10c93628d7497c81686e8e5e557aafa78f230cd9e77dd0c40032ef90c18f2230", 65 | "sha256:11d117e6c63e8f495412d37e7dc2e2fff09c34b2d09dbe2bee3c6229577818be", 66 | "sha256:11d3bcb7be35e7b1bba2c23beedac81ee893ac9871d0ba79effc7fc01167db6c", 67 | "sha256:12a2b561af122e3d94cdb97fe6fb2bb2b82cef0cdca131646fdb940a1eda04f0", 68 | "sha256:12d1a39aa6b8c6f6248bb54550efcc1c38ce0d8096a146638fd4738e42284448", 69 | "sha256:1435ae15108b1cb6fffbcea2af3d468683b7afed0169ad718451f8db5d1aff6f", 70 | "sha256:1c60b9c202d00052183c9be85e5eaf18a4ada0a47d188a83c8f5c5b23252f649", 71 | "sha256:1e8fcdd8f672a1c4fc8d0bd3a2b576b152d2a349782d1eb0f6b8e52e9954731d", 72 | "sha256:20064ead0717cf9a73a6d1e779b23d149b53daf971169289ed2ed43a71e8d3b0", 73 | "sha256:21fa558996782fc226b529fdd2ed7866c2c6ec91cee82735c98a197fae39f706", 74 | "sha256:22908891a380d50738e1f978667536f6c6b526a2064156203d418f4856d6e86a", 75 | "sha256:3160a0fd9754aab7d47f95a6b63ab355388d890163eb03b2d2b87ab0a30cfa59", 76 | "sha256:322102cdf1ab682ecc7d9b1c5eed4ec59657a65e1c146a0da342b78f4112db23", 77 | "sha256:34e0a2f9c370eb95597aae63bf85eb5e96826d81e3dcf88b8886012906f509b5", 78 | "sha256:3573d376454d956553c356df45bb824262c397c6e26ce43e8203c4c540ee0acb", 79 | "sha256:3747443b6a904001473370d7810aa19c3a180ccd52a7157aacc264a5ac79265e", 80 | "sha256:38e812a197bf8e71a59fe55b757a84c1f946d0ac114acafaafaf21667a7e169e", 81 | "sha256:3a06f32c9634a8705f4ca9946d667609f52cf130d5548881401f1eb2c39b1e2c", 82 | "sha256:3a5fc78f9e3f501a1614a98f7c54d3969f3ad9bba8ba3d9b438c3bc5d047dd28", 83 | "sha256:3d9098b479e78c85080c98e1e35ff40b4a31d8953102bb0fd7d1b6f8a2111a3d", 84 | "sha256:3dc5b6a8ecfdc5748a7e429782598e4f17ef378e3e272eeb1340ea57c9109f41", 85 | "sha256:4155b51ae05ed47199dc5b2a4e62abccb274cee6b01da5b895099b61b1982974", 86 | "sha256:49919f8400b5e49e961f320c735388ee686a62327e773fa5b3ce6721f7e785ce", 87 | "sha256:53d0a3fa5f8af98a1e261de6a3943ca631c526635eb5817a87a59d9a57ebf48f", 88 | "sha256:5f008525e02908b20e04707a4f704cd286d94718f48bb33edddc7d7b584dddc1", 89 | "sha256:628c985afb2c7d27a4800bfb609e03985aaecb42f955049957814e0491d4006d", 90 | "sha256:65ed923f84a6844de5fd29726b888e58c62820e0769b76565480e1fdc3d062f8", 91 | "sha256:6734e606355834f13445b6adc38b53c0fd45f1a56a9ba06c2058f86893ae8017", 92 | "sha256:6baf0baf0d5d265fa7944feb9f7451cc316bfe30e8df1a61b1bb08577c554f31", 93 | "sha256:6f4f4668e1831850ebcc2fd0b1cd11721947b6dc7c00bf1c6bd3c929ae14f2c7", 94 | "sha256:6f5c2e7bc8a4bf7c426599765b1bd33217ec84023033672c1e9a8b35eaeaaaf8", 95 | "sha256:6f6c7a8a57e9405cad7485f4c9d3172ae486cfef1344b5ddd8e5239582d7355e", 96 | "sha256:7381c66e0561c5757ffe616af869b916c8b4e42b367ab29fedc98481d1e74e14", 97 | "sha256:73dc03a6a7e30b7edc5b01b601e53e7fc924b04e1835e8e407c12c037e81adbd", 98 | "sha256:74db0052d985cf37fa111828d0dd230776ac99c740e1a758ad99094be4f1803d", 99 | "sha256:75f2568b4189dda1c567339b48cba4ac7384accb9c2a7ed655cd86b04055c795", 100 | "sha256:78cacd03e79d009d95635e7d6ff12c21eb89b894c354bd2b2ed0b4763373693b", 101 | "sha256:80d1543d58bd3d6c271b66abf454d437a438dff01c3e62fdbcd68f2a11310d4b", 102 | "sha256:830d2948a5ec37c386d3170c483063798d7879037492540f10a475e3fd6f244b", 103 | "sha256:891cf9b48776b5c61c700b55a598621fdb7b1e301a550365571e9624f270c203", 104 | "sha256:8f25e17ab3039b05f762b0a55ae0b3632b2e073d9c8fc88e89aca31a6198e88f", 105 | "sha256:9a3267620866c9d17b959a84dd0bd2d45719b817245e49371ead79ed4f710d19", 106 | "sha256:a04f86f41a8916fe45ac5024ec477f41f886b3c435da2d4e3d2709b22ab02af1", 107 | "sha256:aaf53a6cebad0eae578f062c7d462155eada9c172bd8c4d250b8c1d8eb7f916a", 108 | "sha256:abc1185d79f47c0a7aaf7e2412a0eb2c03b724581139193d2d82b3ad8cbb00ac", 109 | "sha256:ac0aa6cd53ab9a31d397f8303f92c42f534693528fafbdb997c82bae6e477ad9", 110 | "sha256:ac3775e3311661d4adace3697a52ac0bab17edd166087d493b52d4f4f553f9f0", 111 | "sha256:b06f0d3bf045158d2fb8837c5785fe9ff9b8c93358be64461a1089f5da983137", 112 | "sha256:b116502087ce8a6b7a5f1814568ccbd0e9f6cfd99948aa59b0e241dc57cf739f", 113 | "sha256:b82fab78e0b1329e183a65260581de4375f619167478dddab510c6c6fb04d9b6", 114 | "sha256:bd7163182133c0c7701b25e604cf1611c0d87712e56e88e7ee5d72deab3e76b5", 115 | "sha256:c36bcbc0d5174a80d6cccf43a0ecaca44e81d25be4b7f90f0ed7bcfbb5a00909", 116 | "sha256:c3af8e0f07399d3176b179f2e2634c3ce9c1301379a6b8c9c9aeecd481da494f", 117 | "sha256:c84132a54c750fda57729d1e2599bb598f5fa0344085dbde5003ba429a4798c0", 118 | "sha256:cb7b2ab0188829593b9de646545175547a70d9a6e2b63bf2cd87a0a391599324", 119 | "sha256:cca4def576f47a09a943666b8f829606bcb17e2bc2d5911a46c8f8da45f56755", 120 | "sha256:cf6511efa4801b9b38dc5546d7547d5b5c6ef4b081c60b23e4d941d0eba9cbeb", 121 | "sha256:d16fd5252f883eb074ca55cb622bc0bee49b979ae4e8639fff6ca3ff44f9f854", 122 | "sha256:d2686f91611f9e17f4548dbf050e75b079bbc2a82be565832bc8ea9047b61c8c", 123 | "sha256:d7fc3fca01da18fbabe4625d64bb612b533533ed10045a2ac3dd194bfa656b60", 124 | "sha256:dd5653e67b149503c68c4018bf07e42eeed6b4e956b24c00ccdf93ac79cdff84", 125 | "sha256:de5695a6f1d8340b12a5d6d4484290ee74d61e467c39ff03b39e30df62cf83a0", 126 | "sha256:e0ac8959c929593fee38da1c2b64ee9778733cdf03c482c9ff1d508b6b593b2b", 127 | "sha256:e1b25e3ad6c909f398df8921780d6a3d120d8c09466720226fc621605b6f92b1", 128 | "sha256:e633940f28c1e913615fd624fcdd72fdba807bf53ea6925d6a588e84e1151531", 129 | "sha256:e89df2958e5159b811af9ff0f92614dabf4ff617c03a4c1c6ff53bf1c399e0e1", 130 | "sha256:ea9f9c6034ea2d93d9147818f17c2a0860d41b71c38b9ce4d55f21b6f9165a11", 131 | "sha256:f645caaf0008bacf349875a974220f1f1da349c5dbe7c4ec93048cdc785a3326", 132 | "sha256:f8303414c7b03f794347ad062c0516cee0e15f7a612abd0ce1e25caf6ceb47df", 133 | "sha256:fca62a8301b605b954ad2e9c3666f9d97f63872aa4efcae5492baca2056b74ab" 134 | ], 135 | "markers": "python_full_version >= '3.7.0'", 136 | "version": "==3.1.0" 137 | }, 138 | "coreapi": { 139 | "hashes": [ 140 | "sha256:46145fcc1f7017c076a2ef684969b641d18a2991051fddec9458ad3f78ffc1cb", 141 | "sha256:bf39d118d6d3e171f10df9ede5666f63ad80bba9a29a8ec17726a66cf52ee6f3" 142 | ], 143 | "version": "==2.3.3" 144 | }, 145 | "coreschema": { 146 | "hashes": [ 147 | "sha256:5e6ef7bf38c1525d5e55a895934ab4273548629f16aed5c0a6caa74ebf45551f", 148 | "sha256:9503506007d482ab0867ba14724b93c18a33b22b6d19fb419ef2d239dd4a1607" 149 | ], 150 | "version": "==0.0.4" 151 | }, 152 | "django": { 153 | "hashes": [ 154 | "sha256:066b6debb5ac335458d2a713ed995570536c8b59a580005acb0732378d5eb1ee", 155 | "sha256:7efa6b1f781a6119a10ac94b4794ded90db8accbe7802281cd26f8664ffed59c" 156 | ], 157 | "index": "pypi", 158 | "version": "==4.2.1" 159 | }, 160 | "django-filter": { 161 | "hashes": [ 162 | "sha256:2fe15f78108475eda525692813205fa6f9e8c1caf1ae65daa5862d403c6dbf00", 163 | "sha256:d12d8e0fc6d3eb26641e553e5d53b191eb8cec611427d4bdce0becb1f7c172b5" 164 | ], 165 | "index": "pypi", 166 | "version": "==23.2" 167 | }, 168 | "djangorestframework": { 169 | "hashes": [ 170 | "sha256:579a333e6256b09489cbe0a067e66abe55c6595d8926be6b99423786334350c8", 171 | "sha256:eb63f58c9f218e1a7d064d17a70751f528ed4e1d35547fdade9aaf4cd103fd08" 172 | ], 173 | "index": "pypi", 174 | "version": "==3.14.0" 175 | }, 176 | "drf-api-tracking": { 177 | "editable": true, 178 | "path": "./../../drf-api-tracking" 179 | }, 180 | "drf-yasg": { 181 | "hashes": [ 182 | "sha256:ba9cf4bf79f259290daee9b400fa4fcdb0e78d2f043fa5e9f6589c939fd06d05", 183 | "sha256:ceef0c3b5dc4389781afd786e6dc3697af2a2fe0d8724ee1f637c23d75bbc5b2" 184 | ], 185 | "index": "pypi", 186 | "version": "==1.21.5" 187 | }, 188 | "idna": { 189 | "hashes": [ 190 | "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4", 191 | "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2" 192 | ], 193 | "markers": "python_version >= '3.5'", 194 | "version": "==3.4" 195 | }, 196 | "importlib-metadata": { 197 | "hashes": [ 198 | "sha256:43dd286a2cd8995d5eaef7fee2066340423b818ed3fd70adf0bad5f1fac53fed", 199 | "sha256:92501cdf9cc66ebd3e612f1b4f0c0765dfa42f0fa38ffb319b6bd84dd675d705" 200 | ], 201 | "markers": "python_version < '3.10'", 202 | "version": "==6.6.0" 203 | }, 204 | "inflection": { 205 | "hashes": [ 206 | "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417", 207 | "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2" 208 | ], 209 | "markers": "python_version >= '3.5'", 210 | "version": "==0.5.1" 211 | }, 212 | "itypes": { 213 | "hashes": [ 214 | "sha256:03da6872ca89d29aef62773672b2d408f490f80db48b23079a4b194c86dd04c6", 215 | "sha256:af886f129dea4a2a1e3d36595a2d139589e4dd287f5cab0b40e799ee81570ff1" 216 | ], 217 | "version": "==1.2.0" 218 | }, 219 | "jinja2": { 220 | "hashes": [ 221 | "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852", 222 | "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61" 223 | ], 224 | "markers": "python_version >= '3.7'", 225 | "version": "==3.1.2" 226 | }, 227 | "markdown": { 228 | "hashes": [ 229 | "sha256:065fd4df22da73a625f14890dd77eb8040edcbd68794bcd35943be14490608b2", 230 | "sha256:8bf101198e004dc93e84a12a7395e31aac6a9c9942848ae1d99b9d72cf9b3520" 231 | ], 232 | "index": "pypi", 233 | "version": "==3.4.3" 234 | }, 235 | "markupsafe": { 236 | "hashes": [ 237 | "sha256:0576fe974b40a400449768941d5d0858cc624e3249dfd1e0c33674e5c7ca7aed", 238 | "sha256:085fd3201e7b12809f9e6e9bc1e5c96a368c8523fad5afb02afe3c051ae4afcc", 239 | "sha256:090376d812fb6ac5f171e5938e82e7f2d7adc2b629101cec0db8b267815c85e2", 240 | "sha256:0b462104ba25f1ac006fdab8b6a01ebbfbce9ed37fd37fd4acd70c67c973e460", 241 | "sha256:137678c63c977754abe9086a3ec011e8fd985ab90631145dfb9294ad09c102a7", 242 | "sha256:1bea30e9bf331f3fef67e0a3877b2288593c98a21ccb2cf29b74c581a4eb3af0", 243 | "sha256:22152d00bf4a9c7c83960521fc558f55a1adbc0631fbb00a9471e097b19d72e1", 244 | "sha256:22731d79ed2eb25059ae3df1dfc9cb1546691cc41f4e3130fe6bfbc3ecbbecfa", 245 | "sha256:2298c859cfc5463f1b64bd55cb3e602528db6fa0f3cfd568d3605c50678f8f03", 246 | "sha256:28057e985dace2f478e042eaa15606c7efccb700797660629da387eb289b9323", 247 | "sha256:2e7821bffe00aa6bd07a23913b7f4e01328c3d5cc0b40b36c0bd81d362faeb65", 248 | "sha256:2ec4f2d48ae59bbb9d1f9d7efb9236ab81429a764dedca114f5fdabbc3788013", 249 | "sha256:340bea174e9761308703ae988e982005aedf427de816d1afe98147668cc03036", 250 | "sha256:40627dcf047dadb22cd25ea7ecfe9cbf3bbbad0482ee5920b582f3809c97654f", 251 | "sha256:40dfd3fefbef579ee058f139733ac336312663c6706d1163b82b3003fb1925c4", 252 | "sha256:4cf06cdc1dda95223e9d2d3c58d3b178aa5dacb35ee7e3bbac10e4e1faacb419", 253 | "sha256:50c42830a633fa0cf9e7d27664637532791bfc31c731a87b202d2d8ac40c3ea2", 254 | "sha256:55f44b440d491028addb3b88f72207d71eeebfb7b5dbf0643f7c023ae1fba619", 255 | "sha256:608e7073dfa9e38a85d38474c082d4281f4ce276ac0010224eaba11e929dd53a", 256 | "sha256:63ba06c9941e46fa389d389644e2d8225e0e3e5ebcc4ff1ea8506dce646f8c8a", 257 | "sha256:65608c35bfb8a76763f37036547f7adfd09270fbdbf96608be2bead319728fcd", 258 | "sha256:665a36ae6f8f20a4676b53224e33d456a6f5a72657d9c83c2aa00765072f31f7", 259 | "sha256:6d6607f98fcf17e534162f0709aaad3ab7a96032723d8ac8750ffe17ae5a0666", 260 | "sha256:7313ce6a199651c4ed9d7e4cfb4aa56fe923b1adf9af3b420ee14e6d9a73df65", 261 | "sha256:7668b52e102d0ed87cb082380a7e2e1e78737ddecdde129acadb0eccc5423859", 262 | "sha256:7df70907e00c970c60b9ef2938d894a9381f38e6b9db73c5be35e59d92e06625", 263 | "sha256:7e007132af78ea9df29495dbf7b5824cb71648d7133cf7848a2a5dd00d36f9ff", 264 | "sha256:835fb5e38fd89328e9c81067fd642b3593c33e1e17e2fdbf77f5676abb14a156", 265 | "sha256:8bca7e26c1dd751236cfb0c6c72d4ad61d986e9a41bbf76cb445f69488b2a2bd", 266 | "sha256:8db032bf0ce9022a8e41a22598eefc802314e81b879ae093f36ce9ddf39ab1ba", 267 | "sha256:99625a92da8229df6d44335e6fcc558a5037dd0a760e11d84be2260e6f37002f", 268 | "sha256:9cad97ab29dfc3f0249b483412c85c8ef4766d96cdf9dcf5a1e3caa3f3661cf1", 269 | "sha256:a4abaec6ca3ad8660690236d11bfe28dfd707778e2442b45addd2f086d6ef094", 270 | "sha256:a6e40afa7f45939ca356f348c8e23048e02cb109ced1eb8420961b2f40fb373a", 271 | "sha256:a6f2fcca746e8d5910e18782f976489939d54a91f9411c32051b4aab2bd7c513", 272 | "sha256:a806db027852538d2ad7555b203300173dd1b77ba116de92da9afbc3a3be3eed", 273 | "sha256:abcabc8c2b26036d62d4c746381a6f7cf60aafcc653198ad678306986b09450d", 274 | "sha256:b8526c6d437855442cdd3d87eede9c425c4445ea011ca38d937db299382e6fa3", 275 | "sha256:bb06feb762bade6bf3c8b844462274db0c76acc95c52abe8dbed28ae3d44a147", 276 | "sha256:c0a33bc9f02c2b17c3ea382f91b4db0e6cde90b63b296422a939886a7a80de1c", 277 | "sha256:c4a549890a45f57f1ebf99c067a4ad0cb423a05544accaf2b065246827ed9603", 278 | "sha256:ca244fa73f50a800cf8c3ebf7fd93149ec37f5cb9596aa8873ae2c1d23498601", 279 | "sha256:cf877ab4ed6e302ec1d04952ca358b381a882fbd9d1b07cccbfd61783561f98a", 280 | "sha256:d9d971ec1e79906046aa3ca266de79eac42f1dbf3612a05dc9368125952bd1a1", 281 | "sha256:da25303d91526aac3672ee6d49a2f3db2d9502a4a60b55519feb1a4c7714e07d", 282 | "sha256:e55e40ff0cc8cc5c07996915ad367fa47da6b3fc091fdadca7f5403239c5fec3", 283 | "sha256:f03a532d7dee1bed20bc4884194a16160a2de9ffc6354b3878ec9682bb623c54", 284 | "sha256:f1cd098434e83e656abf198f103a8207a8187c0fc110306691a2e94a78d0abb2", 285 | "sha256:f2bfb563d0211ce16b63c7cb9395d2c682a23187f54c3d79bfec33e6705473c6", 286 | "sha256:f8ffb705ffcf5ddd0e80b65ddf7bed7ee4f5a441ea7d3419e861a12eaf41af58" 287 | ], 288 | "markers": "python_version >= '3.7'", 289 | "version": "==2.1.2" 290 | }, 291 | "packaging": { 292 | "hashes": [ 293 | "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61", 294 | "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f" 295 | ], 296 | "markers": "python_version >= '3.7'", 297 | "version": "==23.1" 298 | }, 299 | "pytz": { 300 | "hashes": [ 301 | "sha256:1d8ce29db189191fb55338ee6d0387d82ab59f3d00eac103412d64e0ebd0c588", 302 | "sha256:a151b3abb88eda1d4e34a9814df37de2a80e301e68ba0fd856fb9b46bfbbbffb" 303 | ], 304 | "version": "==2023.3" 305 | }, 306 | "requests": { 307 | "hashes": [ 308 | "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", 309 | "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1" 310 | ], 311 | "markers": "python_version >= '3.7'", 312 | "version": "==2.31.0" 313 | }, 314 | "ruamel.yaml": { 315 | "hashes": [ 316 | "sha256:25d0ee82a0a9a6f44683dcf8c282340def4074a4562f3a24f55695bb254c1693", 317 | "sha256:baa2d0a5aad2034826c439ce61c142c07082b76f4791d54145e131206e998059" 318 | ], 319 | "markers": "python_version >= '3'", 320 | "version": "==0.17.26" 321 | }, 322 | "ruamel.yaml.clib": { 323 | "hashes": [ 324 | "sha256:045e0626baf1c52e5527bd5db361bc83180faaba2ff586e763d3d5982a876a9e", 325 | "sha256:15910ef4f3e537eea7fe45f8a5d19997479940d9196f357152a09031c5be59f3", 326 | "sha256:184faeaec61dbaa3cace407cffc5819f7b977e75360e8d5ca19461cd851a5fc5", 327 | "sha256:1f08fd5a2bea9c4180db71678e850b995d2a5f4537be0e94557668cf0f5f9497", 328 | "sha256:2aa261c29a5545adfef9296b7e33941f46aa5bbd21164228e833412af4c9c75f", 329 | "sha256:3110a99e0f94a4a3470ff67fc20d3f96c25b13d24c6980ff841e82bafe827cac", 330 | "sha256:3243f48ecd450eddadc2d11b5feb08aca941b5cd98c9b1db14b2fd128be8c697", 331 | "sha256:370445fd795706fd291ab00c9df38a0caed0f17a6fb46b0f607668ecb16ce763", 332 | "sha256:40d030e2329ce5286d6b231b8726959ebbe0404c92f0a578c0e2482182e38282", 333 | "sha256:41d0f1fa4c6830176eef5b276af04c89320ea616655d01327d5ce65e50575c94", 334 | "sha256:4a4d8d417868d68b979076a9be6a38c676eca060785abaa6709c7b31593c35d1", 335 | "sha256:4b3a93bb9bc662fc1f99c5c3ea8e623d8b23ad22f861eb6fce9377ac07ad6072", 336 | "sha256:5bc0667c1eb8f83a3752b71b9c4ba55ef7c7058ae57022dd9b29065186a113d9", 337 | "sha256:721bc4ba4525f53f6a611ec0967bdcee61b31df5a56801281027a3a6d1c2daf5", 338 | "sha256:763d65baa3b952479c4e972669f679fe490eee058d5aa85da483ebae2009d231", 339 | "sha256:7bdb4c06b063f6fd55e472e201317a3bb6cdeeee5d5a38512ea5c01e1acbdd93", 340 | "sha256:8831a2cedcd0f0927f788c5bdf6567d9dc9cc235646a434986a852af1cb54b4b", 341 | "sha256:91a789b4aa0097b78c93e3dc4b40040ba55bef518f84a40d4442f713b4094acb", 342 | "sha256:92460ce908546ab69770b2e576e4f99fbb4ce6ab4b245345a3869a0a0410488f", 343 | "sha256:99e77daab5d13a48a4054803d052ff40780278240a902b880dd37a51ba01a307", 344 | "sha256:a234a20ae07e8469da311e182e70ef6b199d0fbeb6c6cc2901204dd87fb867e8", 345 | "sha256:a7b301ff08055d73223058b5c46c55638917f04d21577c95e00e0c4d79201a6b", 346 | "sha256:be2a7ad8fd8f7442b24323d24ba0b56c51219513cfa45b9ada3b87b76c374d4b", 347 | "sha256:bf9a6bc4a0221538b1a7de3ed7bca4c93c02346853f44e1cd764be0023cd3640", 348 | "sha256:c3ca1fbba4ae962521e5eb66d72998b51f0f4d0f608d3c0347a48e1af262efa7", 349 | "sha256:d000f258cf42fec2b1bbf2863c61d7b8918d31ffee905da62dede869254d3b8a", 350 | "sha256:d5859983f26d8cd7bb5c287ef452e8aacc86501487634573d260968f753e1d71", 351 | "sha256:d5e51e2901ec2366b79f16c2299a03e74ba4531ddcfacc1416639c557aef0ad8", 352 | "sha256:da538167284de58a52109a9b89b8f6a53ff8437dd6dc26d33b57bf6699153122", 353 | "sha256:debc87a9516b237d0466a711b18b6ebeb17ba9f391eb7f91c649c5c4ec5006c7", 354 | "sha256:df5828871e6648db72d1c19b4bd24819b80a755c4541d3409f0f7acd0f335c80", 355 | "sha256:ecdf1a604009bd35c674b9225a8fa609e0282d9b896c03dd441a91e5f53b534e", 356 | "sha256:efa08d63ef03d079dcae1dfe334f6c8847ba8b645d08df286358b1f5293d24ab", 357 | "sha256:f01da5790e95815eb5a8a138508c01c758e5f5bc0ce4286c4f7028b8dd7ac3d0", 358 | "sha256:f34019dced51047d6f70cb9383b2ae2853b7fc4dce65129a5acd49f4f9256646", 359 | "sha256:f6d3d39611ac2e4f62c3128a9eed45f19a6608670c5a2f4f07f24e8de3441d38" 360 | ], 361 | "markers": "python_version < '3.12' and platform_python_implementation == 'CPython'", 362 | "version": "==0.2.7" 363 | }, 364 | "sqlparse": { 365 | "hashes": [ 366 | "sha256:5430a4fe2ac7d0f93e66f1efc6e1338a41884b7ddf2a350cedd20ccc4d9d28f3", 367 | "sha256:d446183e84b8349fa3061f0fe7f06ca94ba65b426946ffebe6e3e8295332420c" 368 | ], 369 | "markers": "python_version >= '3.5'", 370 | "version": "==0.4.4" 371 | }, 372 | "typing-extensions": { 373 | "hashes": [ 374 | "sha256:6ad00b63f849b7dcc313b70b6b304ed67b2b2963b3098a33efe18056b1a9a223", 375 | "sha256:ff6b238610c747e44c268aa4bb23c8c735d665a63726df3f9431ce707f2aa768" 376 | ], 377 | "markers": "python_version < '3.11'", 378 | "version": "==4.6.0" 379 | }, 380 | "uritemplate": { 381 | "hashes": [ 382 | "sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0", 383 | "sha256:830c08b8d99bdd312ea4ead05994a38e8936266f84b9a7878232db50b044e02e" 384 | ], 385 | "markers": "python_version >= '3.6'", 386 | "version": "==4.1.1" 387 | }, 388 | "urllib3": { 389 | "hashes": [ 390 | "sha256:61717a1095d7e155cdb737ac7bb2f4324a858a1e2e6466f6d03ff630ca68d3cc", 391 | "sha256:d055c2f9d38dc53c808f6fdc8eab7360b6fdbbde02340ed25cfbcd817c62469e" 392 | ], 393 | "markers": "python_version >= '3.7'", 394 | "version": "==2.0.2" 395 | }, 396 | "zipp": { 397 | "hashes": [ 398 | "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b", 399 | "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556" 400 | ], 401 | "markers": "python_version >= '3.7'", 402 | "version": "==3.15.0" 403 | } 404 | }, 405 | "develop": {} 406 | } 407 | -------------------------------------------------------------------------------- /drf_api_sample/db.sqlite3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lingster/drf-api-tracking/e52c365e2e041287dbf4d26907771c289e9c6949/drf_api_sample/db.sqlite3 -------------------------------------------------------------------------------- /drf_api_sample/drf_api_sample/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lingster/drf-api-tracking/e52c365e2e041287dbf4d26907771c289e9c6949/drf_api_sample/drf_api_sample/__init__.py -------------------------------------------------------------------------------- /drf_api_sample/drf_api_sample/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for drf_api_sample project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.0/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "drf_api_sample.settings") 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /drf_api_sample/drf_api_sample/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for drf_api_sample project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.0.3. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.0/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/3.0/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = "bfjw=e4sacv4+yebr@9ta+c8b4m%vgq(rc5a4_p*^pl+@i^)-l" 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | "drf_yasg", 35 | "rest_framework", 36 | "django.contrib.admin", 37 | "django.contrib.auth", 38 | "django.contrib.contenttypes", 39 | "django.contrib.sessions", 40 | "django.contrib.messages", 41 | "django.contrib.staticfiles", 42 | "drfapi.apps.DrfapiConfig", 43 | "rest_framework_tracking", # drf_api_tracking 44 | ] 45 | 46 | MIDDLEWARE = [ 47 | "django.middleware.security.SecurityMiddleware", 48 | "django.contrib.sessions.middleware.SessionMiddleware", 49 | "django.middleware.common.CommonMiddleware", 50 | "django.middleware.csrf.CsrfViewMiddleware", 51 | "django.contrib.auth.middleware.AuthenticationMiddleware", 52 | "django.contrib.messages.middleware.MessageMiddleware", 53 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 54 | ] 55 | 56 | ROOT_URLCONF = "drf_api_sample.urls" 57 | 58 | TEMPLATES = [ 59 | { 60 | "BACKEND": "django.template.backends.django.DjangoTemplates", 61 | "DIRS": [], 62 | "APP_DIRS": True, 63 | "OPTIONS": { 64 | "context_processors": [ 65 | "django.template.context_processors.debug", 66 | "django.template.context_processors.request", 67 | "django.contrib.auth.context_processors.auth", 68 | "django.contrib.messages.context_processors.messages", 69 | ], 70 | }, 71 | }, 72 | ] 73 | 74 | WSGI_APPLICATION = "drf_api_sample.wsgi.application" 75 | 76 | 77 | # Database 78 | # https://docs.djangoproject.com/en/3.0/ref/settings/#databases 79 | 80 | DATABASES = { 81 | "default": { 82 | "ENGINE": "django.db.backends.sqlite3", 83 | "NAME": os.path.join(BASE_DIR, "db.sqlite3"), 84 | } 85 | } 86 | 87 | 88 | # Password validation 89 | # https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators 90 | 91 | AUTH_PASSWORD_VALIDATORS = [ 92 | { 93 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 94 | }, 95 | { 96 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 97 | }, 98 | { 99 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 100 | }, 101 | { 102 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 103 | }, 104 | ] 105 | 106 | 107 | # Internationalization 108 | # https://docs.djangoproject.com/en/3.0/topics/i18n/ 109 | 110 | LANGUAGE_CODE = "en-us" 111 | 112 | TIME_ZONE = "UTC" 113 | 114 | USE_I18N = True 115 | 116 | USE_L10N = True 117 | 118 | USE_TZ = True 119 | 120 | 121 | # Static files (CSS, JavaScript, Images) 122 | # https://docs.djangoproject.com/en/3.0/howto/static-files/ 123 | 124 | STATIC_URL = "/static/" 125 | 126 | REST_FRAMEWORK = { 127 | # Use Django's standard `django.contrib.auth` permissions, 128 | # or allow read-only access for unauthenticated users. 129 | "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly"] 130 | } 131 | -------------------------------------------------------------------------------- /drf_api_sample/drf_api_sample/urls.py: -------------------------------------------------------------------------------- 1 | """drf_api_sample URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/3.0/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.contrib import admin 17 | from django.urls import path, include, re_path 18 | from drf_yasg import openapi 19 | from drf_yasg.views import get_schema_view 20 | from drfapi.models import UserViewSet 21 | 22 | # Routers provide an easy way of automatically determining the URL conf. 23 | from rest_framework import routers, permissions 24 | 25 | router = routers.DefaultRouter() 26 | router.register(r"users", UserViewSet) 27 | 28 | schema_view = get_schema_view( 29 | openapi.Info( 30 | title="drf sample API", 31 | default_version="v1", 32 | description="Test description", 33 | terms_of_service="https://www.google.com/policies/terms/", 34 | contact=openapi.Contact(email="ling.li@intellicharge.co.uk"), 35 | license=openapi.License(name="MIT License"), 36 | ), 37 | public=True, 38 | permission_classes=(permissions.AllowAny,), 39 | ) 40 | 41 | urlpatterns = [ 42 | path("admin/", admin.site.urls), 43 | path(r"api-auth/", include("rest_framework.urls")), 44 | path(r"api/", include(router.urls)), 45 | re_path(r"^swagger(?P\.json|\.yaml)$", schema_view.without_ui(cache_timeout=0), name="schema-json"), 46 | re_path(r"^swagger/$", schema_view.with_ui("swagger", cache_timeout=0), name="schema-swagger-ui"), 47 | re_path(r"^redoc/$", schema_view.with_ui("redoc", cache_timeout=0), name="schema-redoc"), 48 | ] 49 | -------------------------------------------------------------------------------- /drf_api_sample/drf_api_sample/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for drf_api_sample project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.0/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "drf_api_sample.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /drf_api_sample/drfapi/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lingster/drf-api-tracking/e52c365e2e041287dbf4d26907771c289e9c6949/drf_api_sample/drfapi/__init__.py -------------------------------------------------------------------------------- /drf_api_sample/drfapi/admin.py: -------------------------------------------------------------------------------- 1 | # Register your models here. 2 | -------------------------------------------------------------------------------- /drf_api_sample/drfapi/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DrfapiConfig(AppConfig): 5 | name = "drfapi" 6 | -------------------------------------------------------------------------------- /drf_api_sample/drfapi/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lingster/drf-api-tracking/e52c365e2e041287dbf4d26907771c289e9c6949/drf_api_sample/drfapi/migrations/__init__.py -------------------------------------------------------------------------------- /drf_api_sample/drfapi/models.py: -------------------------------------------------------------------------------- 1 | # Create your models here. 2 | 3 | from django.contrib.auth.models import User 4 | from rest_framework import serializers, viewsets 5 | from rest_framework_tracking.mixins import LoggingMixin 6 | 7 | 8 | # Serializers define the API representation. 9 | class UserSerializer(serializers.HyperlinkedModelSerializer): 10 | class Meta: 11 | model = User 12 | fields = ["url", "username", "email", "is_staff"] 13 | 14 | 15 | # ViewSets define the view behavior. 16 | class UserViewSet(LoggingMixin, viewsets.ModelViewSet): 17 | queryset = User.objects.all() 18 | serializer_class = UserSerializer 19 | -------------------------------------------------------------------------------- /drf_api_sample/drfapi/tests.py: -------------------------------------------------------------------------------- 1 | # Create your tests here. 2 | -------------------------------------------------------------------------------- /drf_api_sample/drfapi/views.py: -------------------------------------------------------------------------------- 1 | # Create your views here. 2 | -------------------------------------------------------------------------------- /drf_api_sample/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "drf_api_sample.settings") 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError as exc: 12 | raise ImportError( 13 | "Couldn't import Django. Are you sure it's installed and " 14 | "available on your PYTHONPATH environment variable? Did you " 15 | "forget to activate a virtual environment?" 16 | ) from exc 17 | execute_from_command_line(sys.argv) 18 | 19 | 20 | if __name__ == "__main__": 21 | main() 22 | -------------------------------------------------------------------------------- /drf_api_sample/readme.md: -------------------------------------------------------------------------------- 1 | DRF API Sample 2 | ============== 3 | A simple playground for messing around with REST APIs and for testing drf-api-tracking 4 | 5 | 1) create venv: `python3 -m venv ~/venv/drf_sample` 6 | 2) enable venv: `source ~/venv/drf_sample/bin/activate` 7 | 3) install pip/pipenv: `python3 -m pip install --upgrade pip pipenv` 8 | 4) `pipenv install` 9 | 10 | 5) create db: 11 | `python manage.py migrate` 12 | 6) create admin account 13 | `python manage.py createsuperuser` 14 | 15 | 7) drf_yasg / swagger ui has been included as part of this sample, so you can execute REST api calls via: 16 | `python manage.py runserver localhost:8000/swagger` 17 | 18 | 8) check in admin view that the log has been added after call to your REST api: 19 | `http://localhost:8000/admin/rest_framework_tracking/` 20 | 21 | Development: 22 | ------ 23 | To install drf_api_tracking for development use: 24 | 25 | `pipenv install -e ../../drf-api-tracking` 26 | 27 | Note this is already configured in the Pipfile 28 | 29 | 30 | If you then make changes to the models in the drf-api-tracking library, then make sure you run: 31 | `python manage.py makemigrations` 32 | and ensure that the migrations file(s) are checked into git and PR made. 33 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: drf-api-tracking 2 | site_description: Utils to log Django Rest Framework requests to the database 3 | repo_url: https://github.com/lingster/drf-api-tracking 4 | site_dir: html 5 | extra_css: 6 | - css/extra.css 7 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Minimum Django and REST framework version 2 | Django>=3.1 3 | djangorestframework>=3.5 4 | six>=1.14.0 5 | 6 | # Test requirements 7 | pytest-django 8 | django-environ 9 | pytest>=3.6 10 | pytest-cov>=2.6 11 | coveralls 12 | flaky 13 | flake8 14 | tox 15 | drf-yasg>=1.20 16 | 17 | # wheel for PyPI installs 18 | wheel 19 | 20 | mock 21 | -------------------------------------------------------------------------------- /rest_framework_tracking/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.8.4" 2 | import django 3 | 4 | if django.VERSION < (3, 2): 5 | default_app_config = "rest_framework_tracking.apps.RestFrameworkTrackingConfig" 6 | -------------------------------------------------------------------------------- /rest_framework_tracking/admin.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from django.contrib import admin 4 | from django.db.models import Count 5 | from django.db.models.functions import TruncDay 6 | from django.urls import path 7 | from django.http import JsonResponse 8 | 9 | from .app_settings import app_settings 10 | from .models import APIRequestLog 11 | 12 | 13 | class APIRequestLogAdmin(admin.ModelAdmin): 14 | date_hierarchy = "requested_at" 15 | list_display = ( 16 | "id", 17 | "requested_at", 18 | "response_ms", 19 | "status_code", 20 | "user", 21 | "view_method", 22 | "path", 23 | "remote_addr", 24 | "host", 25 | "query_params", 26 | ) 27 | ordering = ("-requested_at",) 28 | list_filter = ("view_method", "status_code") 29 | search_fields = ( 30 | "path", 31 | f"user__{app_settings.LOOKUP_FIELD}", 32 | ) 33 | raw_id_fields = ("user",) 34 | 35 | if app_settings.ADMIN_LOG_READONLY: 36 | readonly_fields = ( 37 | "user", 38 | "username_persistent", 39 | "requested_at", 40 | "response_ms", 41 | "path", 42 | "view", 43 | "view_method", 44 | "remote_addr", 45 | "host", 46 | "method", 47 | "query_params", 48 | "data", 49 | "response", 50 | "errors", 51 | "status_code", 52 | "user_agent", 53 | ) 54 | 55 | def changelist_view(self, request, extra_context=None): 56 | # Aggregate api logs per day 57 | chart_data = ( 58 | APIRequestLog.objects.annotate(date=TruncDay("requested_at")) 59 | .values("date") 60 | .annotate(y=Count("id")) 61 | .order_by("-date") 62 | ) 63 | 64 | extra_context = extra_context or {"chart_data": list(chart_data)} 65 | 66 | # Call the superclass changelist_view to render the page 67 | return super().changelist_view(request, extra_context=extra_context) 68 | 69 | def get_urls(self): 70 | urls = super().get_urls() 71 | extra_urls = [path("chart_data/", self.admin_site.admin_view(self.chart_data_endpoint))] 72 | return extra_urls + urls 73 | 74 | # JSON endpoint for generating chart data that is used for dynamic loading 75 | # via JS. 76 | def chart_data_endpoint(self, request): 77 | start_date = request.GET.get("start_date") 78 | end_date = request.GET.get("end_date") 79 | 80 | # convert start_date and end_date to datetime objects 81 | start_date = datetime.datetime.strptime(start_date, "%Y-%m-%d").date() 82 | end_date = datetime.datetime.strptime(end_date, "%Y-%m-%d").date() 83 | 84 | chart_data = self.chart_data(start_date, end_date) 85 | return JsonResponse(list(chart_data), safe=False) 86 | 87 | def chart_data(self, start_date, end_date): 88 | return ( 89 | APIRequestLog.objects.filter(requested_at__date__gte=start_date, requested_at__date__lte=end_date) 90 | .annotate(date=TruncDay("requested_at")) 91 | .values("date") 92 | .annotate(y=Count("id")) 93 | .order_by("-date") 94 | ) 95 | 96 | 97 | admin.site.register(APIRequestLog, APIRequestLogAdmin) 98 | -------------------------------------------------------------------------------- /rest_framework_tracking/app_settings.py: -------------------------------------------------------------------------------- 1 | class AppSettings(object): 2 | def __init__(self, prefix): 3 | self.prefix = prefix 4 | 5 | def _setting(self, name, dflt): 6 | from django.conf import settings 7 | 8 | return getattr(settings, self.prefix + name, dflt) 9 | 10 | @property 11 | def ADMIN_LOG_READONLY(self): 12 | """Prevent log entries from being modified from Django admin.""" 13 | return self._setting("ADMIN_LOG_READONLY", False) 14 | 15 | @property 16 | def DECODE_REQUEST_BODY(self): 17 | """ 18 | Allow the request.body byte string to be decoded to a string. 19 | 20 | If you are allowing large file uploads, setting this to False prevents 21 | a RequestDataTooBig exception. 22 | """ 23 | return self._setting("DECODE_REQUEST_BODY", True) 24 | 25 | @property 26 | def PATH_LENGTH(self): 27 | """Maximum length of request path to log""" 28 | return self._setting("PATH_LENGTH", 200) 29 | 30 | @property 31 | def LOOKUP_FIELD(self): 32 | """Field to identify user in User model""" 33 | return self._setting("LOOKUP_FIELD", "email") 34 | 35 | 36 | app_settings = AppSettings("DRF_TRACKING_") 37 | -------------------------------------------------------------------------------- /rest_framework_tracking/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class RestFrameworkTrackingConfig(AppConfig): 5 | name = "rest_framework_tracking" 6 | verbose_name = "REST Framework Tracking" 7 | -------------------------------------------------------------------------------- /rest_framework_tracking/base_mixins.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import ipaddress 3 | import logging 4 | import traceback 5 | 6 | from django.db import connection 7 | from django.utils.timezone import now 8 | 9 | from .app_settings import app_settings 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | class BaseLoggingMixin(object): 15 | """Mixin to log requests""" 16 | 17 | CLEANED_SUBSTITUTE = "********************" 18 | 19 | logging_methods = "__all__" 20 | sensitive_fields = {} 21 | 22 | def __init__(self, *args, **kwargs): 23 | assert isinstance(self.CLEANED_SUBSTITUTE, str), "CLEANED_SUBSTITUTE must be a string." 24 | super(BaseLoggingMixin, self).__init__(*args, **kwargs) 25 | 26 | def initial(self, request, *args, **kwargs): 27 | self.log = {"requested_at": now()} 28 | self.log["data"] = ( 29 | self._clean_data(request.body) 30 | if getattr(self, "decode_request_body", app_settings.DECODE_REQUEST_BODY) 31 | else "" 32 | ) 33 | 34 | super(BaseLoggingMixin, self).initial(request, *args, **kwargs) 35 | 36 | try: 37 | # Accessing request.data *for the first time* parses the request body, which may raise 38 | # ParseError and UnsupportedMediaType exceptions. It's important not to swallow these, 39 | # as (depending on implementation details) they may only get raised this once, and 40 | # DRF logic needs them to be raised by the view for error handling to work correctly. 41 | data = self.request.data.dict() 42 | except AttributeError: 43 | data = self.request.data 44 | self.log["data"] = self._clean_data(data) 45 | 46 | def handle_exception(self, exc): 47 | response = super(BaseLoggingMixin, self).handle_exception(exc) 48 | self.log["errors"] = traceback.format_exc() 49 | 50 | return response 51 | 52 | def finalize_response(self, request, response, *args, **kwargs): 53 | response = super(BaseLoggingMixin, self).finalize_response(request, response, *args, **kwargs) 54 | 55 | # Ensure backward compatibility for those using _should_log hook 56 | should_log = self._should_log if hasattr(self, "_should_log") else self.should_log 57 | 58 | if should_log(request, response): 59 | if (connection.settings_dict.get("ATOMIC_REQUESTS") and getattr(response, "exception", None) and connection.in_atomic_block): 60 | # response with exception (HTTP status like: 401, 404, etc) 61 | # pointwise disable atomic block for handle log (TransactionManagementError) 62 | connection.set_rollback(True) 63 | connection.set_rollback(False) 64 | if response.streaming: 65 | rendered_content = None 66 | elif hasattr(response, "rendered_content"): 67 | rendered_content = response.rendered_content 68 | else: 69 | rendered_content = response.getvalue() 70 | 71 | user = self._get_user(request) 72 | 73 | self.log.update( 74 | { 75 | "remote_addr": self._get_ip_address(request), 76 | "view": self._get_view_name(request), 77 | "view_method": self._get_view_method(request), 78 | "path": self._get_path(request), 79 | "host": request.get_host(), 80 | "user_agent": request.META.get("HTTP_USER_AGENT", ""), 81 | "method": request.method, 82 | "query_params": self._clean_data(request.query_params.dict()), 83 | "user": user, 84 | "username_persistent": user.get_username() if user else "Anonymous", 85 | "response_ms": self._get_response_ms(), 86 | "response": self._clean_data(rendered_content), 87 | "status_code": response.status_code, 88 | } 89 | ) 90 | if self._clean_data(request.query_params.dict()) == {}: 91 | self.log.update({"query_params": self.log["data"]}) 92 | try: 93 | self.handle_log() 94 | except Exception: 95 | # ensure that all exceptions raised by handle_log 96 | # doesn't prevent API call to continue as expected 97 | logger.exception("Logging API call raise exception!") 98 | return response 99 | 100 | def handle_log(self): 101 | """ 102 | Hook to define what happens with the log. 103 | 104 | Defaults on saving the data on the db. 105 | """ 106 | raise NotImplementedError 107 | 108 | def _get_path(self, request): 109 | """Get the request path and truncate it""" 110 | return request.path[: app_settings.PATH_LENGTH] 111 | 112 | def _get_ip_address(self, request): 113 | """Get the remote ip address the request was generated from.""" 114 | ipaddr = request.META.get("HTTP_X_FORWARDED_FOR", None) 115 | if ipaddr: 116 | ipaddr = ipaddr.split(",")[0] 117 | else: 118 | ipaddr = request.META.get("REMOTE_ADDR", "").split(",")[0] 119 | 120 | # Account for IPv4 and IPv6 addresses, each possibly with port appended. Possibilities are: 121 | # 122 | # 123 | # :port 124 | # []:port 125 | # Note that ipv6 addresses are colon separated hex numbers 126 | possibles = (ipaddr.lstrip("[").split("]")[0], ipaddr.split(":")[0]) 127 | 128 | for addr in possibles: 129 | try: 130 | return str(ipaddress.ip_address(addr)) 131 | except ValueError: 132 | pass 133 | 134 | return ipaddr 135 | 136 | def _get_view_name(self, request): 137 | """Get view name.""" 138 | method = request.method.lower() 139 | try: 140 | attributes = getattr(self, method) 141 | return f"{type(attributes.__self__).__module__}.{type(attributes.__self__).__name__}" 142 | 143 | except AttributeError: 144 | return None 145 | 146 | def _get_view_method(self, request): 147 | """Get view method.""" 148 | if hasattr(self, "action"): 149 | return self.action or None 150 | return request.method.lower() 151 | 152 | def _get_user(self, request): 153 | """Get user.""" 154 | user = request.user 155 | if user.is_anonymous: 156 | return None 157 | return user 158 | 159 | def _get_response_ms(self): 160 | """ 161 | Get the duration of the request response cycle is milliseconds. 162 | In case of negative duration 0 is returned. 163 | """ 164 | response_timedelta = now() - self.log["requested_at"] 165 | response_ms = int(response_timedelta.total_seconds() * 1000) 166 | return max(response_ms, 0) 167 | 168 | def should_log(self, request, response): 169 | """ 170 | Method that should return a value that evaluated to True if the request should be logged. 171 | By default, check if the request method is in logging_methods. 172 | """ 173 | return self.logging_methods == "__all__" or request.method in self.logging_methods 174 | 175 | def _clean_data(self, data): 176 | """ 177 | Clean a dictionary of data of potentially sensitive info before 178 | sending to the database. 179 | Function based on the "_clean_credentials" function of django 180 | (https://github.com/django/django/blob/stable/1.11.x/django/contrib/auth/__init__.py#L50) 181 | 182 | Fields defined by django are by default cleaned with this function 183 | 184 | You can define your own sensitive fields in your view by defining a set 185 | eg: sensitive_fields = {'field1', 'field2'} 186 | """ 187 | if isinstance(data, bytes): 188 | data = data.decode(errors="replace") 189 | 190 | if isinstance(data, list): 191 | return [self._clean_data(d) for d in data] 192 | 193 | if isinstance(data, dict): 194 | SENSITIVE_FIELDS = { 195 | "api", 196 | "token", 197 | "key", 198 | "secret", 199 | "password", 200 | "signature", 201 | } 202 | 203 | data = dict(data) 204 | if self.sensitive_fields: 205 | SENSITIVE_FIELDS = SENSITIVE_FIELDS | {field.lower() for field in self.sensitive_fields} 206 | 207 | for key, value in data.items(): 208 | try: 209 | value = ast.literal_eval(value) 210 | except (ValueError, SyntaxError): 211 | pass 212 | if isinstance(value, (list, dict)): 213 | data[key] = self._clean_data(value) 214 | if key.lower() in SENSITIVE_FIELDS: 215 | data[key] = self.CLEANED_SUBSTITUTE 216 | return data 217 | -------------------------------------------------------------------------------- /rest_framework_tracking/base_models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from django.db import models 3 | from django.conf import settings 4 | 5 | from .managers import PrefetchUserManager 6 | 7 | 8 | class BaseAPIRequestLog(models.Model): 9 | """Logs Django rest framework API requests""" 10 | 11 | user = models.ForeignKey( 12 | settings.AUTH_USER_MODEL, 13 | on_delete=models.SET_NULL, 14 | null=True, 15 | blank=True, 16 | ) 17 | username_persistent = models.CharField( 18 | max_length=getattr(settings, "DRF_TRACKING_USERNAME_LENGTH", 200), 19 | null=True, 20 | blank=True, 21 | ) 22 | requested_at = models.DateTimeField(default=datetime.now, db_index=True) 23 | response_ms = models.PositiveIntegerField(default=0) 24 | path = models.CharField( 25 | max_length=getattr(settings, "DRF_TRACKING_PATH_LENGTH", 200), 26 | db_index=True, 27 | help_text="url path", 28 | ) 29 | view = models.CharField( 30 | max_length=getattr(settings, "DRF_TRACKING_VIEW_LENGTH", 200), 31 | null=True, 32 | blank=True, 33 | db_index=True, 34 | help_text="method called by this endpoint", 35 | ) 36 | view_method = models.CharField( 37 | max_length=getattr(settings, "DRF_TRACKING_VIEW_METHOD_LENGTH", 200), 38 | null=True, 39 | blank=True, 40 | db_index=True, 41 | ) 42 | remote_addr = models.GenericIPAddressField(null=True, blank=True) 43 | host = models.URLField() 44 | method = models.CharField(max_length=10) 45 | user_agent = models.CharField(max_length=255, blank=True) 46 | query_params = models.TextField(null=True, blank=True) 47 | data = models.TextField(null=True, blank=True) 48 | response = models.TextField(null=True, blank=True) 49 | errors = models.TextField(null=True, blank=True) 50 | status_code = models.PositiveIntegerField(null=True, blank=True, db_index=True) 51 | objects = PrefetchUserManager() 52 | 53 | class Meta: 54 | abstract = True 55 | verbose_name = "API Request Log" 56 | 57 | def __str__(self): 58 | return f"{self.method} {self.path}" 59 | -------------------------------------------------------------------------------- /rest_framework_tracking/management/commands/clearapilogs.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | from rest_framework_tracking.models import APIRequestLog 3 | import datetime 4 | from django.utils import timezone 5 | 6 | 7 | class Command(BaseCommand): 8 | help = "Removes all api logs OR keeps X number of days of api logs" 9 | 10 | def add_arguments(self, parser): 11 | parser.add_argument( 12 | "--days_num", 13 | help="Keep X number of days of logs and delete the rest", 14 | type=int, 15 | choices=[x + 1 for x in range(100000)], 16 | ) 17 | 18 | def handle(self, *args, **options): 19 | days_num = options["days_num"] 20 | 21 | if days_num: 22 | today = timezone.now() 23 | start_date = today - datetime.timedelta(days=days_num) 24 | logs_to_delete = APIRequestLog.objects.filter(requested_at__lt=start_date) 25 | 26 | else: 27 | logs_to_delete = APIRequestLog.objects.all() 28 | 29 | deleted_logs_count = logs_to_delete.count() 30 | logs_to_delete.delete() 31 | 32 | if deleted_logs_count: 33 | success_message = ( 34 | f'Successfully removed {deleted_logs_count} api log{"s" if deleted_logs_count > 1 else ""}' 35 | ) 36 | else: 37 | success_message = "No logs to delete" 38 | 39 | self.stdout.write(self.style.SUCCESS(success_message)) 40 | -------------------------------------------------------------------------------- /rest_framework_tracking/managers.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class PrefetchUserManager(models.Manager): 5 | def get_queryset(self): 6 | return super(PrefetchUserManager, self).get_queryset().select_related("user") 7 | -------------------------------------------------------------------------------- /rest_framework_tracking/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9 on 2016-09-07 10:17 3 | from __future__ import unicode_literals 4 | 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | import django.db.models.deletion 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | initial = True 13 | 14 | dependencies = [ 15 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 16 | ] 17 | 18 | operations = [ 19 | migrations.CreateModel( 20 | name="APIRequestLog", 21 | fields=[ 22 | ( 23 | "id", 24 | models.AutoField( 25 | auto_created=True, 26 | primary_key=True, 27 | serialize=False, 28 | verbose_name="ID", 29 | ), 30 | ), 31 | ("requested_at", models.DateTimeField(db_index=True)), 32 | ("response_ms", models.PositiveIntegerField(default=0)), 33 | ("path", models.CharField(db_index=True, max_length=200)), 34 | ("remote_addr", models.GenericIPAddressField()), 35 | ("host", models.URLField()), 36 | ("method", models.CharField(max_length=10)), 37 | ("query_params", models.TextField(blank=True, null=True)), 38 | ("data", models.TextField(blank=True, null=True)), 39 | ("response", models.TextField(blank=True, null=True)), 40 | ("status_code", models.PositiveIntegerField(blank=True, null=True)), 41 | ( 42 | "user", 43 | models.ForeignKey( 44 | blank=True, 45 | null=True, 46 | on_delete=django.db.models.deletion.CASCADE, 47 | to=settings.AUTH_USER_MODEL, 48 | ), 49 | ), 50 | ], 51 | options={ 52 | "abstract": False, 53 | }, 54 | ), 55 | ] 56 | -------------------------------------------------------------------------------- /rest_framework_tracking/migrations/0002_auto_20170118_1713.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.5 on 2017-01-18 16:13 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ("rest_framework_tracking", "0001_initial"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name="apirequestlog", 17 | name="view", 18 | field=models.CharField(db_index=True, default="", max_length=200), 19 | preserve_default=False, 20 | ), 21 | migrations.AddField( 22 | model_name="apirequestlog", 23 | name="view_method", 24 | field=models.CharField(db_index=True, default="", max_length=200), 25 | preserve_default=False, 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /rest_framework_tracking/migrations/0003_add_errors.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("rest_framework_tracking", "0002_auto_20170118_1713"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | "APIRequestLog", 16 | "errors", 17 | models.TextField(null=True, blank=True), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /rest_framework_tracking/migrations/0004_add_verbose_name.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.5 on 2017-01-26 22:10 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ("rest_framework_tracking", "0003_add_errors"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterModelOptions( 16 | name="apirequestlog", 17 | options={"verbose_name": "API Request Log"}, 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /rest_framework_tracking/migrations/0005_auto_20171219_1537.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.5 on 2017-12-19 14:37 3 | from __future__ import unicode_literals 4 | 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | import django.db.models.deletion 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [ 13 | ("rest_framework_tracking", "0004_add_verbose_name"), 14 | ] 15 | 16 | operations = [ 17 | migrations.AlterField( 18 | model_name="apirequestlog", 19 | name="user", 20 | field=models.ForeignKey( 21 | blank=True, 22 | null=True, 23 | on_delete=django.db.models.deletion.PROTECT, 24 | to=settings.AUTH_USER_MODEL, 25 | ), 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /rest_framework_tracking/migrations/0006_auto_20180315_1442.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.11 on 2018-03-15 14:42 3 | from __future__ import unicode_literals 4 | 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | import django.db.models.deletion 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [ 13 | ("rest_framework_tracking", "0005_auto_20171219_1537"), 14 | ] 15 | 16 | operations = [ 17 | migrations.AlterField( 18 | model_name="apirequestlog", 19 | name="user", 20 | field=models.ForeignKey( 21 | blank=True, 22 | null=True, 23 | on_delete=django.db.models.deletion.SET_NULL, 24 | to=settings.AUTH_USER_MODEL, 25 | ), 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /rest_framework_tracking/migrations/0006_view_and_view_method_nullable.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.11 on 2018-03-21 03:55 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ("rest_framework_tracking", "0005_auto_20171219_1537"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name="apirequestlog", 17 | name="view", 18 | field=models.CharField( 19 | db_index=True, max_length=200, blank=True, null=True 20 | ), 21 | ), 22 | migrations.AlterField( 23 | model_name="apirequestlog", 24 | name="view_method", 25 | field=models.CharField(db_index=True, max_length=27, blank=True, null=True), 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /rest_framework_tracking/migrations/0007_merge_20180419_1646.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 2.0.3 on 2018-04-19 16:46 3 | 4 | from django.db import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("rest_framework_tracking", "0006_auto_20180315_1442"), 11 | ("rest_framework_tracking", "0006_view_and_view_method_nullable"), 12 | ] 13 | 14 | operations = [] 15 | -------------------------------------------------------------------------------- /rest_framework_tracking/migrations/0008_auto_20200201_2048.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 3.0.2 on 2020-02-01 20:48 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ("rest_framework_tracking", "0007_merge_20180419_1646"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name="apirequestlog", 17 | name="username_persistent", 18 | field=models.CharField(blank=True, default="", max_length=200, null=True), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /rest_framework_tracking/migrations/0009_view_method_max_length_200.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.28 on 2020-05-27 20:27 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ("rest_framework_tracking", "0008_auto_20200201_2048"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name="apirequestlog", 17 | name="view_method", 18 | field=models.CharField( 19 | blank=True, db_index=True, max_length=200, null=True 20 | ), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /rest_framework_tracking/migrations/0010_auto_20200609_1404.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.6 on 2020-06-09 14:04 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("rest_framework_tracking", "0009_view_method_max_length_200"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="apirequestlog", 15 | name="path", 16 | field=models.CharField(db_index=True, help_text="url path", max_length=200), 17 | ), 18 | migrations.AlterField( 19 | model_name="apirequestlog", 20 | name="username_persistent", 21 | field=models.CharField(blank=True, max_length=200, null=True), 22 | ), 23 | migrations.AlterField( 24 | model_name="apirequestlog", 25 | name="view", 26 | field=models.CharField( 27 | blank=True, 28 | db_index=True, 29 | help_text="method called by this endpoint", 30 | max_length=200, 31 | null=True, 32 | ), 33 | ), 34 | ] 35 | -------------------------------------------------------------------------------- /rest_framework_tracking/migrations/0011_auto_20201117_2016.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.15 on 2020-11-17 20:16 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("rest_framework_tracking", "0010_auto_20200609_1404"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="apirequestlog", 15 | name="status_code", 16 | field=models.PositiveIntegerField(blank=True, db_index=True, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /rest_framework_tracking/migrations/0012_auto_20210930_0713.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.7 on 2021-09-30 07:13 2 | 3 | import datetime 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('rest_framework_tracking', '0011_auto_20201117_2016'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='apirequestlog', 16 | name='remote_addr', 17 | field=models.GenericIPAddressField(blank=True, null=True), 18 | ), 19 | migrations.AlterField( 20 | model_name='apirequestlog', 21 | name='requested_at', 22 | field=models.DateTimeField(db_index=True, default=datetime.datetime.now), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /rest_framework_tracking/migrations/0013_apirequestlog_user_agent.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.1 on 2023-05-31 19:14 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('rest_framework_tracking', '0012_auto_20210930_0713'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='apirequestlog', 15 | name='user_agent', 16 | field=models.CharField(blank=True, max_length=255), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /rest_framework_tracking/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lingster/drf-api-tracking/e52c365e2e041287dbf4d26907771c289e9c6949/rest_framework_tracking/migrations/__init__.py -------------------------------------------------------------------------------- /rest_framework_tracking/mixins.py: -------------------------------------------------------------------------------- 1 | from .base_mixins import BaseLoggingMixin 2 | from .models import APIRequestLog 3 | 4 | 5 | class LoggingMixin(BaseLoggingMixin): 6 | def handle_log(self): 7 | """ 8 | Hook to define what happens with the log. 9 | 10 | Defaults on saving the data on the db. 11 | """ 12 | APIRequestLog(**self.log).save() 13 | 14 | 15 | class LoggingErrorsMixin(LoggingMixin): 16 | """ 17 | Log only errors 18 | """ 19 | 20 | def should_log(self, request, response): 21 | return response.status_code >= 400 22 | -------------------------------------------------------------------------------- /rest_framework_tracking/models.py: -------------------------------------------------------------------------------- 1 | from .base_models import BaseAPIRequestLog 2 | 3 | 4 | class APIRequestLog(BaseAPIRequestLog): 5 | pass 6 | -------------------------------------------------------------------------------- /rest_framework_tracking/templates/admin/rest_framework_tracking/apirequestlog/change_list.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_list.html" %} 2 | {% load static %} 3 | 4 | 5 | {% block extrahead %} 6 | {{ block.super }} 7 | 8 | 9 | 14 | 15 | 16 | 17 | 18 | 105 | 106 | {{ chart_data|json_script:"chartData" }} 107 | 108 | {% endblock %} 109 | 110 | {% block content %} 111 | 112 |
114 |   115 | 116 |
117 | 118 |
119 | 120 |
121 | 122 | {{ block.super }} 123 | {% endblock %} 124 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | # extra long line-length 2 | line-length = 140 3 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | from __future__ import print_function 3 | 4 | import pytest 5 | import sys 6 | import os 7 | import subprocess 8 | 9 | 10 | PYTEST_ARGS = { 11 | "default": ["tests"], 12 | "fast": ["tests", "-q"], 13 | } 14 | 15 | FLAKE8_ARGS = ["rest_framework_tracking", "tests", "--ignore=E501"] 16 | 17 | 18 | sys.path.append(os.path.dirname(__file__)) 19 | 20 | 21 | def exit_on_failure(ret, message=None): 22 | if ret: 23 | sys.exit(ret) 24 | 25 | 26 | def flake8_main(args): 27 | print("Running flake8 code linting") 28 | ret = subprocess.call(["flake8"] + args) 29 | print("flake8 failed" if ret else "flake8 passed") 30 | return ret 31 | 32 | 33 | def split_class_and_function(string): 34 | class_string, function_string = string.split(".", 1) 35 | return "%s and %s" % (class_string, function_string) 36 | 37 | 38 | def is_function(string): 39 | # `True` if it looks like a test function is included in the string. 40 | return string.startswith("test_") or ".test_" in string 41 | 42 | 43 | def is_class(string): 44 | # `True` if first character is uppercase - assume it's a class name. 45 | return string[0] == string[0].upper() 46 | 47 | 48 | if __name__ == "__main__": 49 | try: 50 | sys.argv.remove("--nolint") 51 | except ValueError: 52 | run_flake8 = True 53 | else: 54 | run_flake8 = False 55 | 56 | try: 57 | sys.argv.remove("--lintonly") 58 | except ValueError: 59 | run_tests = True 60 | else: 61 | run_tests = False 62 | 63 | try: 64 | sys.argv.remove("--fast") 65 | except ValueError: 66 | style = "default" 67 | else: 68 | style = "fast" 69 | run_flake8 = False 70 | 71 | if len(sys.argv) > 1: 72 | pytest_args = sys.argv[1:] 73 | first_arg = pytest_args[0] 74 | if first_arg.startswith("-"): 75 | # `runtests.py [flags]` 76 | pytest_args = ["tests"] + pytest_args 77 | elif is_class(first_arg) and is_function(first_arg): 78 | # `runtests.py TestCase.test_function [flags]` 79 | expression = split_class_and_function(first_arg) 80 | pytest_args = ["tests", "-k", expression] + pytest_args[1:] 81 | elif is_class(first_arg) or is_function(first_arg): 82 | # `runtests.py TestCase [flags]` 83 | # `runtests.py test_function [flags]` 84 | pytest_args = ["tests", "-k", pytest_args[0]] + pytest_args[1:] 85 | else: 86 | pytest_args = PYTEST_ARGS[style] 87 | 88 | if run_tests: 89 | exit_on_failure(pytest.main(pytest_args)) 90 | if run_flake8: 91 | exit_on_failure(flake8_main(FLAKE8_ARGS)) 92 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import re 4 | import os 5 | import sys 6 | from setuptools import setup 7 | from os import path 8 | 9 | name = "drf-api-tracking" 10 | package = "rest_framework_tracking" 11 | summary = "Utils to log Django Rest Framework requests to the database" 12 | 13 | description = "Utils to log Django Rest Framework requests to the database" 14 | 15 | description_content_type = "text/markdown; charset=UTF-8" 16 | 17 | url = "https://github.com/lingster/drf-api-tracking" 18 | author = "Anna Schneider" 19 | author_email = "anna@WattTime.org" 20 | maintainer = "Ling Li" 21 | maintainer_email = "email@ling-li.com" 22 | license = "BSD" 23 | 24 | 25 | def get_version(package): 26 | """ 27 | Return package version as listed in `__version__` in `init.py`. 28 | """ 29 | init_py = open(os.path.join(package, "__init__.py")).read() 30 | return re.search("^__version__ = ['\"]([^'\"]+)['\"]", init_py, re.MULTILINE).group(1) 31 | 32 | 33 | def get_packages(package): 34 | """ 35 | Return root package and all sub-packages. 36 | """ 37 | return [ 38 | dirpath 39 | for dirpath, dirnames, filenames in os.walk(package) 40 | if os.path.exists(os.path.join(dirpath, "__init__.py")) 41 | ] 42 | 43 | 44 | def get_package_data(package): 45 | """ 46 | Return all files under the root package, that are not in a 47 | package themselves. 48 | """ 49 | walk = [ 50 | (dirpath.replace(package + os.sep, "", 1), filenames) 51 | for dirpath, dirnames, filenames in os.walk(package) 52 | if not os.path.exists(os.path.join(dirpath, "__init__.py")) 53 | ] 54 | 55 | filepaths = [] 56 | for base, filenames in walk: 57 | filepaths.extend([os.path.join(base, filename) for filename in filenames]) 58 | return {package: filepaths} 59 | 60 | 61 | version = get_version(package) 62 | 63 | 64 | if sys.argv[-1] == "publish": 65 | if os.system("pip freeze | grep wheel"): 66 | print("wheel not installed.\nUse `pip install wheel`.\nExiting.") 67 | sys.exit() 68 | os.system("python setup.py sdist upload") 69 | os.system("python setup.py bdist_wheel upload") 70 | print("You probably want to also tag the version now:") 71 | print(" git tag -a {0} -m 'version {0}'".format(version)) 72 | print(" git push --tags") 73 | sys.exit() 74 | 75 | # read contents of readme: 76 | this_dir = path.abspath(path.dirname(__file__)) 77 | with open(path.join(this_dir, "README.md"), encoding="utf-8") as f: 78 | long_description = f.read() 79 | 80 | setup( 81 | name=name, 82 | version=os.environ.get("PACKAGE_VERSION", version), 83 | url=url, 84 | license=license, 85 | description=description, 86 | long_description=long_description, 87 | long_description_content_type=description_content_type, 88 | author=author, 89 | author_email=author_email, 90 | packages=get_packages(package), 91 | package_data=get_package_data(package), 92 | install_requires=[ 93 | "Django>=1.7", 94 | "djangorestframework>=3", 95 | "pytz", 96 | ], 97 | classifiers=[ 98 | "Development Status :: 2 - Pre-Alpha", 99 | "Environment :: Web Environment", 100 | "Framework :: Django", 101 | "Framework :: Django :: 1.11", 102 | "Framework :: Django :: 2.0", 103 | "Framework :: Django :: 2.1", 104 | "Framework :: Django :: 2.2", 105 | "Framework :: Django :: 3.0", 106 | "Intended Audience :: Developers", 107 | "License :: OSI Approved :: BSD License", 108 | "Operating System :: OS Independent", 109 | "Natural Language :: English", 110 | "Programming Language :: Python :: 3", 111 | "Programming Language :: Python :: 3.4", 112 | "Programming Language :: Python :: 3.5", 113 | "Programming Language :: Python :: 3.6", 114 | "Programming Language :: Python :: 3.7", 115 | "Programming Language :: Python :: 3.8", 116 | "Topic :: Internet :: WWW/HTTP", 117 | ], 118 | ) 119 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lingster/drf-api-tracking/e52c365e2e041287dbf4d26907771c289e9c6949/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | 3 | 4 | def pytest_configure(): 5 | from django.conf import settings 6 | import environ 7 | 8 | settings.configure( 9 | DEBUG_PROPAGATE_EXCEPTIONS=True, 10 | DATABASES={ 11 | "default": environ.Env().db(default="sqlite://") # DATABASE_URL should be specified in the environment 12 | }, 13 | SITE_ID=1, 14 | SECRET_KEY="not very secret in tests", 15 | USE_I18N=True, 16 | USE_L10N=True, 17 | STATIC_URL="/static/", 18 | ROOT_URLCONF="tests.urls", 19 | TEMPLATE_LOADERS=( 20 | "django.template.loaders.filesystem.Loader", 21 | "django.template.loaders.app_directories.Loader", 22 | ), 23 | MIDDLEWARE=( 24 | "django.middleware.common.CommonMiddleware", 25 | "django.contrib.sessions.middleware.SessionMiddleware", 26 | "django.middleware.csrf.CsrfViewMiddleware", 27 | "django.contrib.auth.middleware.AuthenticationMiddleware", 28 | "django.contrib.messages.middleware.MessageMiddleware", 29 | ), 30 | INSTALLED_APPS=( 31 | "django.contrib.auth", 32 | "django.contrib.contenttypes", 33 | "django.contrib.sessions", 34 | "django.contrib.sites", 35 | "django.contrib.messages", 36 | "django.contrib.staticfiles", 37 | "rest_framework", 38 | "rest_framework.authtoken", 39 | "tests", 40 | "rest_framework_tracking", 41 | ), 42 | PASSWORD_HASHERS=( 43 | "django.contrib.auth.hashers.SHA1PasswordHasher", 44 | "django.contrib.auth.hashers.PBKDF2PasswordHasher", 45 | "django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher", 46 | "django.contrib.auth.hashers.BCryptPasswordHasher", 47 | "django.contrib.auth.hashers.MD5PasswordHasher", 48 | "django.contrib.auth.hashers.CryptPasswordHasher", 49 | ), 50 | ) 51 | settings.DATABASES["default"]["ATOMIC_REQUESTS"] = True 52 | settings.DATABASES["default"]["AUTOCOMMIT"] = True 53 | 54 | try: 55 | import oauth_provider # NOQA 56 | import oauth2 # NOQA 57 | except ImportError: 58 | pass 59 | else: 60 | settings.INSTALLED_APPS += ("oauth_provider",) 61 | 62 | try: 63 | import provider # NOQA 64 | except ImportError: 65 | pass 66 | else: 67 | settings.INSTALLED_APPS += ( 68 | "provider", 69 | "provider.oauth2", 70 | ) 71 | 72 | # guardian is optional 73 | try: 74 | import guardian # NOQA 75 | except ImportError: 76 | pass 77 | else: 78 | settings.ANONYMOUS_USER_ID = -1 79 | settings.AUTHENTICATION_BACKENDS = ( 80 | "django.contrib.auth.backends.ModelBackend", 81 | "guardian.backends.ObjectPermissionBackend", 82 | ) 83 | settings.INSTALLED_APPS += ("guardian",) 84 | 85 | try: 86 | import django 87 | 88 | django.setup() 89 | except AttributeError: 90 | pass 91 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lingster/drf-api-tracking/e52c365e2e041287dbf4d26907771c289e9c6949/tests/models.py -------------------------------------------------------------------------------- /tests/test_mixins.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | from __future__ import absolute_import 3 | 4 | import pytest 5 | import ast 6 | import datetime 7 | from io import BytesIO 8 | import json 9 | from django.contrib.auth.models import User 10 | from django.utils.timezone import now 11 | from django.test.utils import override_settings 12 | from flaky import flaky 13 | from rest_framework import status 14 | from rest_framework.authtoken.models import Token 15 | from rest_framework.test import APIRequestFactory, APITestCase 16 | from rest_framework_tracking.mixins import BaseLoggingMixin 17 | from rest_framework_tracking.models import APIRequestLog 18 | 19 | try: 20 | import mock 21 | except Exception: 22 | from unittest import mock 23 | 24 | from .views import MockLoggingView 25 | 26 | pytestmark = pytest.mark.django_db 27 | 28 | 29 | @override_settings(ROOT_URLCONF="tests.urls") 30 | class TestLoggingMixin(APITestCase): 31 | def test_nologging_no_log_created(self): 32 | self.client.get("/no-logging") 33 | self.assertEqual(APIRequestLog.objects.all().count(), 0) 34 | 35 | def test_logging_creates_log(self): 36 | self.client.get("/logging") 37 | self.assertEqual(APIRequestLog.objects.all().count(), 1) 38 | 39 | def test_log_path(self): 40 | self.client.get("/logging") 41 | log = APIRequestLog.objects.first() 42 | self.assertEqual(log.path, "/logging") 43 | 44 | def test_log_ip_remote(self): 45 | request = APIRequestFactory().get("/logging") 46 | request.META["REMOTE_ADDR"] = "127.0.0.9" 47 | 48 | MockLoggingView.as_view()(request).render() 49 | log = APIRequestLog.objects.first() 50 | self.assertEqual(log.remote_addr, "127.0.0.9") 51 | 52 | def test_log_ip_remote_list(self): 53 | request = APIRequestFactory().get("/logging") 54 | request.META["REMOTE_ADDR"] = "127.0.0.9, 128.1.1.9" 55 | 56 | MockLoggingView.as_view()(request).render() 57 | log = APIRequestLog.objects.first() 58 | self.assertEqual(log.remote_addr, "127.0.0.9") 59 | 60 | def test_log_ip_remote_v4_with_port(self): 61 | request = APIRequestFactory().get("/logging") 62 | request.META["REMOTE_ADDR"] = "127.0.0.9:1234" 63 | 64 | MockLoggingView.as_view()(request).render() 65 | log = APIRequestLog.objects.first() 66 | self.assertEqual(log.remote_addr, "127.0.0.9") 67 | 68 | def test_log_ip_remote_v6(self): 69 | request = APIRequestFactory().get("/logging") 70 | request.META["REMOTE_ADDR"] = "2001:0db8:85a3:0000:0000:8a2e:0370:7334" 71 | 72 | MockLoggingView.as_view()(request).render() 73 | log = APIRequestLog.objects.first() 74 | self.assertEqual(log.remote_addr, "2001:db8:85a3::8a2e:370:7334") 75 | 76 | def test_log_ip_remote_v6_loopback(self): 77 | request = APIRequestFactory().get("/logging") 78 | request.META["REMOTE_ADDR"] = "::1" 79 | 80 | MockLoggingView.as_view()(request).render() 81 | log = APIRequestLog.objects.first() 82 | self.assertEqual(log.remote_addr, "::1") 83 | 84 | def test_log_ip_remote_v6_with_port(self): 85 | request = APIRequestFactory().get("/logging") 86 | request.META["REMOTE_ADDR"] = "[::1]:1234" 87 | 88 | MockLoggingView.as_view()(request).render() 89 | log = APIRequestLog.objects.first() 90 | self.assertEqual(log.remote_addr, "::1") 91 | 92 | def test_log_ip_xforwarded(self): 93 | request = APIRequestFactory().get("/logging") 94 | request.META["HTTP_X_FORWARDED_FOR"] = "127.0.0.8" 95 | 96 | MockLoggingView.as_view()(request).render() 97 | log = APIRequestLog.objects.first() 98 | self.assertEqual(log.remote_addr, "127.0.0.8") 99 | 100 | def test_log_ip_xforwarded_list(self): 101 | request = APIRequestFactory().get("/logging") 102 | request.META["HTTP_X_FORWARDED_FOR"] = "127.0.0.8, 128.1.1.9" 103 | 104 | MockLoggingView.as_view()(request).render() 105 | log = APIRequestLog.objects.first() 106 | self.assertEqual(log.remote_addr, "127.0.0.8") 107 | 108 | def test_log_host(self): 109 | self.client.get("/logging") 110 | log = APIRequestLog.objects.first() 111 | self.assertEqual(log.host, "testserver") 112 | 113 | def test_log_method(self): 114 | self.client.get("/logging") 115 | log = APIRequestLog.objects.first() 116 | self.assertEqual(log.method, "GET") 117 | 118 | def test_log_status(self): 119 | self.client.get("/logging") 120 | log = APIRequestLog.objects.first() 121 | self.assertEqual(log.status_code, 200) 122 | 123 | @flaky 124 | def test_log_time_fast(self): 125 | self.client.get("/logging") 126 | log = APIRequestLog.objects.first() 127 | 128 | # response time is very short 129 | self.assertLessEqual(log.response_ms, 20) 130 | 131 | # request_at is time of request, not response 132 | threshold = 0.002 133 | saved_delay = (now() - log.requested_at).total_seconds() 134 | self.assertAlmostEqual(threshold, saved_delay, 2) 135 | 136 | def test_log_time_slow(self): 137 | self.client.get("/slow-logging") 138 | log = APIRequestLog.objects.first() 139 | 140 | # response time is longer than 1000 milliseconds 141 | self.assertGreaterEqual(log.response_ms, 1000) 142 | 143 | # request_at is time of request, not response 144 | self.assertGreaterEqual((now() - log.requested_at).total_seconds(), 1) 145 | 146 | def test_logging_explicit(self): 147 | self.client.get("/explicit-logging") 148 | self.client.post("/explicit-logging") 149 | self.assertEqual(APIRequestLog.objects.all().count(), 1) 150 | 151 | def test_custom_check_logging(self): 152 | self.client.get("/custom-check-logging") 153 | self.client.post("/custom-check-logging") 154 | self.assertEqual(APIRequestLog.objects.all().count(), 1) 155 | 156 | def test_custom_check_logging_deprecated(self): 157 | self.client.get("/custom-check-logging-deprecated") 158 | self.client.post("/custom-check-logging-deprecated") 159 | self.assertEqual(APIRequestLog.objects.all().count(), 1) 160 | 161 | def test_custom_check_logging_with_logging_methods_fail(self): 162 | """Custom `should_log` does not respect logging_methods.""" 163 | self.client.get("/custom-check-logging-methods-fail") 164 | self.client.post("/custom-check-logging-methods-fail") 165 | self.assertEqual(APIRequestLog.objects.all().count(), 2) 166 | 167 | def test_custom_check_logging_with_logging_methods(self): 168 | """Custom `should_log` respect logging_methods.""" 169 | self.client.get("/custom-check-logging-methods") 170 | self.client.post("/custom-check-logging-methods") 171 | self.assertEqual(APIRequestLog.objects.all().count(), 0) 172 | 173 | def test_errors_logging(self): 174 | self.client.get("/errors-logging") 175 | self.client.post("/errors-logging") 176 | self.assertEqual(APIRequestLog.objects.all().count(), 1) 177 | 178 | def test_log_anon_user(self): 179 | self.client.get("/logging") 180 | log = APIRequestLog.objects.first() 181 | self.assertEqual(log.user, None) 182 | 183 | def test_log_auth_user(self): 184 | # set up active user 185 | User.objects.create_user(username="myname", password="secret") 186 | user = User.objects.get(username="myname") 187 | 188 | # set up request with auth 189 | self.client.login(username="myname", password="secret") 190 | self.client.get("/session-auth-logging") 191 | 192 | # test 193 | log = APIRequestLog.objects.first() 194 | self.assertEqual(log.user, user) 195 | 196 | def test_log_auth_inactive_user(self): 197 | # set up inactive user with token 198 | user = User.objects.create_user(username="myname", password="secret") 199 | token = Token.objects.create(user=user) 200 | token_header = "Token %s" % token.key 201 | user.is_active = False 202 | user.save() 203 | 204 | # force login because regular client.login doesn't work for inactive users 205 | self.client.get("/token-auth-logging", HTTP_AUTHORIZATION=token_header) 206 | 207 | # test 208 | log = APIRequestLog.objects.first() 209 | self.assertIsNone(log.user) 210 | self.assertIn("User inactive or deleted", log.response) 211 | 212 | def test_log_unauth_fails(self): 213 | # set up request without auth 214 | self.client.logout() 215 | response = self.client.get("/session-auth-logging") 216 | 217 | # test 218 | log = APIRequestLog.objects.first() 219 | self.assertEqual(status.HTTP_403_FORBIDDEN, log.status_code) 220 | self.assertEqual(json.loads(log.response), response.json()) 221 | 222 | def test_log_params(self): 223 | self.client.get("/logging", {"p1": "a", "another": "2"}) 224 | log = APIRequestLog.objects.first() 225 | self.assertEqual(ast.literal_eval(log.query_params), {"p1": "a", "another": "2"}) 226 | 227 | def test_log_params_cleaned(self): 228 | self.client.get("/logging", {"password": "1234", "key": "12345", "secret": "123456"}) 229 | log = APIRequestLog.objects.first() 230 | self.assertEqual( 231 | ast.literal_eval(log.query_params), 232 | { 233 | "password": BaseLoggingMixin.CLEANED_SUBSTITUTE, 234 | "key": BaseLoggingMixin.CLEANED_SUBSTITUTE, 235 | "secret": BaseLoggingMixin.CLEANED_SUBSTITUTE, 236 | }, 237 | ) 238 | 239 | def test_log_data_empty(self): 240 | """Default payload is string {}""" 241 | self.client.post("/logging") 242 | log = APIRequestLog.objects.first() 243 | self.assertEqual(log.data, str({})) 244 | 245 | def test_log_data_json(self): 246 | self.client.post("/logging", {"val": 1, "val2": [{"a": "b"}]}, format="json") 247 | log = APIRequestLog.objects.first() 248 | expected_data = frozenset( 249 | { # keys could be either way round 250 | str({"val": 1, "val2": [{"a": "b"}]}), 251 | str({"val2": [{"a": "b"}], "val": 1}), 252 | } 253 | ) 254 | self.assertIn(log.data, expected_data) 255 | 256 | def test_log_list_data_json(self): 257 | self.client.post("/logging", [1, 2, {"k1": 1, "k2": 2}, {"k3": 3}], format="json") 258 | 259 | log = APIRequestLog.objects.first() 260 | expected_data = str( 261 | [ 262 | 1, 263 | 2, 264 | {"k1": 1, "k2": 2}, 265 | {"k3": 3}, 266 | ] 267 | ) 268 | self.assertEqual(log.data, expected_data) 269 | 270 | def test_log_data_json_cleaned(self): 271 | self.client.post("/logging", {"password": "123456", "val2": [{"val": "b"}]}, format="json") 272 | log = APIRequestLog.objects.first() 273 | expected_data = frozenset( 274 | { # keys could be either way round 275 | str({"password": BaseLoggingMixin.CLEANED_SUBSTITUTE, "val2": [{"val": "b"}]}), 276 | str({"val2": [{"val": "b"}], "password": BaseLoggingMixin.CLEANED_SUBSTITUTE}), 277 | } 278 | ) 279 | self.assertIn(log.data, expected_data) 280 | 281 | def test_log_data_json_cleaned_nested(self): 282 | self.client.post("/logging", {"password": "123456", "val2": [{"api": "b"}]}, format="json") 283 | log = APIRequestLog.objects.first() 284 | expected_data = frozenset( 285 | { # keys could be either way round 286 | str( 287 | { 288 | "password": BaseLoggingMixin.CLEANED_SUBSTITUTE, 289 | "val2": [{"api": BaseLoggingMixin.CLEANED_SUBSTITUTE}], 290 | } 291 | ), 292 | str( 293 | { 294 | "val2": [{"api": BaseLoggingMixin.CLEANED_SUBSTITUTE}], 295 | "password": BaseLoggingMixin.CLEANED_SUBSTITUTE, 296 | } 297 | ), 298 | } 299 | ) 300 | self.assertIn(log.data, expected_data) 301 | 302 | def test_log_data_json_cleaned_nested_syntax_error(self): 303 | self.client.post("/logging", {"password": "@", "val2": [{"api": "b"}]}, format="json") 304 | log = APIRequestLog.objects.first() 305 | expected_data = frozenset( 306 | { # keys could be either way round 307 | str( 308 | { 309 | "password": BaseLoggingMixin.CLEANED_SUBSTITUTE, 310 | "val2": [{"api": BaseLoggingMixin.CLEANED_SUBSTITUTE}], 311 | } 312 | ), 313 | str( 314 | { 315 | "val2": [{"api": BaseLoggingMixin.CLEANED_SUBSTITUTE}], 316 | "password": BaseLoggingMixin.CLEANED_SUBSTITUTE, 317 | } 318 | ), 319 | } 320 | ) 321 | self.assertIn(log.data, expected_data) 322 | 323 | def test_log_exact_match_params_cleaned(self): 324 | self.client.get("/logging", {"api": "1234", "capitalized": "12345", "keyword": "123456"}) 325 | log = APIRequestLog.objects.first() 326 | self.assertEqual( 327 | ast.literal_eval(log.query_params), 328 | {"api": BaseLoggingMixin.CLEANED_SUBSTITUTE, "capitalized": "12345", "keyword": "123456"}, 329 | ) 330 | 331 | def test_log_with_exception(self): 332 | self.client.get("/logging-exception", {"api": "1234", "capitalized": "12345", "keyword": "123456"}) 333 | log = APIRequestLog.objects.first() 334 | self.assertEqual( 335 | ast.literal_eval(log.query_params), 336 | {"api": BaseLoggingMixin.CLEANED_SUBSTITUTE, "capitalized": "12345", "keyword": "123456"}, 337 | ) 338 | 339 | def test_log_params_cleaned_from_personal_list(self): 340 | self.client.get("/sensitive-fields-logging", {"api": "1234", "capitalized": "12345", "my_field": "123456"}) 341 | log = APIRequestLog.objects.first() 342 | self.assertEqual( 343 | ast.literal_eval(log.query_params), 344 | { 345 | "api": BaseLoggingMixin.CLEANED_SUBSTITUTE, 346 | "capitalized": "12345", 347 | "my_field": BaseLoggingMixin.CLEANED_SUBSTITUTE, 348 | }, 349 | ) 350 | 351 | def test_invalid_cleaned_substitute_fails(self): 352 | with self.assertRaises(AssertionError): 353 | self.client.get("/invalid-cleaned-substitute-logging") 354 | 355 | def test_log_text_response(self): 356 | self.client.get("/logging") 357 | log = APIRequestLog.objects.first() 358 | self.assertEqual(log.response, '"with logging"') 359 | 360 | def test_log_json_get_response(self): 361 | self.client.get("/json-logging") 362 | log = APIRequestLog.objects.first() 363 | self.assertEqual(log.response, '{"get":"response"}') 364 | 365 | def test_log_json_post_response(self): 366 | self.client.post("/json-logging", {}, format="json") 367 | log = APIRequestLog.objects.first() 368 | self.assertEqual(log.response, '{"post":"response"}') 369 | 370 | def test_log_multipart_post_response(self): 371 | self.client.post("/multipart-logging", {}, format="multipart") 372 | log = APIRequestLog.objects.first() 373 | self.assertEqual(log.response, '{"post":"response"}') 374 | 375 | def test_log_multipart_utf8_encoded_file_post_response(self): 376 | file = BytesIO("test data".encode("utf-8")) 377 | self.client.post("/multipart-logging", {"file": file}, format="multipart") 378 | log = APIRequestLog.objects.first() 379 | self.assertEqual(log.response, '{"post":"response"}') 380 | 381 | def test_log_multipart_utf16_encoded_file_post_response(self): 382 | file = BytesIO("test data".encode("utf-16")) 383 | self.client.post("/multipart-logging", {"file": file}, format="multipart") 384 | log = APIRequestLog.objects.first() 385 | self.assertEqual(log.response, '{"post":"response"}') 386 | 387 | def test_log_streaming(self): 388 | response = self.client.get("/streaming-logging") 389 | self.assertEqual(response.getvalue(), b"ab") # iterator was not consumed by logging 390 | log = APIRequestLog.objects.first() 391 | self.assertIs(log.response, None) 392 | 393 | def test_log_status_validation_error(self): 394 | self.client.get("/validation-error-logging") 395 | log = APIRequestLog.objects.first() 396 | self.assertEqual(log.status_code, 400) 397 | self.assertEqual(log.response, '["bad input"]') 398 | 399 | def test_log_request_404_error(self): 400 | self.client.get("/404-error-logging") 401 | log = APIRequestLog.objects.first() 402 | self.assertEqual(log.status_code, 404) 403 | self.assertIn("Not found", log.response) 404 | self.assertIn("Traceback", log.errors) 405 | 406 | def test_log_request_500_error(self): 407 | self.client.get("/500-error-logging") 408 | log = APIRequestLog.objects.first() 409 | self.assertEqual(log.status_code, 500) 410 | self.assertIn("response", log.response) 411 | self.assertIn("Traceback", log.errors) 412 | 413 | def test_log_request_415_error(self): 414 | content_type = "text/plain" 415 | self.client.post("/415-error-logging", {}, content_type=content_type) 416 | log = APIRequestLog.objects.first() 417 | self.assertEqual(log.status_code, 415) 418 | self.assertIn("Unsupported media type", log.response) 419 | 420 | def test_log_view_name_api_view(self): 421 | self.client.get("/no-view-log") 422 | log = APIRequestLog.objects.first() 423 | self.assertEqual(log.view, "tests.views.MockNameAPIView") 424 | 425 | def test_no_log_view_name(self): 426 | self.client.post("/view-log") 427 | log = APIRequestLog.objects.first() 428 | self.assertIsNone(log.view) 429 | 430 | def test_log_view_name_generic_viewset(self): 431 | self.client.get("/view-log") 432 | log = APIRequestLog.objects.first() 433 | self.assertEqual(log.view, "tests.views.MockNameViewSet") 434 | 435 | def test_log_view_method_name_api_view(self): 436 | self.client.get("/no-view-log") 437 | log = APIRequestLog.objects.first() 438 | self.assertEqual(log.view_method, "get") 439 | 440 | def test_no_log_view_method_name(self): 441 | self.client.post("/view-log") 442 | log = APIRequestLog.objects.first() 443 | self.assertIsNone(log.view_method) 444 | 445 | def test_get_user(self): 446 | self.client.get("/user/", {"id": 1}) 447 | log = APIRequestLog.objects.first() 448 | self.assertIsNotNone(log.view_method) 449 | self.assertEqual(ast.literal_eval(log.query_params), {"id": "1"}) 450 | 451 | def test_delete_user(self): 452 | self.client.delete("/user/1/", {"id": 1}) 453 | log = APIRequestLog.objects.first() 454 | self.assertEqual("destroy", log.view_method) 455 | self.assertEqual(ast.literal_eval(log.query_params), {"id": "1"}) 456 | 457 | def test_patch_user(self): 458 | self.client.patch("/user/1/", {"username": "fred"}) 459 | log = APIRequestLog.objects.first() 460 | self.assertEqual("partial_update", log.view_method) 461 | self.assertEqual(ast.literal_eval(log.query_params), {"username": "fred"}) 462 | 463 | def test_post_user(self): 464 | self.client.post( 465 | "/user/", {"username": "fred", "first_name": "fred", "last_name": "jones", "email": "test@test.com"} 466 | ) 467 | log = APIRequestLog.objects.first() 468 | self.assertEqual("create", log.view_method) 469 | self.assertEqual( 470 | ast.literal_eval(log.query_params), 471 | {"email": "test@test.com", "first_name": "fred", "last_name": "jones", "username": "fred"}, 472 | ) 473 | 474 | def test_put_user(self): 475 | self.client.put( 476 | "/user/1/", 477 | {"pk": 1, "username": "fred", "first_name": "fred", "last_name": "jones", "email": "test@test.com"}, 478 | ) 479 | log = APIRequestLog.objects.first() 480 | self.assertEqual("update", log.view_method) 481 | self.assertEqual( 482 | ast.literal_eval(log.query_params), 483 | {"pk": "1", "email": "test@test.com", "first_name": "fred", "last_name": "jones", "username": "fred"}, 484 | ) 485 | 486 | def test_get_user_not_exist(self): 487 | self.client.get("/user/", {"id": 100}) 488 | log = APIRequestLog.objects.first() 489 | self.assertIsNotNone(log.view_method) 490 | self.assertEqual(ast.literal_eval(log.query_params), {"id": "100"}) 491 | 492 | def test_log_view_method_name_generic_viewset(self): 493 | self.client.get("/view-log") 494 | log = APIRequestLog.objects.first() 495 | self.assertEqual(log.view_method, "list") 496 | 497 | def test_log_request_body_parse_error(self): 498 | content_type = "application/json" 499 | self.client.post("/400-body-parse-error-logging", "INVALID JSON", content_type=content_type) 500 | log = APIRequestLog.objects.first() 501 | self.assertEqual(log.status_code, 400) 502 | self.assertEqual(log.data, "INVALID JSON") 503 | self.assertIn("parse error", log.response) 504 | 505 | @mock.patch("rest_framework_tracking.models.APIRequestLog.save") 506 | def test_log_doesnt_prevent_api_call_if_log_save_fails(self, mock_apirequestlog_save): 507 | mock_apirequestlog_save.side_effect = Exception("db failure") 508 | response = self.client.get("/logging") 509 | self.assertEqual(response.status_code, status.HTTP_200_OK) 510 | self.assertEqual(APIRequestLog.objects.all().count(), 0) 511 | 512 | @mock.patch("rest_framework_tracking.base_mixins.now") 513 | def test_log_doesnt_fail_with_negative_response_ms(self, mock_now): 514 | mock_now.side_effect = [datetime.datetime(2017, 12, 1, 10, 0, 10), datetime.datetime(2017, 12, 1, 10)] 515 | self.client.get("/logging") 516 | log = APIRequestLog.objects.first() 517 | self.assertEqual(log.response_ms, 0) 518 | 519 | def test_custom_log_handler(self): 520 | self.client.get("/custom-log-handler") 521 | self.client.post("/custom-log-handler") 522 | self.assertEqual(APIRequestLog.objects.all().count(), 1) 523 | 524 | @override_settings(DATA_UPLOAD_MAX_MEMORY_SIZE=1) 525 | def test_decode_request_body_setting(self): 526 | content_type = "multipart/form-data; boundary=_" 527 | response = self.client.post("/decode-request-body-false", {"data": "some test data"}, content_type=content_type) 528 | self.assertEqual(response.status_code, 200) 529 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from django.test import TestCase 3 | from django.contrib.auth.models import User 4 | from django.utils.timezone import now, timedelta 5 | from rest_framework_tracking.models import APIRequestLog 6 | import pytest 7 | from unittest.mock import patch 8 | 9 | 10 | pytestmark = pytest.mark.django_db 11 | 12 | 13 | class TestAPIRequestLog(TestCase): 14 | def setUp(self): 15 | username = "api_user" 16 | password = "apipw" 17 | self.user = User.objects.create_user(username, "api_user@example.com", password) 18 | self.ip = "127.0.0.1" 19 | self.user_agent = "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0" 20 | 21 | def test_create_anon(self): 22 | log = APIRequestLog.objects.create(remote_addr=self.ip, requested_at=now()) 23 | self.assertIsNone(log.user) 24 | 25 | def test_create_auth(self): 26 | log = APIRequestLog.objects.create(user=self.user, remote_addr=self.ip, requested_at=now()) 27 | self.assertEqual(log.user, self.user) 28 | 29 | def test_delete_user(self): 30 | log = APIRequestLog.objects.create(user=self.user, remote_addr=self.ip, requested_at=now()) 31 | self.assertEqual(log.user, self.user) 32 | self.user.delete() 33 | log.refresh_from_db() 34 | self.assertIsNone(log.user) 35 | 36 | def test_create_timestamp(self): 37 | before = now() - timedelta(seconds=1) 38 | log = APIRequestLog.objects.create(remote_addr=self.ip, requested_at=now()) 39 | after = now() + timedelta(seconds=1) 40 | 41 | self.assertLess(log.requested_at, after) 42 | self.assertGreater(log.requested_at, before) 43 | 44 | def test_path(self): 45 | log = APIRequestLog.objects.create(path="/test", remote_addr=self.ip, requested_at=now()) 46 | self.assertEqual(log.path, "/test") 47 | 48 | def test_view(self): 49 | log = APIRequestLog.objects.create(view="views.api.ApiView", remote_addr=self.ip, requested_at=now()) 50 | self.assertEqual(log.view, "views.api.ApiView") 51 | 52 | def test_view_method(self): 53 | log = APIRequestLog.objects.create(view_method="get", remote_addr=self.ip, requested_at=now()) 54 | self.assertEqual(log.view_method, "get") 55 | 56 | def test_remote_addr(self): 57 | log = APIRequestLog.objects.create(remote_addr="127.0.0.9", requested_at=now()) 58 | self.assertEqual(log.remote_addr, "127.0.0.9") 59 | 60 | def test_host(self): 61 | log = APIRequestLog.objects.create(remote_addr=self.ip, host="testserver", requested_at=now()) 62 | self.assertEqual(log.host, "testserver") 63 | 64 | def test_method(self): 65 | log = APIRequestLog.objects.create(remote_addr=self.ip, method="GET", requested_at=now()) 66 | self.assertEqual(log.method, "GET") 67 | 68 | def test_params(self): 69 | log = APIRequestLog.objects.create(remote_addr=self.ip, query_params={"test1": 1}, requested_at=now()) 70 | self.assertEqual(log.query_params, {"test1": 1}) 71 | 72 | def test_default_response_ms(self): 73 | log = APIRequestLog.objects.create(remote_addr=self.ip, requested_at=now()) 74 | self.assertEqual(log.response_ms, 0) 75 | 76 | def test_data(self): 77 | log = APIRequestLog.objects.create(remote_addr=self.ip, requested_at=now(), data="test POST") 78 | self.assertEqual(log.data, "test POST") 79 | 80 | def test_status_code(self): 81 | log = APIRequestLog.objects.create(remote_addr=self.ip, requested_at=now(), status_code=200) 82 | self.assertEqual(log.status_code, 200) 83 | 84 | def test_errors(self): 85 | log = APIRequestLog.objects.create(remote_addr=self.ip, requested_at=now(), errors="dummy") 86 | self.assertEqual(log.errors, "dummy") 87 | 88 | def test_queries_anon(self): 89 | for _ in range(100): 90 | APIRequestLog.objects.create(remote_addr=self.ip, requested_at=now()) 91 | 92 | with self.assertNumQueries(1): 93 | [o.user for o in APIRequestLog.objects.all()] 94 | 95 | def test_queries_user(self): 96 | for _ in range(100): 97 | APIRequestLog.objects.create(remote_addr=self.ip, requested_at=now(), user=self.user) 98 | 99 | with self.assertNumQueries(1): 100 | [o.user for o in APIRequestLog.objects.all()] 101 | 102 | def test_without_remote_addr(self): 103 | log = APIRequestLog.objects.create(requested_at=now(), user=self.user) 104 | self.assertIsNone(log.remote_addr) 105 | 106 | def test_without_requested_at(self): 107 | FAKE_NOW = datetime(2021, 9, 30, 9, 0, 0) 108 | field = APIRequestLog._meta.get_field("requested_at") 109 | with patch.object(field, "default", new=lambda: FAKE_NOW): 110 | log = APIRequestLog.objects.create(remote_addr=self.ip, user=self.user) 111 | self.assertEqual(log.requested_at, FAKE_NOW) 112 | -------------------------------------------------------------------------------- /tests/test_serializers.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from rest_framework import serializers 3 | from rest_framework_tracking.models import APIRequestLog 4 | 5 | 6 | class ApiRequestLogSerializer(serializers.ModelSerializer): 7 | class Meta: 8 | model = APIRequestLog 9 | fields = ("view",) 10 | 11 | 12 | class UserSerializer(serializers.ModelSerializer): 13 | class Meta: 14 | model = User 15 | fields = ("id", "username", "email", "first_name", "last_name") 16 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | from __future__ import absolute_import 3 | 4 | import django 5 | from rest_framework.routers import DefaultRouter 6 | 7 | from . import views as test_views 8 | 9 | if django.VERSION[0] == 1: 10 | from django.conf.urls import include 11 | else: 12 | from django.urls import include, re_path 13 | 14 | router = DefaultRouter() 15 | router.register(r"user", test_views.MockUserViewSet) 16 | 17 | urlpatterns = [ 18 | re_path(r"^no-logging$", test_views.MockNoLoggingView.as_view()), 19 | re_path(r"^logging$", test_views.MockLoggingView.as_view()), 20 | re_path(r"^logging-exception$", test_views.MockLoggingView.as_view()), 21 | re_path(r"^slow-logging$", test_views.MockSlowLoggingView.as_view()), 22 | re_path(r"^explicit-logging$", test_views.MockExplicitLoggingView.as_view()), 23 | re_path(r"^sensitive-fields-logging$", test_views.MockSensitiveFieldsLoggingView.as_view()), 24 | re_path(r"^invalid-cleaned-substitute-logging$", test_views.MockInvalidCleanedSubstituteLoggingView.as_view()), 25 | re_path(r"^custom-check-logging-deprecated$", test_views.MockCustomCheckLoggingViewDeprecated.as_view()), 26 | re_path(r"^custom-check-logging$", test_views.MockCustomCheckLoggingView.as_view()), 27 | re_path(r"^custom-check-logging-methods$", test_views.MockCustomCheckLoggingWithLoggingMethodsView.as_view()), 28 | re_path( 29 | r"^custom-check-logging-methods-fail$", test_views.MockCustomCheckLoggingWithLoggingMethodsFailView.as_view() 30 | ), 31 | re_path(r"^custom-log-handler$", test_views.MockCustomLogHandlerView.as_view()), 32 | re_path(r"^errors-logging$", test_views.MockLoggingErrorsView.as_view()), 33 | re_path(r"^session-auth-logging$", test_views.MockSessionAuthLoggingView.as_view()), 34 | re_path(r"^token-auth-logging$", test_views.MockTokenAuthLoggingView.as_view()), 35 | re_path(r"^json-logging$", test_views.MockJSONLoggingView.as_view()), 36 | re_path(r"^multipart-logging$", test_views.MockMultipartLoggingView.as_view()), 37 | re_path(r"^streaming-logging$", test_views.MockStreamingLoggingView.as_view()), 38 | re_path(r"^validation-error-logging$", test_views.MockValidationErrorLoggingView.as_view()), 39 | re_path(r"^404-error-logging$", test_views.Mock404ErrorLoggingView.as_view()), 40 | re_path(r"^500-error-logging$", test_views.Mock500ErrorLoggingView.as_view()), 41 | re_path(r"^415-error-logging$", test_views.Mock415ErrorLoggingView.as_view()), 42 | re_path(r"^no-view-log$", test_views.MockNameAPIView.as_view()), 43 | re_path(r"^view-log$", test_views.MockNameViewSet.as_view({"get": "list"})), 44 | re_path(r"^400-body-parse-error-logging$", test_views.Mock400BodyParseErrorLoggingView.as_view()), 45 | re_path(r"^decode-request-body-false$", test_views.MockDecodeRequestBodyFalse.as_view()), 46 | re_path(r"", include(router.urls)), 47 | ] 48 | -------------------------------------------------------------------------------- /tests/views.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from rest_framework import mixins, serializers, status, viewsets 4 | from rest_framework.authentication import SessionAuthentication, TokenAuthentication 5 | from rest_framework.exceptions import APIException 6 | from rest_framework.permissions import IsAuthenticated 7 | from rest_framework.response import Response 8 | from rest_framework_tracking.mixins import LoggingErrorsMixin, LoggingMixin 9 | from rest_framework_tracking.models import APIRequestLog 10 | from rest_framework.views import APIView 11 | from tests.test_serializers import ApiRequestLogSerializer, UserSerializer 12 | 13 | from django.contrib.auth.models import User 14 | from django.http.response import StreamingHttpResponse 15 | from django.shortcuts import get_list_or_404 16 | 17 | 18 | class MockNoLoggingView(APIView): 19 | def get(self, request): 20 | return Response("no logging") 21 | 22 | 23 | class MockLoggingView(LoggingMixin, APIView): 24 | def get(self, request): 25 | return Response("with logging") 26 | 27 | def post(self, request): 28 | return Response("with logging") 29 | 30 | 31 | class MockLoggingExceptionView(LoggingMixin, APIView): 32 | def get(self, request): 33 | raise Exception("mock exception") 34 | 35 | def post(self, request): 36 | raise Exception("mock exception") 37 | 38 | 39 | class MockSlowLoggingView(LoggingMixin, APIView): 40 | def get(self, request): 41 | time.sleep(1) 42 | return Response("with logging") 43 | 44 | 45 | class MockExplicitLoggingView(LoggingMixin, APIView): 46 | logging_methods = ["POST"] 47 | 48 | def get(self, request): 49 | return Response("no logging") 50 | 51 | def post(self, request): 52 | return Response("with logging") 53 | 54 | 55 | class MockSensitiveFieldsLoggingView(LoggingMixin, APIView): 56 | sensitive_fields = {"mY_fiEld"} 57 | 58 | def get(self, request): 59 | return Response("with logging") 60 | 61 | 62 | class MockInvalidCleanedSubstituteLoggingView(LoggingMixin, APIView): 63 | CLEANED_SUBSTITUTE = 1 64 | 65 | 66 | class MockCustomCheckLoggingViewDeprecated(LoggingMixin, APIView): 67 | def _should_log(self, request, response): 68 | """ 69 | Log only if response contains 'log' 70 | """ 71 | return "log" in response.data 72 | 73 | def get(self, request): 74 | return Response("with logging") 75 | 76 | def post(self, request): 77 | return Response("no recording") 78 | 79 | 80 | class MockCustomCheckLoggingView(LoggingMixin, APIView): 81 | def should_log(self, request, response): 82 | """ 83 | Log only if response contains 'log' 84 | """ 85 | return "log" in response.data 86 | 87 | def get(self, request): 88 | return Response("with logging") 89 | 90 | def post(self, request): 91 | return Response("no recording") 92 | 93 | 94 | class MockCustomCheckLoggingWithLoggingMethodsView(LoggingMixin, APIView): 95 | logging_methods = ["POST"] 96 | 97 | def should_log(self, request, response): 98 | """ 99 | Log only if request is in the logging methods and response contains 'log'. 100 | """ 101 | should_log_method = super(MockCustomCheckLoggingWithLoggingMethodsView, self).should_log(request, response) 102 | if not should_log_method: 103 | return False 104 | return "log" in response.data 105 | 106 | def get(self, request): 107 | return Response("with logging") 108 | 109 | def post(self, request): 110 | return Response("no recording") 111 | 112 | 113 | class MockCustomCheckLoggingWithLoggingMethodsFailView(LoggingMixin, APIView): 114 | """The expected behavior should be to save only the post request. 115 | Though, due to the improper `should_log` implementation both requests are saved. 116 | """ 117 | 118 | logging_methods = ["POST"] 119 | 120 | def should_log(self, request, response): 121 | """ 122 | Log only if response contains 'log' 123 | """ 124 | return "log" in response.data 125 | 126 | def get(self, request): 127 | return Response("with logging") 128 | 129 | def post(self, request): 130 | return Response("with logging") 131 | 132 | 133 | class MockLoggingErrorsView(LoggingErrorsMixin, APIView): 134 | def get(self, request): 135 | raise APIException("with logging") 136 | 137 | def post(self, request): 138 | return Response("no logging") 139 | 140 | 141 | class MockSessionAuthLoggingView(LoggingMixin, APIView): 142 | authentication_classes = (SessionAuthentication,) 143 | permission_classes = (IsAuthenticated,) 144 | 145 | def get(self, request): 146 | return Response("with session auth logging") 147 | 148 | 149 | class MockTokenAuthLoggingView(LoggingMixin, APIView): 150 | authentication_classes = (TokenAuthentication,) 151 | permission_classes = (IsAuthenticated,) 152 | 153 | def get(self, request): 154 | return Response("with token auth logging") 155 | 156 | 157 | class MockJSONLoggingView(LoggingMixin, APIView): 158 | def get(self, request): 159 | return Response({"get": "response"}) 160 | 161 | def post(self, request): 162 | return Response({"post": "response"}) 163 | 164 | 165 | class MockMultipartLoggingView(LoggingMixin, APIView): 166 | def post(self, request): 167 | return Response({"post": "response"}) 168 | 169 | 170 | class MockStreamingLoggingView(LoggingMixin, APIView): 171 | def get(self, request): 172 | return StreamingHttpResponse(iter([b"a", b"b"])) 173 | 174 | 175 | class MockValidationErrorLoggingView(LoggingMixin, APIView): 176 | def get(self, request): 177 | raise serializers.ValidationError("bad input") 178 | 179 | 180 | class Mock404ErrorLoggingView(LoggingMixin, APIView): 181 | def get(self, request): 182 | empty_qs = APIRequestLog.objects.none() 183 | return get_list_or_404(empty_qs) 184 | 185 | 186 | class Mock500ErrorLoggingView(LoggingMixin, APIView): 187 | def get(self, request): 188 | raise APIException("response") 189 | 190 | 191 | class Mock415ErrorLoggingView(LoggingMixin, APIView): 192 | def post(self, request): 193 | return request.data 194 | 195 | 196 | class MockNameAPIView(LoggingMixin, APIView): 197 | def get(self, _): 198 | return Response("with logging") 199 | 200 | 201 | class MockNameViewSet(LoggingMixin, viewsets.GenericViewSet, mixins.ListModelMixin): 202 | authentication_classes = () 203 | permission_classes = [] 204 | 205 | queryset = APIRequestLog.objects.all() 206 | serializer_class = ApiRequestLogSerializer 207 | 208 | 209 | class MockUserViewSet(LoggingMixin, viewsets.ModelViewSet): 210 | authentication_classes = () 211 | permission_classes = [] 212 | 213 | queryset = User.objects.all() 214 | serializer_class = UserSerializer 215 | 216 | 217 | class Mock400BodyParseErrorLoggingView(LoggingMixin, APIView): 218 | def post(self, request): 219 | # raise ParseError for request with mismatched Content-Type and body: 220 | # (though only if it's the first access to request.data) 221 | request.data 222 | return Response("Data processed") 223 | 224 | 225 | class MockCustomLogHandlerView(LoggingMixin, APIView): 226 | def handle_log(self): 227 | """ 228 | Save only very slow requests. Requests that took more than 500 ms. 229 | """ 230 | if self.log["response_ms"] > 500: 231 | super(MockCustomLogHandlerView, self).handle_log() 232 | 233 | def get(self, request): 234 | return Response("Fast request. No logging.") 235 | 236 | def post(self, request): 237 | time.sleep(1) 238 | return Response("Slow request. Save it on db.") 239 | 240 | 241 | class MockDecodeRequestBodyFalse(LoggingMixin, APIView): 242 | decode_request_body = False 243 | 244 | def post(self, request): 245 | return Response({"decode_request_body": False}, status=status.HTTP_200_OK) 246 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py38-{flake8,docs}, 4 | py{36,37}-django2.2-drf{3.11}, 5 | py{36,37,38}-django3.0-drf{3.11,3.12}, 6 | py{36,37,38,39}-django3.1-drf{3.12}, 7 | py{36,37,38,39}-django3.2-drf{3.12}, 8 | 9 | [gh-actions] 10 | python = 11 | 3.6: py36 12 | 3.7: py37 13 | 3.8: py38 14 | 3.9: py39 15 | 16 | [testenv] 17 | tox_pyenv_fallback = True 18 | 19 | commands = python -V 20 | pip install --upgrade pip pipenv 21 | pipenv install --skip-lock 22 | ./runtests.py --fast 23 | passenv = 24 | DATABASE_URL 25 | PYTHON_VERSION 26 | 27 | setenv = 28 | PYTHONDONTWRITEBYTECODE=1 29 | PYTHONPATH={toxinidir} 30 | TOX_ENV_NAME={envname} 31 | 32 | deps = 33 | django2.2: django~=2.2.16 34 | django3.1: django~=3.1.2 35 | django3.2: django~=3.2.2 36 | drf3.11: djangorestframework~=3.11.2 37 | drf3.12: djangorestframework~=3.12.1 38 | pytest-django 39 | django-environ 40 | flaky 41 | mock 42 | 43 | basepython = 44 | py36: python3.6 45 | py37: python3.7 46 | py38: python3.8 47 | 48 | [testenv:py38-flake8] 49 | commands = ./runtests.py --lintonly 50 | deps = 51 | pytest>=2.7 52 | flake8>=2.4 53 | 54 | [travis] 55 | python = 56 | 3.6: py36 57 | 3.7: py37 58 | 3.8: py38 59 | 3.9: py39 60 | 61 | [travis:env] 62 | DJANGO = 63 | 2.2: django2.2 64 | 3.1: django3.1 65 | 3.2: django3.2 66 | DRF = 67 | 3.11: drf3.11 68 | 3.12: drf3.12 69 | 70 | [testenv:format] 71 | basepython = python3.8 72 | deps = 73 | isort 74 | black 75 | skip_install = true 76 | commands = 77 | black --check wagtailstreamforms/ tests/ 78 | --------------------------------------------------------------------------------