├── .github └── workflows │ └── packaging.yml ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── conftest.py ├── manage.py ├── pyproject.toml ├── src └── webmention │ ├── __init__.py │ ├── admin.py │ ├── checks.py │ ├── middleware.py │ ├── migrations │ ├── 0001_initial.py │ └── __init__.py │ ├── models.py │ ├── resolution.py │ ├── urls.py │ └── views.py └── tests ├── __init__.py ├── settings.py ├── test_middleware.py ├── test_models.py ├── test_resolution.py ├── test_urls.py └── test_views.py /.github/workflows/packaging.yml: -------------------------------------------------------------------------------- 1 | name: Packaging 2 | 3 | on: 4 | - push 5 | 6 | jobs: 7 | format: 8 | name: Check formatting 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | 13 | - uses: actions/setup-python@v5 14 | with: 15 | python-version: "3.12" 16 | 17 | - name: Install tox 18 | run: python -m pip install tox 19 | 20 | - name: Run black 21 | run: tox -e format 22 | 23 | lint: 24 | name: Lint 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@v4 28 | 29 | - uses: actions/setup-python@v5 30 | with: 31 | python-version: "3.12" 32 | 33 | - name: Install tox 34 | run: python -m pip install tox 35 | 36 | - name: Run flake8 37 | run: tox -e lint 38 | 39 | typecheck: 40 | name: Type check 41 | runs-on: ubuntu-latest 42 | steps: 43 | - uses: actions/checkout@v4 44 | 45 | - uses: actions/setup-python@v5 46 | with: 47 | python-version: "3.12" 48 | 49 | - name: Install tox 50 | run: python -m pip install tox 51 | 52 | - name: Run mypy 53 | run: tox -e typecheck 54 | 55 | test: 56 | name: Test 57 | runs-on: ubuntu-latest 58 | strategy: 59 | matrix: 60 | python: 61 | - version: "3.13" 62 | toxenv: "py313" 63 | - version: "3.12" 64 | toxenv: "py312" 65 | - version: "3.11" 66 | toxenv: "py311" 67 | - version: "3.10" 68 | toxenv: "py310" 69 | - version: "3.9" 70 | toxenv: "py39" 71 | steps: 72 | - uses: actions/checkout@v4 73 | 74 | - uses: actions/setup-python@v5 75 | with: 76 | python-version: ${{ matrix.python.version }} 77 | 78 | - name: Install tox 79 | run: python -m pip install tox 80 | 81 | - name: Run pytest 82 | run: tox -e ${{ matrix.python.toxenv }} 83 | 84 | build_source_dist: 85 | name: Build source distribution 86 | runs-on: ubuntu-latest 87 | steps: 88 | - uses: actions/checkout@v4 89 | 90 | - uses: actions/setup-python@v5 91 | with: 92 | python-version: "3.12" 93 | 94 | - name: Install build 95 | run: python -m pip install build 96 | 97 | - name: Run build 98 | run: python -m build --sdist 99 | 100 | - uses: actions/upload-artifact@v4 101 | with: 102 | path: dist/*.tar.gz 103 | 104 | publish: 105 | needs: [format, lint, typecheck, test] 106 | if: startsWith(github.ref, 'refs/tags') 107 | runs-on: ubuntu-latest 108 | environment: release 109 | permissions: 110 | id-token: write 111 | contents: write 112 | steps: 113 | - uses: actions/checkout@v4 114 | 115 | - name: Set up Python 116 | uses: actions/setup-python@v5 117 | with: 118 | python-version: 3.9 119 | 120 | - name: Install pypa/build 121 | run: python -m pip install build 122 | 123 | - name: Build distribution 124 | run: python -m build --outdir dist/ 125 | 126 | - name: Publish distribution to Test PyPI 127 | uses: pypa/gh-action-pypi-publish@release/v1 128 | with: 129 | repository_url: https://test.pypi.org/legacy/ 130 | 131 | - name: Publish distribution to PyPI 132 | uses: pypa/gh-action-pypi-publish@release/v1 133 | 134 | - name: Publish distribution to GitHub release 135 | uses: softprops/action-gh-release@v2 136 | with: 137 | files: | 138 | dist/django_webmention-*.whl 139 | dist/django_webmention-*.tar.gz 140 | env: 141 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 142 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | pip-wheel-metadata/ 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | 47 | # Translations 48 | *.mo 49 | *.pot 50 | 51 | # Django stuff: 52 | *.log 53 | 54 | # Sphinx documentation 55 | docs/_build/ 56 | 57 | # PyBuilder 58 | target/ 59 | 60 | # PyCharm 61 | .idea/ 62 | 63 | # PyPI 64 | README.rst 65 | 66 | # pyenv 67 | .python-version 68 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: xenial 2 | language: python 3 | cache: pip 4 | 5 | python: 6 | - '3.8' 7 | - '3.7' 8 | - '3.6' 9 | 10 | install: 11 | - pip install tox-travis 12 | 13 | script: 14 | - tox 15 | 16 | stages: 17 | - lint 18 | - test 19 | - name: deploy 20 | if: tag IS present 21 | 22 | jobs: 23 | include: 24 | - stage: lint 25 | script: 26 | - tox -e lint 27 | 28 | - stage: deploy 29 | before_deploy: 30 | python setup.py sdist bdist_wheel 31 | deploy: 32 | - provider: releases # GitHub releases 33 | api_key: 34 | secure: 'ONG/Gqo6zQvLTW2F4wIiu21fgDsBOSVWhuSgmzs98P6brIeSY4IqAYh6MH4d0XszfYOV1ytky3YjyoXbQsBdYBhyS6Psgw8XvJeLN/mRjr3gNmxZ0dEUiJcdnYePRLe7FG+gFB08FS+Aq4d9GqpithmBcHpGSmL6q5ZnnxC98OX/Ts46CrNiBCA6gg8klkOygk1VOJklJwWHK39yRZNuyUpKGk9WEf23BDpLoaqrnivwJODvMDCnGaAjTeeziCZlpGTdR75F6CViJkeqA9dq0cG6zy1i3q6Mf8zEBmmRJxFcrbW2pdDYgKiguGohTsxt2drhDQRTItbgu01pMXDtapX4WO51tEbEr2JjOq7Yv1L1VAyARFdM6E07s+JE5WLCWKxWlFxly0Vc58pRpd7wgP87lvGiPHSHPUVVeic9hzCbxMsl8iVzEO+MldZHHrgU9R0PQNW4LtpN0HGTTIyyu8cbGytfdd/ZoQVzW1cJMxuxBP8c4tj8uGlHQCRmxOy4pHhj3r2kyTLrbaR8VK2aLIMiosqylp1ZIDbmHSsSEYYDSrRsaXqEXXBaozqi/o9vZ0vnPMoHJ7L6FxR2c+1FjaIlmjnSUv6de5a9ytR6xoT8QYF4fbWA/U46yCJL7mboRXXqgqBwxHZNRrivnxBD3sfDSUT0Ykq3fegGGow2lfM=' 35 | file_glob: true 36 | file: 37 | - dist/django-webmention-*-py3-none-any.whl 38 | - dist/django-webmention-*.tar.gz 39 | skip_cleanup: true 40 | on: 41 | tags: true 42 | python: '3.8' 43 | 44 | - &pypi # Production PyPI 45 | provider: pypi 46 | user: '__token__' 47 | password: 48 | secure: 'XXT3GDecB3QDePR5+3OotyUkz6esUmZK5InF4lJQ58SKU1e0R2CxgKivEKDDs88dFtiHdNnKwRknQ4/9WgQ5rhXuEZ/eiwmxue1xfa9284rfX6fAmnY1WCvnIonus82tmhCB6eSkQnRRu03+aLIilI4WYSb1rwkOx8Soa6rtRMbfGJNv8cAeDeHK2uM16WWgDVp+pZOO54r0a/j05SQk4VL/W5c9visFFnWKxZqBTK+C95ZAk1d4BoS+VHiYxzX4dImqSwomC4OZ4Df/lS7p/thabrC4T8n/KXBvI3LiM2u6glFM42rJ6MmFoadLaBwON4+OhInKJ+Pc8yWjKuQ34e07VZ97t3XXxEoSG3hrH0wHyTSx9tca8UwuIFqNP70DV9X8ePbINBCPCLKuJCOa5njUubU3MpXM9nwt71IjWCgZpxXj9mEYH0lPGDcR8kQLLGxxtoFL5oVrEDR6YrPoObuTokXMgXeLrNkcCkEKuzB6fPnH28hvXIshmixFygnz+fio4Z/CcE6A9oCp8hmFOAj+1ZPWJuiRZrQzuGV4S7/G6GHWhdI4S9W54A+3MU6QFhi2oum/IcG55C54eY0p04oFL87LaRhuy1SB7obr2i0871j2LR8tWzQIaaTgNww4cYPLKWnnYGXmiQxtkuwqT1VtCFvI2AufcDdL9x8bJ3U=' 49 | skip_cleanup: true 50 | skip_existing: true 51 | on: 52 | tags: true 53 | python: '3.8' 54 | 55 | - <<: *pypi # Test PyPI 56 | server: 'https://test.pypi.org/legacy/' 57 | password: 58 | secure: 'Wgbk8W2KQfsaXFQBKNAAo7VvwuBIpGboYG+iaf/ImrkjDz1oLOy/P/d7qlEY79SJVdOAK5Orrf6Dp+vlKziiIcIIa+A0qfcLtNlT6nIhrx0h0l+N9/Hsliw1FAd1DhJA1a6qE8z3790ZHB6QXA8FWP2Vqd2iY05l3R3Ii0Rz+701WLENNi1+6iHiTdzLJJtcpvy9snzwdwLHLXJN2FDJbKRN1DJW2GE35OOxrhuZC8x8lUwsJujI1UbN5P16TWxVgNZz6Ob05hOkkRBaMDxXA9deSsk+wDqKoJaD2KWEr9GDzJLFTyw9ZOG3iN5eJ8IN011cXeJ3YneucHEkD+0xFjFF1oRzMkTw6Kga1+/IIzeUG8CeKR6RQ2V+1yI9Ou2qZmc975AlgPCbbfmBtOsoZZWsoQAPXrOyBpLhoiEHuqMglbhQDTz2jS9mgyYW5bXbhJhPrb7CwAtwjH1QpVlC2v0TTgwpGdY0KEhwKogm/cSudCOgKUHfU63AXJoIjAzTmsWbiCbkKrdQ+E7kHLVUxrPVennfueBQ0AXHrSRPkupYSzjeQFI4mE5y3NSxwDFgb3IZj8ingTKokHrO5W0JUBDlZEVlrT1HX0bcbu2V7sM9Iul1ZIWGAigOo+2izQSvxu1YMHvLX8ghjQagkOOFRXWiAzr++aKkqnxhJrgmjFM=' 59 | -------------------------------------------------------------------------------- /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 | ## [3.0.0] - 2023-01-03 8 | ### Added 9 | - Support for Django 4.0+ 10 | 11 | ### Removed 12 | - Support for Django versions less than 2.2 13 | - Support for Python versions less than 3.7 14 | 15 | ## [2.0.2] - 2021-02-14 16 | ### Fixed 17 | - Store source content as a string rather than a bytes object 18 | 19 | ## [2.0.1] - 2020-08-25 20 | ### Fixed 21 | - Render HTML links in the Django admin by using `format_html` instead of the now-deprecated `allow_tags` attribute 22 | 23 | ## [2.0.0] - 2020-08-20 24 | ### Removed 25 | - Support for Python 3.5 26 | 27 | ### Added 28 | - Compatibility in the `include_webmention_information` decorator for versions of Django with new-style middleware 29 | 30 | ## [1.1.0] - 2019-07-22 31 | ### Changed 32 | - Use static `setup.cfg` for package metadata and tooling configuration 33 | - Use black code style 34 | - Lint with pyflakes 35 | 36 | ## [1.0.1] - 2018-04-12 37 | ### Fixed 38 | - Made `setup.py` aware that the README content type is, in fact, markdown 39 | 40 | ## [1.0.0] - 2018-04-12 41 | ### Added 42 | - Better documentation about testing 43 | - Coverage configuration 44 | 45 | ### Changed 46 | - Use markdown for PyPI README 47 | 48 | ## [0.1.0] - 2018-01-02 49 | ### Added 50 | - Mention use of `path()` over `url()` in README 51 | - Mention use of new-style `MIDDLEWARE` over old-style `MIDDLEWARE_CLASSES` in README 52 | - Add system check to detect presence of incorrect middleware configuration 53 | - Update imports and other syntax for forward compatibility with Django 1.10+ and Django 2.0+ 54 | 55 | ## [0.0.4] - 2016-07-15 56 | ### Changed 57 | - Reworked the unit tests to be runnable under Travis CI to support continuous integration 58 | 59 | ## [0.0.3] - 2016-01-22 60 | ### Changed 61 | - Successful POST requests will now receive a 202 Accepted response rather than a 200 OK response 62 | 63 | ### Added 64 | - Django 1.9 in frameworks listed in setup.py 65 | 66 | ### Fixed 67 | - Errors in documentation 68 | 69 | ## [0.0.2] - 2015-07-11 70 | ### Added 71 | - Webmentions are now available for review in the admin console 72 | - Webmentions are now updated or invalidated when a new webmention notification request is sent 73 | - Thorough unit testing 74 | 75 | ## [0.0.1] - 2015-07-10 76 | ### Added 77 | - Pre-alpha initial release 78 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2015 Dane Hillard 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), 4 | to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, 5 | and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 10 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 11 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 12 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 13 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-webmention [![PyPI version](https://badge.fury.io/py/django-webmention.svg)](https://badge.fury.io/py/django-webmention) [![Build Status](https://travis-ci.org/easy-as-python/django-webmention.svg?branch=master)](https://travis-ci.org/easy-as-python/django-webmention) 2 | 3 | [webmention](https://www.w3.org/TR/webmention/) for Django projects. 4 | 5 | ## What this project is 6 | 7 | This package provides a way to integrate [webmention endpoint discovery](https://www.w3.org/TR/webmention/#sender-discovers-receiver-webmention-endpoint) and [webmention receipts](https://www.w3.org/TR/webmention/#receiving-webmentions) into your project. Once you follow the installation instructions, you should be able to use something like [webmention.rocks](https://webmention.rocks/) to generate a test webmention and see it in the Django admin panel. 8 | 9 | Once you receive a webmention, you can click through to the page the webmention was sent from and see what people are saying about your site. Afterward, you can mark the webmention as reviewed in the Django admin so you can more easily see the latest webmentions you receive. 10 | 11 | Once you verify that you're receiving webmentions successfully, you can use the webmention information as you like. As an example, you could query the webmentions that are responses to a specific page and display them on that page. 12 | 13 | ## What this project isn't 14 | 15 | This package does not currently provide functionality for [sending webmentions](https://www.w3.org/TR/webmention/#sending-webmentions). 16 | 17 | ## Installation 18 | 19 | `$ pip install django-webmention` 20 | 21 | * Add `'webmention'` to `INSTALLED_APPS` 22 | * Run `python manage.py migrate webmention` 23 | * Add the URL patterns to your top-level `urls.py` 24 | * `path('webmention/', include('webmention.urls'))` for Django >= 3.2 25 | 26 | ## Usage 27 | 28 | * Include webmention information by either: 29 | * Installing the middleware in `settings.py` (affects all views) 30 | * Append `webmention.middleware.webmention_middleware` to your `MIDDLEWARE` settings 31 | * Decorating a specific view with `webmention.middleware.include_webmention_information` 32 | * View webmention responses in the Django admin interface and mark them as reviewed as needed 33 | 34 | ## Development 35 | 36 | ### Setup 37 | 38 | * Install [tox](https://tox.readthedocs.io) 39 | 40 | ### Running Tests 41 | 42 | You can run tests using `tox`: 43 | 44 | ```shell 45 | $ tox --parallel=auto 46 | ``` 47 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.fixture 5 | def test_source(): 6 | return 'http://example.com' 7 | 8 | 9 | @pytest.fixture 10 | def test_target(): 11 | return 'http://mysite.com' 12 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "webmention.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "django-webmention" 3 | version = "3.0.0" 4 | description = "A pluggable implementation of webmention for Django projects" 5 | authors = [ 6 | { name = "Dane Hillard", email = "github@danehillard.com" }, 7 | ] 8 | license = { file = "LICENSE" } 9 | classifiers = [ 10 | "Development Status :: 5 - Production/Stable", 11 | "Intended Audience :: Developers", 12 | "Framework :: Django", 13 | "Framework :: Django :: 3.2", 14 | "Framework :: Django :: 4.0", 15 | "Framework :: Django :: 4.1", 16 | "Topic :: Internet :: WWW/HTTP :: Indexing/Search", 17 | "License :: OSI Approved :: MIT License", 18 | "Programming Language :: Python", 19 | "Programming Language :: Python :: 3 :: Only", 20 | "Programming Language :: Python :: 3", 21 | "Programming Language :: Python :: 3.9", 22 | "Programming Language :: Python :: 3.10", 23 | "Programming Language :: Python :: 3.11", 24 | "Programming Language :: Python :: 3.12", 25 | "Programming Language :: Python :: 3.13", 26 | ] 27 | dependencies = [ 28 | "Django>=4.2.0", 29 | "requests>=2.32.3", 30 | ] 31 | 32 | [project.urls] 33 | Repository = "https://github.com/easy-as-python/django-webmention" 34 | 35 | [tool.setuptools.packages.find] 36 | where = ["src"] 37 | exclude = ["test*"] 38 | 39 | ###################### 40 | # Tool configuration # 41 | ###################### 42 | 43 | [tool.black] 44 | line-length = 120 45 | target-version = ["py39", "py310", "py311", "py312", "py313"] 46 | 47 | [tool.mypy] 48 | python_version = "3.9" 49 | warn_unused_configs = true 50 | show_error_context = true 51 | pretty = true 52 | namespace_packages = true 53 | check_untyped_defs = true 54 | 55 | [[tool.mypy.overrides]] 56 | module = [ 57 | "django.core.urlresolvers", 58 | ] 59 | ignore_missing_imports = true 60 | 61 | [tool.coverage.run] 62 | branch = true 63 | omit = [ 64 | "manage.py", 65 | "webmention/checks.py", 66 | "*test*", 67 | "*/migrations/*", 68 | "*/admin.py", 69 | "*/__init__.py", 70 | ] 71 | 72 | [tool.coverage.report] 73 | precision = 2 74 | show_missing = true 75 | skip_covered = true 76 | 77 | [tool.coverage.paths] 78 | source = [ 79 | "src/webmention", 80 | "*/site-packages/webmention", 81 | ] 82 | 83 | [tool.pytest.ini_options] 84 | DJANGO_SETTINGS_MODULE = "tests.settings" 85 | testpaths = ["tests"] 86 | addopts = ["-ra", "-q", "--cov=webmention"] 87 | xfail_strict = true 88 | 89 | [tool.tox] 90 | envlist = [ 91 | "py39-django4.2", 92 | "py39-django5.0", 93 | "py39-django5.1", 94 | "py310-django4.2", 95 | "py310-django5.0", 96 | "py310-django5.1", 97 | "py311-django4.2", 98 | "py311-django5.0", 99 | "py311-django5.1", 100 | "py312-django4.2", 101 | "py312-django5.0", 102 | "py312-django5.1", 103 | "py313-django4.2", 104 | "py313-django5.0", 105 | "py313-django5.1", 106 | ] 107 | 108 | [tool.tox.env_run_base] 109 | deps = [ 110 | "pytest", 111 | "pytest-cov", 112 | "pytest-django", 113 | ] 114 | commands = [ 115 | ["pytest", { replace = "posargs", default = [], extend = true }], 116 | ] 117 | 118 | [tool.tox.env."py39-django4.2"] 119 | deps = [ 120 | { replace = "ref", of = ["tool", "tox", "env_run_base", "deps"], extend = true }, 121 | "Django>=4.2,<4.3", 122 | ] 123 | 124 | [tool.tox.env."py39-django5.0"] 125 | deps = [ 126 | { replace = "ref", of = ["tool", "tox", "env_run_base", "deps"], extend = true }, 127 | "Django>=5.0,<5.1", 128 | ] 129 | 130 | [tool.tox.env."py39-django5.1"] 131 | deps = [ 132 | { replace = "ref", of = ["tool", "tox", "env_run_base", "deps"], extend = true }, 133 | "Django>=5.1,<5.2", 134 | ] 135 | 136 | [tool.tox.env."py310-django4.2"] 137 | deps = [ 138 | { replace = "ref", of = ["tool", "tox", "env_run_base", "deps"], extend = true }, 139 | "Django>=4.2,<4.3", 140 | ] 141 | 142 | [tool.tox.env."py310-django5.0"] 143 | deps = [ 144 | { replace = "ref", of = ["tool", "tox", "env_run_base", "deps"], extend = true }, 145 | "Django>=5.0,<5.1", 146 | ] 147 | 148 | [tool.tox.env."py310-django5.1"] 149 | deps = [ 150 | { replace = "ref", of = ["tool", "tox", "env_run_base", "deps"], extend = true }, 151 | "Django>=5.1,<5.2", 152 | ] 153 | 154 | [tool.tox.env."py311-django4.2"] 155 | deps = [ 156 | { replace = "ref", of = ["tool", "tox", "env_run_base", "deps"], extend = true }, 157 | "Django>=4.2,<4.3", 158 | ] 159 | 160 | [tool.tox.env."py311-django5.0"] 161 | deps = [ 162 | { replace = "ref", of = ["tool", "tox", "env_run_base", "deps"], extend = true }, 163 | "Django>=5.0,<5.1", 164 | ] 165 | 166 | [tool.tox.env."py311-django5.1"] 167 | deps = [ 168 | { replace = "ref", of = ["tool", "tox", "env_run_base", "deps"], extend = true }, 169 | "Django>=5.1,<5.2", 170 | ] 171 | 172 | [tool.tox.env."py312-django4.2"] 173 | deps = [ 174 | { replace = "ref", of = ["tool", "tox", "env_run_base", "deps"], extend = true }, 175 | "Django>=4.2,<4.3", 176 | ] 177 | 178 | [tool.tox.env."py312-django5.0"] 179 | deps = [ 180 | { replace = "ref", of = ["tool", "tox", "env_run_base", "deps"], extend = true }, 181 | "Django>=5.0,<5.1", 182 | ] 183 | 184 | [tool.tox.env."py312-django5.1"] 185 | deps = [ 186 | { replace = "ref", of = ["tool", "tox", "env_run_base", "deps"], extend = true }, 187 | "Django>=5.1,<5.2", 188 | ] 189 | 190 | [tool.tox.env."py313-django4.2"] 191 | deps = [ 192 | { replace = "ref", of = ["tool", "tox", "env_run_base", "deps"], extend = true }, 193 | "Django>=4.2,<4.3", 194 | ] 195 | 196 | [tool.tox.env."py313-django5.0"] 197 | deps = [ 198 | { replace = "ref", of = ["tool", "tox", "env_run_base", "deps"], extend = true }, 199 | "Django>=5.0,<5.1", 200 | ] 201 | 202 | [tool.tox.env."py313-django5.1"] 203 | deps = [ 204 | { replace = "ref", of = ["tool", "tox", "env_run_base", "deps"], extend = true }, 205 | "Django>=5.1,<5.2", 206 | ] 207 | 208 | [tool.tox.env.lint] 209 | skip_install = true 210 | deps = [ 211 | "ruff", 212 | ] 213 | commands = [ 214 | ["ruff", "check", { replace = "posargs", default = ["--diff", "src/webmention", "tests"], extend = true }], 215 | ] 216 | 217 | [tool.tox.env.format] 218 | skip_install = true 219 | deps = [ 220 | "black", 221 | ] 222 | commands = [ 223 | ["black", { replace = "posargs", default = ["--check", "--diff", "src/webmention", "tests"], extend = true }], 224 | ] 225 | 226 | [tool.tox.env.typecheck] 227 | deps = [ 228 | { replace = "ref", of = ["tool", "tox", "env_run_base", "deps"], extend = true }, 229 | "mypy", 230 | "django-types", 231 | "types-requests", 232 | ] 233 | commands = [ 234 | ["mypy", { replace = "posargs", default = ["src/webmention", "tests"], extend = true }], 235 | ] 236 | -------------------------------------------------------------------------------- /src/webmention/__init__.py: -------------------------------------------------------------------------------- 1 | from . import checks 2 | 3 | __all__ = ["checks"] 4 | -------------------------------------------------------------------------------- /src/webmention/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import WebMentionResponse 4 | 5 | 6 | class WebMentionResponseAdmin(admin.ModelAdmin): 7 | model = WebMentionResponse 8 | 9 | fields = [ 10 | ("source_for_admin", "response_to_for_admin"), 11 | "response_body", 12 | ("date_created", "date_modified"), 13 | ("reviewed", "current"), 14 | ] 15 | 16 | readonly_fields = [ 17 | "response_body", 18 | "source_for_admin", 19 | "response_to_for_admin", 20 | "date_created", 21 | "date_modified", 22 | "current", 23 | ] 24 | 25 | list_display = [ 26 | "pk", 27 | "source_for_admin", 28 | "response_to_for_admin", 29 | "date_created", 30 | "date_modified", 31 | "reviewed", 32 | "current", 33 | ] 34 | 35 | list_editable = ["reviewed"] 36 | 37 | list_filter = ["reviewed", "current"] 38 | 39 | date_hierarchy = "date_modified" 40 | 41 | 42 | admin.site.register(WebMentionResponse, WebMentionResponseAdmin) 43 | -------------------------------------------------------------------------------- /src/webmention/checks.py: -------------------------------------------------------------------------------- 1 | import django 2 | from django.conf import settings 3 | from django.core.checks import Error, register, Tags 4 | 5 | 6 | @register(Tags.compatibility) 7 | def new_style_middleware_check(app_configs, **kwargs): 8 | errors = [] 9 | 10 | if django.VERSION[1] >= 10 or django.VERSION[0] > 1: 11 | installed_middlewares = getattr(settings, "MIDDLEWARE", []) or [] 12 | if "webmention.middleware.WebMentionMiddleware" in installed_middlewares: 13 | errors.append( 14 | Error( 15 | "You are attempting to use an old-style middleware class in the MIDDLEWARE setting", 16 | hint="Either use MIDDLEWARE_CLASSES or use webmention.middleware.webmention_middleware instead", 17 | id="webmention.E001", 18 | ) 19 | ) 20 | return errors 21 | -------------------------------------------------------------------------------- /src/webmention/middleware.py: -------------------------------------------------------------------------------- 1 | from django.utils.decorators import decorator_from_middleware 2 | from django.utils.deprecation import MiddlewareMixin 3 | 4 | try: 5 | from django.core.urlresolvers import reverse 6 | except ImportError: 7 | from django.urls import reverse 8 | 9 | 10 | def add_webmention_headers_to_response(request, response): 11 | link_header = '<{scheme}://{host}{path}>; rel="webmention"'.format( 12 | scheme=request.scheme, host=request.META.get("HTTP_HOST"), path=reverse("webmention:receive") 13 | ) 14 | if not response.get("Link"): 15 | response["Link"] = link_header 16 | else: 17 | response["Link"] = ", ".join((response["Link"], link_header)) 18 | 19 | return response 20 | 21 | 22 | class WebMentionMiddleware(MiddlewareMixin): 23 | def process_response(self, request, response): 24 | return add_webmention_headers_to_response(request, response) 25 | 26 | 27 | def webmention_middleware(get_response): 28 | def middleware(request): 29 | response = get_response(request) 30 | return add_webmention_headers_to_response(request, response) 31 | 32 | return middleware 33 | 34 | 35 | include_webmention_information = decorator_from_middleware(WebMentionMiddleware) 36 | -------------------------------------------------------------------------------- /src/webmention/migrations/0001_initial.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 | 11 | operations = [ 12 | migrations.CreateModel( 13 | name="WebMentionResponse", 14 | fields=[ 15 | ("id", models.AutoField(verbose_name="ID", auto_created=True, primary_key=True, serialize=False)), 16 | ("response_body", models.TextField()), 17 | ("response_to", models.URLField()), 18 | ("source", models.URLField()), 19 | ("reviewed", models.BooleanField(default=False)), 20 | ("current", models.BooleanField(default=True)), 21 | ("date_created", models.DateTimeField(auto_now_add=True)), 22 | ("date_modified", models.DateTimeField(auto_now=True)), 23 | ], 24 | options={"verbose_name": "webmention", "verbose_name_plural": "webmentions"}, 25 | ) 26 | ] 27 | -------------------------------------------------------------------------------- /src/webmention/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/easy-as-python/django-webmention/2ff415291199a733f399388a30427d6469c614ee/src/webmention/migrations/__init__.py -------------------------------------------------------------------------------- /src/webmention/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.admin import display 2 | from django.db import models 3 | from django.utils.html import format_html 4 | 5 | 6 | class WebMentionResponse(models.Model): 7 | id: int 8 | response_body = models.TextField() 9 | response_to = models.URLField() 10 | source = models.URLField() 11 | reviewed = models.BooleanField(default=False) 12 | current = models.BooleanField(default=True) 13 | date_created = models.DateTimeField(auto_now_add=True) 14 | date_modified = models.DateTimeField(auto_now=True) 15 | 16 | class Meta: 17 | verbose_name = "webmention" 18 | verbose_name_plural = "webmentions" 19 | 20 | def __str__(self): 21 | return self.source 22 | 23 | @display(description="source") 24 | def source_for_admin(self): 25 | return format_html('{}', self.source, self.source) 26 | 27 | @display(description="response to") 28 | def response_to_for_admin(self): 29 | return format_html('{}', self.response_to, self.response_to) 30 | 31 | def invalidate(self): 32 | if self.id: 33 | self.current = False 34 | self.save() 35 | 36 | def update(self, source, target, response_body): 37 | self.response_body = response_body 38 | self.source = source 39 | self.response_to = target 40 | self.current = True 41 | self.save() 42 | -------------------------------------------------------------------------------- /src/webmention/resolution.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from urllib.parse import urlparse 4 | 5 | try: 6 | from django.core.urlresolvers import resolve, Resolver404 7 | except ImportError: 8 | from django.urls import resolve, Resolver404 9 | 10 | 11 | def url_resolves(url): 12 | try: 13 | resolve(urlparse(url).path) 14 | except Resolver404: 15 | return False 16 | return True 17 | 18 | 19 | def fetch_and_validate_source(source, target): 20 | response = requests.get(source) 21 | if response.status_code == 200: 22 | if target in response.text: 23 | return response.text 24 | else: 25 | raise TargetNotFoundError("Source URL did not contain target URL") 26 | else: 27 | raise SourceFetchError("Could not fetch source URL") 28 | 29 | 30 | class SourceFetchError(Exception): 31 | pass 32 | 33 | 34 | class TargetNotFoundError(Exception): 35 | pass 36 | -------------------------------------------------------------------------------- /src/webmention/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import re_path 2 | 3 | from . import views 4 | 5 | 6 | app_name = "webmention" 7 | 8 | 9 | urlpatterns = [re_path(r"^receive$", views.receive, name="receive")] 10 | -------------------------------------------------------------------------------- /src/webmention/views.py: -------------------------------------------------------------------------------- 1 | from django.views.decorators.csrf import csrf_exempt 2 | from django.views.decorators.http import require_POST 3 | from django.http import HttpResponseBadRequest, HttpResponseServerError, HttpResponse 4 | 5 | from .models import WebMentionResponse 6 | from .resolution import url_resolves, fetch_and_validate_source, SourceFetchError, TargetNotFoundError 7 | 8 | 9 | @csrf_exempt 10 | @require_POST 11 | def receive(request): 12 | if "source" in request.POST and "target" in request.POST: 13 | source = request.POST.get("source") 14 | target = request.POST.get("target") 15 | webmention = None 16 | 17 | if not url_resolves(target): 18 | return HttpResponseBadRequest("Target URL did not resolve to a resource on the server") 19 | 20 | try: 21 | try: 22 | webmention = WebMentionResponse.objects.get(source=source, response_to=target) 23 | except WebMentionResponse.DoesNotExist: 24 | webmention = WebMentionResponse() 25 | 26 | response_body = fetch_and_validate_source(source, target) 27 | webmention.update(source, target, response_body) 28 | return HttpResponse("The webmention was successfully received", status=202) 29 | except (SourceFetchError, TargetNotFoundError) as e: 30 | if webmention: 31 | webmention.invalidate() 32 | return HttpResponseBadRequest(str(e)) 33 | except Exception as e: 34 | return HttpResponseServerError(str(e)) 35 | else: 36 | return HttpResponseBadRequest("webmention source and/or target not in request") 37 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/easy-as-python/django-webmention/2ff415291199a733f399388a30427d6469c614ee/tests/__init__.py -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = "key-for-testing" 2 | INSTALLED_APPS = ["webmention"] 3 | 4 | DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": "tests.sqlite3"}} 5 | 6 | ROOT_URLCONF = "tests.test_urls" 7 | -------------------------------------------------------------------------------- /tests/test_middleware.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | import pytest 4 | 5 | from django.urls import reverse 6 | from django.http import HttpResponse 7 | 8 | from webmention.middleware import WebMentionMiddleware 9 | 10 | 11 | @pytest.fixture 12 | def middleware(): 13 | return WebMentionMiddleware(get_response=Mock()) 14 | 15 | 16 | def test_process_request_creates_link_header(middleware): 17 | request = Mock() 18 | request.scheme = "http" 19 | request.META = {"HTTP_HOST": "example.com"} 20 | 21 | response = HttpResponse() 22 | response = middleware.process_response(request, response) 23 | 24 | expected_link_header = '<{scheme}://{host}{path}>; rel="webmention"'.format( 25 | scheme=request.scheme, host=request.META.get("HTTP_HOST"), path=reverse("webmention:receive") 26 | ) 27 | 28 | assert "Link" in response 29 | assert response["Link"] == expected_link_header 30 | 31 | 32 | def test_process_request_appends_link_header(middleware): 33 | request = Mock() 34 | request.scheme = "http" 35 | request.META = {"HTTP_HOST": "example.com"} 36 | 37 | response = HttpResponse() 38 | original_link_header = '; rel="meta"' 39 | response["Link"] = original_link_header 40 | response = middleware.process_response(request, response) 41 | 42 | new_link_header = '<{scheme}://{host}{path}>; rel="webmention"'.format( 43 | scheme=request.scheme, host=request.META.get("HTTP_HOST"), path=reverse("webmention:receive") 44 | ) 45 | 46 | expected_link_header = ", ".join((original_link_header, new_link_header)) 47 | 48 | assert "Link" in response 49 | assert response["Link"] == expected_link_header 50 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | import pytest 4 | 5 | from webmention.models import WebMentionResponse 6 | 7 | 8 | @pytest.fixture 9 | def test_response_body(): 10 | return "foo" 11 | 12 | 13 | @pytest.mark.django_db 14 | def test_str(test_source, test_target, test_response_body): 15 | webmention = WebMentionResponse.objects.create( 16 | source=test_source, response_to=test_target, response_body=test_response_body 17 | ) 18 | webmention.save() 19 | 20 | assert str(webmention) == webmention.source 21 | 22 | 23 | @pytest.mark.django_db 24 | def test_source_for_admin(test_source, test_target, test_response_body): 25 | webmention = WebMentionResponse.objects.create( 26 | source=test_source, response_to=test_target, response_body=test_response_body 27 | ) 28 | webmention.save() 29 | 30 | assert webmention.source_for_admin() == '{href}'.format(href=webmention.source) 31 | 32 | 33 | @pytest.mark.django_db 34 | def test_response_to_for_admin(test_source, test_target, test_response_body): 35 | webmention = WebMentionResponse.objects.create( 36 | source=test_source, response_to=test_target, response_body=test_response_body 37 | ) 38 | webmention.save() 39 | 40 | assert webmention.response_to_for_admin() == '{href}'.format(href=webmention.response_to) 41 | 42 | 43 | @patch("webmention.models.WebMentionResponse.save") 44 | def test_invalidate_when_not_previously_saved(mock_save): 45 | webmention = WebMentionResponse() 46 | webmention.invalidate() 47 | 48 | assert not mock_save.called 49 | 50 | 51 | @pytest.mark.django_db 52 | def test_invalidate_when_previously_saved(test_source, test_target, test_response_body): 53 | webmention = WebMentionResponse.objects.create( 54 | source=test_source, response_to=test_target, response_body=test_response_body 55 | ) 56 | webmention.save() 57 | webmention.invalidate() 58 | 59 | assert not webmention.current 60 | 61 | 62 | @patch("webmention.models.WebMentionResponse.save") 63 | def test_update_when_previously_invalid(mock_save, test_source, test_target, test_response_body): 64 | webmention = WebMentionResponse.objects.create(source="foo", response_to="bar", response_body="baz", current=False) 65 | assert mock_save.call_count == 1 66 | webmention.update(test_source, test_target, test_response_body) 67 | 68 | assert webmention.current 69 | assert webmention.source == test_source 70 | assert webmention.response_to == test_target 71 | assert webmention.response_body == test_response_body 72 | assert mock_save.call_count == 2 73 | -------------------------------------------------------------------------------- /tests/test_resolution.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock, patch 2 | 3 | import pytest 4 | 5 | try: 6 | from django.core.urlresolvers import Resolver404 7 | except ImportError: 8 | from django.urls import Resolver404 9 | 10 | from webmention.resolution import url_resolves, fetch_and_validate_source, SourceFetchError, TargetNotFoundError 11 | 12 | 13 | @patch("webmention.resolution.resolve") 14 | def test_url_resolves_when_resolves(mock_resolve, test_source, test_target): 15 | mock_resolve.return_value = "foo" 16 | assert url_resolves(test_target) 17 | 18 | 19 | @patch("webmention.resolution.resolve") 20 | def test_url_resolves_when_does_not_resolve(mock_resolve): 21 | mock_resolve.side_effect = Resolver404 22 | assert not url_resolves("http://example.com/page") 23 | 24 | 25 | @patch("requests.get") 26 | def test_fetch_and_validate_source_happy_path(mock_get, test_source, test_target): 27 | mock_response = Mock() 28 | mock_response.status_code = 200 29 | mock_response.text = '{href}'.format(href=test_target) 30 | mock_get.return_value = mock_response 31 | 32 | assert fetch_and_validate_source(test_source, test_target) == mock_response.text 33 | 34 | 35 | @patch("requests.get") 36 | def test_fetch_and_validate_source_when_source_unavailable(mock_get, test_source, test_target): 37 | mock_response = Mock() 38 | mock_response.status_code = 404 39 | mock_get.return_value = mock_response 40 | 41 | with pytest.raises(SourceFetchError): 42 | fetch_and_validate_source(test_source, test_target) 43 | 44 | 45 | @patch("requests.get") 46 | def test_fetch_and_validate_source_when_source_does_not_contain_target(mock_get, test_source, test_target): 47 | mock_response = Mock() 48 | mock_response.status_code = 200 49 | mock_response.text = "foo" 50 | mock_get.return_value = mock_response 51 | 52 | with pytest.raises(TargetNotFoundError): 53 | fetch_and_validate_source(test_source, test_target) 54 | -------------------------------------------------------------------------------- /tests/test_urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import re_path, include 2 | 3 | urlpatterns = [re_path(r"^webmention", include("webmention.urls", namespace="webmention"))] 4 | -------------------------------------------------------------------------------- /tests/test_views.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock, patch 2 | 3 | import pytest 4 | 5 | from django.http import HttpResponseBadRequest, HttpResponse, HttpResponseServerError 6 | 7 | from webmention.views import receive 8 | from webmention.resolution import SourceFetchError, TargetNotFoundError 9 | 10 | 11 | def test_receive_when_source_not_in_post_data(test_target): 12 | request = Mock() 13 | request.method = "POST" 14 | request.POST = {"target": test_target} 15 | 16 | response = receive(request) 17 | 18 | assert isinstance(response, HttpResponseBadRequest) 19 | 20 | 21 | def test_receive_when_target_not_in_post_data(test_source): 22 | request = Mock() 23 | request.method = "POST" 24 | request.POST = {"source": test_source} 25 | 26 | response = receive(request) 27 | 28 | assert isinstance(response, HttpResponseBadRequest) 29 | 30 | 31 | @patch("webmention.views.url_resolves") 32 | def test_receive_when_target_does_not_resolve(mock_url_resolves, test_source, test_target): 33 | request = Mock() 34 | request.method = "POST" 35 | request.POST = {"source": test_source, "target": test_target} 36 | 37 | mock_url_resolves.return_value = False 38 | response = receive(request) 39 | 40 | mock_url_resolves.assert_called_once_with(test_target) 41 | assert isinstance(response, HttpResponseBadRequest) 42 | 43 | 44 | @pytest.mark.django_db 45 | @patch("webmention.views.WebMentionResponse.update") 46 | @patch("webmention.views.fetch_and_validate_source") 47 | @patch("webmention.views.url_resolves") 48 | def test_receive_happy_path(mock_url_resolves, mock_fetch_and_validate_source, mock_update, test_source, test_target): 49 | request = Mock() 50 | request.method = "POST" 51 | request.POST = {"source": test_source, "target": test_target} 52 | 53 | mock_url_resolves.return_value = True 54 | mock_fetch_and_validate_source.return_value = "foo" 55 | response = receive(request) 56 | 57 | mock_fetch_and_validate_source.assert_called_once_with(test_source, test_target) 58 | mock_update.assert_called_once_with(test_source, test_target, mock_fetch_and_validate_source.return_value) 59 | mock_url_resolves.assert_called_once_with(test_target) 60 | assert isinstance(response, HttpResponse) 61 | 62 | 63 | @pytest.mark.django_db 64 | @patch("webmention.views.WebMentionResponse.invalidate") 65 | @patch("webmention.views.fetch_and_validate_source") 66 | @patch("webmention.views.url_resolves") 67 | def test_receive_when_source_unavailable( 68 | mock_url_resolves, mock_fetch_and_validate_source, mock_invalidate, test_source, test_target 69 | ): 70 | request = Mock() 71 | request.method = "POST" 72 | request.POST = {"source": test_source, "target": test_target} 73 | 74 | mock_url_resolves.return_value = True 75 | mock_fetch_and_validate_source.side_effect = SourceFetchError 76 | response = receive(request) 77 | 78 | mock_fetch_and_validate_source.assert_called_once_with(test_source, test_target) 79 | mock_url_resolves.assert_called_once_with(test_target) 80 | assert mock_invalidate.call_count == 1 81 | assert isinstance(response, HttpResponseBadRequest) 82 | 83 | 84 | @pytest.mark.django_db 85 | @patch("webmention.views.WebMentionResponse.invalidate") 86 | @patch("webmention.views.fetch_and_validate_source") 87 | @patch("webmention.views.url_resolves") 88 | def test_receive_when_source_does_not_contain_target( 89 | mock_url_resolves, mock_fetch_and_validate_source, mock_invalidate, test_source, test_target 90 | ): 91 | request = Mock() 92 | request.method = "POST" 93 | request.POST = {"source": test_source, "target": test_target} 94 | 95 | mock_url_resolves.return_value = True 96 | mock_fetch_and_validate_source.side_effect = TargetNotFoundError 97 | response = receive(request) 98 | 99 | mock_fetch_and_validate_source.assert_called_once_with(test_source, test_target) 100 | mock_url_resolves.assert_called_once_with(test_target) 101 | assert mock_invalidate.call_count == 1 102 | assert isinstance(response, HttpResponseBadRequest) 103 | 104 | 105 | @pytest.mark.django_db 106 | @patch("webmention.views.fetch_and_validate_source") 107 | @patch("webmention.views.url_resolves") 108 | def test_receive_when_general_exception_occurs( 109 | mock_url_resolves, mock_fetch_and_validate_source, test_source, test_target 110 | ): 111 | request = Mock() 112 | request.method = "POST" 113 | request.POST = {"source": test_source, "target": test_target} 114 | 115 | mock_url_resolves.return_value = True 116 | mock_fetch_and_validate_source.side_effect = Exception 117 | response = receive(request) 118 | 119 | mock_fetch_and_validate_source.assert_called_once_with(test_source, test_target) 120 | mock_url_resolves.assert_called_once_with(test_target) 121 | assert isinstance(response, HttpResponseServerError) 122 | --------------------------------------------------------------------------------