├── .editorconfig ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── MANIFEST.in ├── README.rst ├── SECURITY.md ├── docs ├── Makefile ├── __init__.py ├── conf.py ├── index.rst ├── make.bat ├── setup.rst ├── spelling_wordlist.txt └── usage.rst ├── pyproject.toml ├── requirements.txt ├── runtests.py ├── snakeoil ├── __init__.py ├── admin.py ├── apps.py ├── jinja2.py ├── jinja2 │ └── snakeoil │ │ └── seo.jinja2 ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20200727_0951.py │ └── __init__.py ├── models.py ├── py.typed ├── templates │ └── snakeoil │ │ └── seo.html ├── templatetags │ ├── __init__.py │ └── snakeoil.py ├── types.py └── utils.py └── tests ├── __init__.py ├── data └── kitties.jpg ├── jinja2.py ├── jinja2 └── base.jinja2 ├── migrations ├── 0001_initial.py └── __init__.py ├── models.py ├── settings.py ├── templates ├── base.html └── tests │ ├── article_detail.html │ └── article_detail_without_obj.html ├── test_jinja2.py ├── test_models.py ├── test_template_tags.py ├── urls.py └── views.py /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 4 7 | indent_style = space 8 | insert_final_newline = true 9 | 10 | [*.{html,jinja2,yaml,yml}] 11 | indent_size = 2 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "daily" 8 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: 8 | - main 9 | release: 10 | types: 11 | - published 12 | 13 | concurrency: 14 | group: ${{ github.workflow }}-${{ github.ref }} 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | docs: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/setup-python@v5 22 | with: 23 | python-version: "3.13" 24 | - uses: actions/checkout@v4 25 | - run: | 26 | python -m pip install -U pip wheel 27 | python -m pip install -e . 28 | python -m pip install sphinx sphinx-rtd-theme sphinxcontrib-spelling 29 | - run: | 30 | cd docs 31 | sphinx-build -b spelling -n -d _build/doctrees . _build/spelling 32 | 33 | SQLite: 34 | needs: [docs] 35 | runs-on: ubuntu-latest 36 | strategy: 37 | matrix: 38 | django-version: ["4.2", "5.1"] 39 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 40 | exclude: 41 | - django-version: "5.1" 42 | python-version: "3.9" 43 | steps: 44 | - name: Set up Python ${{ matrix.python-version }} 45 | uses: actions/setup-python@v5 46 | with: 47 | python-version: ${{ matrix.python-version }} 48 | - run: sudo apt-get update && sudo apt-get install libmemcached-dev 49 | - uses: actions/checkout@v4 50 | - run: | 51 | python -m pip install -U pip wheel 52 | pip install -e . 53 | python -m pip install jinja2 pillow 54 | python -m pip install django~=${{ matrix.django-version }} 55 | - run: python runtests.py 56 | 57 | PostgreSQL: 58 | needs: [docs] 59 | runs-on: ubuntu-latest 60 | strategy: 61 | matrix: 62 | django-version: ["4.2", "5.1"] 63 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 64 | exclude: 65 | - django-version: "5.1" 66 | python-version: "3.9" 67 | services: 68 | postgres: 69 | image: postgres 70 | env: 71 | POSTGRES_USER: snakeoil 72 | POSTGRES_PASSWORD: snakeoil 73 | ports: 74 | - 5432/tcp 75 | options: >- 76 | --health-cmd pg_isready 77 | --health-interval 10s 78 | --health-timeout 5s 79 | --health-retries 5 80 | steps: 81 | - name: Set up Python ${{ matrix.python-version }} 82 | uses: actions/setup-python@v5 83 | with: 84 | python-version: ${{ matrix.python-version }} 85 | - uses: actions/checkout@v4 86 | - run: | 87 | python -m pip install -U pip wheel 88 | python -m pip install -e . 89 | python -m pip install jinja2 pillow psycopg 90 | python -m pip install django~=${{ matrix.django-version }} 91 | - run: python runtests.py 92 | env: 93 | DB: postgres 94 | DB_PORT: ${{ job.services.postgres.ports[5432] }} 95 | 96 | MySQL: 97 | needs: [docs] 98 | runs-on: ubuntu-latest 99 | strategy: 100 | matrix: 101 | django-version: ["4.2", "5.1"] 102 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 103 | exclude: 104 | - django-version: "5.1" 105 | python-version: "3.9" 106 | services: 107 | mysql: 108 | image: mysql 109 | ports: 110 | - 3306 111 | options: >- 112 | --health-cmd="mysqladmin ping" 113 | --health-interval=10s 114 | --health-timeout=5s 115 | --health-retries=5 116 | env: 117 | MYSQL_ROOT_PASSWORD: snakeoil 118 | steps: 119 | - name: Set up Python ${{ matrix.python-version }} 120 | uses: actions/setup-python@v5 121 | with: 122 | python-version: ${{ matrix.python-version }} 123 | - run: sudo apt-get update && sudo apt-get install libmysqlclient-dev 124 | - uses: actions/checkout@v4 125 | - run: | 126 | python -m pip install -U pip wheel 127 | python -m pip install -e . 128 | python -m pip install jinja2 pillow mysqlclient 129 | python -m pip install django~=${{ matrix.django-version }} 130 | - run: python runtests.py 131 | env: 132 | DB: mysql 133 | DB_PORT: ${{ job.services.mysql.ports['3306'] }} 134 | 135 | MariaDB: 136 | needs: [docs] 137 | runs-on: ubuntu-latest 138 | strategy: 139 | matrix: 140 | django-version: ["4.2", "5.1"] 141 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 142 | exclude: 143 | - django-version: "5.1" 144 | python-version: "3.9" 145 | services: 146 | mariadb: 147 | image: mariadb 148 | ports: 149 | - 3306 150 | options: >- 151 | --health-cmd="mariadb-admin ping" 152 | --health-interval=10s 153 | --health-timeout=5s 154 | --health-retries=5 155 | env: 156 | MYSQL_ROOT_PASSWORD: snakeoil 157 | steps: 158 | - name: Set up Python ${{ matrix.python-version }} 159 | uses: actions/setup-python@v5 160 | with: 161 | python-version: ${{ matrix.python-version }} 162 | - run: sudo apt-get update && sudo apt-get install libmariadb-dev 163 | - uses: actions/checkout@v4 164 | - run: | 165 | python -m pip install -U pip wheel 166 | pip install -e . 167 | python -m pip install jinja2 pillow mysqlclient 168 | python -m pip install django~=${{ matrix.django-version }} 169 | - run: python runtests.py 170 | env: 171 | DB: mariadb 172 | DB_PORT: ${{ job.services.mariadb.ports['3306'] }} 173 | 174 | release: 175 | if: ${{ github.event_name == 'release' }} 176 | runs-on: ubuntu-latest 177 | needs: [docs, SQLite, PostgreSQL, MySQL, MariaDB] 178 | environment: 179 | name: pypi 180 | url: https://pypi.org/p/django-snakeoil 181 | permissions: 182 | id-token: write 183 | steps: 184 | - name: Set up Python 185 | uses: actions/setup-python@v5 186 | with: 187 | python-version: "3.13" 188 | - name: Check out code 189 | uses: actions/checkout@v4 190 | - name: Install dependencies 191 | run: python -m pip install -U pip wheel build 192 | - name: Build package 193 | run: python -m build 194 | - name: Publish package 195 | uses: pypa/gh-action-pypi-publish@release/v1 196 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | dist/ 3 | django_snakeoil.egg-info/ 4 | docs/_build/ 5 | tests/media/ 6 | tests/test.sqlite3 7 | .coverage 8 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: check-added-large-files 6 | - id: check-ast 7 | - id: check-json 8 | - id: check-merge-conflict 9 | - id: check-toml 10 | - id: check-yaml 11 | - id: debug-statements 12 | - id: end-of-file-fixer 13 | - id: trailing-whitespace 14 | 15 | - repo: https://github.com/adamchainz/django-upgrade 16 | rev: "1.25.0" 17 | hooks: 18 | - id: django-upgrade 19 | args: [--target-version, "4.2"] 20 | 21 | - repo: https://github.com/astral-sh/ruff-pre-commit 22 | rev: v0.11.12 23 | hooks: 24 | - id: ruff 25 | args: [--fix] 26 | - id: ruff-format 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Tom Carrick 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | include snakeoil/py.typed 4 | recursive-include docs * 5 | recursive-include snakeoil/jinja2 * 6 | recursive-include snakeoil/templates * 7 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | django-snakeoil 3 | =============== 4 | 5 | django-snakeoil helps manage your ```` tags. It works on all supported 6 | Django versions and databases. 7 | 8 | It offers full internationalization support (tags for multiple languages), 9 | content set dynamically from object attributes, automatic Opengraph image 10 | width and heights for ``ImageField``, and more. 11 | 12 | `Full documentation `_ 13 | 14 | Getting started 15 | =============== 16 | 17 | To install, ``pip install django-snakeoil`` or use your favourite package 18 | manager. 19 | 20 | You can use Snakeoil in two ways. If you'd like to attach metadata to an 21 | object, you can use the model abstract base class: 22 | 23 | .. code-block:: python 24 | 25 | from snakeoil.models import SEOModel 26 | 27 | class Article(SEOModel): 28 | title = models.CharField(max_length=200) 29 | author = models.ForeignKey(User, on_delete=models.CASCADE) 30 | main_image = models.Imagefield(blank=True, null=True) 31 | 32 | @property 33 | def author_name(self): 34 | return 35 | 36 | @property 37 | def snakeoil_metadata(self): 38 | metadata = { 39 | "default": [ 40 | { 41 | "name": "author", 42 | "content": self.author.get_full_name(), 43 | }, 44 | {"property": "og:title", "content": self.title}, 45 | ] 46 | } 47 | if self.main_image: 48 | metadata["default"].append( 49 | {"property": "og:image", "attribute": "main_image"} 50 | ) 51 | return metadata 52 | 53 | You can also override these tags in the admin per-object. 54 | 55 | For situations where you can't change the model (flatpages, third party apps) 56 | or don't have one at all, there is an ``SEOPath`` model that maps paths to 57 | your meta tags. 58 | 59 | Tags are added in the admin (or however else you like) as JSON. For example: 60 | 61 | .. code-block:: JSON 62 | 63 | { 64 | "default": [ 65 | {"name": "description", "property": "og:description", "content": "Meta description"}, 66 | {"property": "og:title", "content": "My blog post"}, 67 | {"name": "author", "attribute": "author_name"}, 68 | {"property": "og:image", "static": "img/default.jpg"} 69 | ] 70 | } 71 | 72 | Where ``default`` will work for any language. You can replace ``default`` 73 | with a language code, e.g. "nl_NL", and these tags will only display if the 74 | current language is Dutch. This will generate something like: 75 | 76 | .. code-block:: html 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | Note that when using ``static``, width and height are not added, but you may 86 | add these yourself. For ``ImageField``, this will be added automatically: 87 | 88 | .. code-block:: JSON 89 | 90 | { 91 | "default": [ 92 | {"property": "og:image", "attribute": "main_image"} 93 | ] 94 | } 95 | 96 | Results in: 97 | 98 | .. code-block:: html 99 | 100 | 101 | 102 | 103 | 104 | Django Templates 105 | ---------------- 106 | 107 | Add ``snakeoil`` to your ``INSTALLED_APPS``: 108 | 109 | .. code-block:: python 110 | 111 | INSTALLED_APPS = [ 112 | "snakeoil", 113 | # ... 114 | ] 115 | 116 | In your base template, add this where you want the tags to appear: 117 | 118 | .. code-block:: html 119 | 120 | {% load snakeoil %} 121 | {% block head %} 122 | {% meta %} 123 | {% endblock %} 124 | 125 | This will automatically find an object based on the ``get_absolute_url()`` 126 | of your model, by looking in the request context. If nothing is found, 127 | snakeoil will check for an ``SEOPath`` object for the current path. If 128 | you have an object, it is recommended to pass it into the tag directly 129 | to short-circuit the tag finding mechanisms: 130 | 131 | .. code-block:: html 132 | 133 | {% meta my_obj %} 134 | 135 | Jinja2 136 | ------ 137 | 138 | Set your environment: 139 | 140 | .. code-block:: python 141 | 142 | from jinja2 import Environment 143 | from snakeoil.jinja2 import get_meta_tags 144 | 145 | def environment(**options): 146 | env = Environment(**options) 147 | env.globals.update( 148 | { 149 | "get_meta_tags": get_meta_tags, 150 | # ... 151 | } 152 | ) 153 | return env 154 | 155 | In your template: 156 | 157 | .. code-block:: html 158 | 159 | {% block meta %} 160 | {% with meta_tags=get_meta_tags() %} 161 | {% include "snakeoil/seo.jinja2" %} 162 | {% endwith %} 163 | {% endblock meta %} 164 | 165 | To pass in an object: 166 | 167 | .. code-block:: html 168 | 169 | {% block meta %} 170 | {% with meta_tags=get_meta_tags(my_object) %} 171 | {% include "snakeoil/seo.jinja2" %} 172 | {% endwith %} 173 | {% endblock meta %} 174 | 175 | Notes 176 | ===== 177 | 178 | Thanks to kezabelle for the name. For those wondering: 179 | 180 | Metadata is often used for SEO purposes. A lot of people (rightly or not) 181 | consider SEO to be snakeoil. Also, SnakEOil. Very clever, I know. 182 | 183 | The old version of django-snakeoil can be found on the ``old`` branch, but 184 | won't be updated. 185 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Only the latest version is supported. This version supports all versions of Python and 6 | Django that are in support themselves. 7 | 8 | ## Reporting a Vulnerability 9 | 10 | If you find a security issue in this repo, please contact me directly at tom@carrick.eu 11 | 12 | I'll respond within a week and make sure it gets fixed and pushed out promptly. 13 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knyghty/django-snakeoil/a5284777d0c157f8d76ee36a46065166a052e4d9/docs/__init__.py -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | import sphinx_rtd_theme # noqa: F401 2 | 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file only contains a selection of the most common options. For a full 6 | # list see the documentation: 7 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 8 | 9 | # -- Path setup -------------------------------------------------------------- 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | # 15 | # import os 16 | # import sys 17 | # sys.path.insert(0, os.path.abspath('.')) 18 | 19 | 20 | # -- Project information ----------------------------------------------------- 21 | 22 | project = "django-snakeoil" 23 | copyright = "2020, Tom Carrick" 24 | author = "Tom Carrick" 25 | 26 | 27 | # -- General configuration --------------------------------------------------- 28 | 29 | # Add any Sphinx extension module names here, as strings. They can be 30 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 31 | # ones. 32 | extensions = ["sphinxcontrib.spelling", "sphinx_rtd_theme"] 33 | 34 | # Add any paths that contain templates here, relative to this directory. 35 | templates_path = ["_templates"] 36 | 37 | # List of patterns, relative to source directory, that match files and 38 | # directories to ignore when looking for source files. 39 | # This pattern also affects html_static_path and html_extra_path. 40 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 41 | 42 | master_doc = "index" 43 | 44 | 45 | # -- Options for HTML output ------------------------------------------------- 46 | 47 | # The theme to use for HTML and HTML Help pages. See the documentation for 48 | # a list of builtin themes. 49 | # 50 | html_theme = "sphinx_rtd_theme" 51 | 52 | # Add any paths that contain custom static files (such as style sheets) here, 53 | # relative to this directory. They are copied after the builtin static files, 54 | # so a file named "default.css" will overwrite the builtin "default.css". 55 | html_static_path = ["_static"] 56 | 57 | 58 | # Spelling. 59 | spelling_lang = "en_GB" 60 | tokenizer_lang = "en_US" 61 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | django-snakeoil 2 | =============== 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | :caption: Contents: 7 | 8 | setup 9 | usage 10 | 11 | Indices and tables 12 | ================== 13 | 14 | * :ref:`genindex` 15 | * :ref:`modindex` 16 | * :ref:`search` 17 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/setup.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Setup 3 | ===== 4 | 5 | Installation 6 | ============ 7 | 8 | To install:: 9 | 10 | pip install django-snakeoil 11 | 12 | Configuration 13 | ============= 14 | 15 | If you're using Django templates, add ``snakeoil`` to your installed apps:: 16 | 17 | INSTALLED_APPS = [ 18 | "myapp", 19 | "snakeoil", 20 | # ... 21 | ] 22 | 23 | If you're using Jinja2, you need to add the ``get_meta_tags`` function to 24 | your environment:: 25 | 26 | from jinja2 import Environment 27 | from snakeoil.jinja2 import get_meta_tags 28 | 29 | def environment(**options): 30 | env = Environment(**options) 31 | env.globals.update( 32 | { 33 | "get_meta_tags": get_meta_tags, 34 | # ... 35 | } 36 | ) 37 | return env 38 | -------------------------------------------------------------------------------- /docs/spelling_wordlist.txt: -------------------------------------------------------------------------------- 1 | django 2 | snakeoil 3 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Usage 3 | ===== 4 | 5 | There are several ways to use snakeoil. 6 | 7 | Global metadata 8 | =============== 9 | 10 | Usually, you will have some metadata that will be the same on every page, 11 | or at least a majority of them. You can set these in your ``settings.py``:: 12 | 13 | SNAKEOIL_DEFAULT_TAGS={ 14 | "default": [ 15 | {"name": "author", "content": "Tom Carrick"}, 16 | {"property": "og:site_name", "content": "My Website"}, 17 | {"property": "og:type", "content": "website"} 18 | ] 19 | "eo": [ 20 | {"property": "og:site_name", "content": "Mia Ratejo"}, 21 | ] 22 | } 23 | 24 | .. note:: 25 | The ``default`` key here is for the language. If you're not using 26 | internationalization, you need only use the ``default`` key. 27 | 28 | This will set the Open Graph site name property to ``Mia Ratejo`` if the 29 | page is requested in Esperanto, or ``My Website`` for any other language. 30 | Additionally, the ``meta author`` tag will be set to ``Tom Carrick`` on 31 | every page. 32 | 33 | It's possible to set both the meta ``name`` and ``property`` for the same 34 | tag:: 35 | 36 | { 37 | "default": [ 38 | { 39 | "name": "description", 40 | "property": "og:description", 41 | "content": "My meta description.", 42 | } 43 | ] 44 | 45 | Per-object metadata 46 | =================== 47 | 48 | Metadata can be set and overridden per-object. 49 | 50 | First, inherit your model from ``SEOModel``:: 51 | 52 | from snakeoil.models import SEOModel 53 | 54 | class Article(SEOModel): 55 | title = models.CharField(max_length=200) 56 | author = models.ForeignKey(User, on_delete=models.CASCADE) 57 | main_image = models.Imagefield() 58 | 59 | def get_absolute_url(self): 60 | # Snakeoil will use this to find the object 61 | # if you don't pass it in. 62 | return reverse("...") 63 | 64 | @property 65 | def author_name(self): 66 | return self.author.get_full_name() 67 | 68 | 69 | Then you can build a JSON object (perhaps in a code-editor) and add this 70 | into the ``meta_tags`` field of the object in the Django Admin, or with 71 | code. For example: 72 | 73 | .. code-block:: JSON 74 | 75 | { 76 | "default": [ 77 | {"property": "og:type", "content": "article"} 78 | ] 79 | } 80 | 81 | This will override the type from ``website`` (defined in the global config) 82 | to ``article``. 83 | 84 | Setting metadata from object attributes 85 | --------------------------------------- 86 | 87 | You can also set metadata from object attributes with the ``attribute`` 88 | key: 89 | 90 | .. code-block:: JSON 91 | 92 | { 93 | "default": [ 94 | {"name": "author", "attribute": "author"}, 95 | {"property": "og:image", "attribute": "main_image"}, 96 | {"property": "og:title", "attribute": "title"} 97 | ] 98 | } 99 | 100 | For images using ``ImageField`` in an ``og:image``, this will automatically 101 | populate the ``og:image:width`` and ``og:image:height`` properties. 102 | 103 | Setting metadata dynamically 104 | ---------------------------- 105 | 106 | Usually, your models will have some metadata stored as model fields or 107 | attributes, and it's a lot of effort to override this for every obejct. 108 | To set per-model metadata based on object attributes, you can define a 109 | property called ``snakeoil_metadata`` on your model: 110 | 111 | .. code-block:: python 112 | 113 | from snakeoil.models import SEOModel 114 | 115 | class Article(SEOModel): 116 | title = models.CharField(max_length=200) 117 | author = models.ForeignKey(User, on_delete=models.CASCADE) 118 | main_image = models.Imagefield(blank=True, null=True) 119 | 120 | @property 121 | def author_name(self): 122 | return 123 | 124 | @property 125 | def snakeoil_metadata(self): 126 | metadata = { 127 | "default": [ 128 | { 129 | "name": "author", 130 | "content": self.author.get_full_name(), 131 | }, 132 | {"property": "og:title", "content": self.title}, 133 | ] 134 | } 135 | if self.main_image: 136 | metadata["default"].append( 137 | {"property": "og:image", "attribute": "main_image"} 138 | ) 139 | return metadata 140 | 141 | .. note:: 142 | It's important to use ``attribute`` for ``og:image`` so the height and 143 | and width can be set automatically. 144 | 145 | Per-URL metadata 146 | ================ 147 | 148 | Sometimes you don't have an object, or can't add anything to it, if for 149 | example you're using ``django.contrib.flatpages`` or are using static views. 150 | For this, you can use the ``SEOPath`` model, added to the Django admin. 151 | 152 | Using static files 153 | ================== 154 | 155 | You can also get files by their static path. However, this won't 156 | auatomatically add ``og:image:width`` and ``og:image:height`` properties, 157 | so these need to be added manually if needed: 158 | 159 | .. code-block:: JSON 160 | 161 | { 162 | "default": [ 163 | {"property": "og:image", "static": "img/default_image.jpg"}, 164 | {"property": "og:image:width", "content": "600"}, 165 | {"property": "og:image:height", "content": "480"}, 166 | ] 167 | } 168 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | authors = [{ "name" = "Tom Carrick", "email" = "tom@carrick.eu" }] 7 | classifiers = [ 8 | "Development Status :: 5 - Production/Stable", 9 | "Environment :: Web Environment", 10 | "Framework :: Django", 11 | "Framework :: Django :: 4.2", 12 | "Framework :: Django :: 5.1", 13 | "Intended Audience :: Developers", 14 | "License :: OSI Approved :: MIT License", 15 | "Operating System :: OS Independent", 16 | "Programming Language :: Python :: 3 :: Only", 17 | "Programming Language :: Python :: 3.9", 18 | "Programming Language :: Python :: 3.10", 19 | "Programming Language :: Python :: 3.11", 20 | "Programming Language :: Python :: 3.12", 21 | "Programming Language :: Python :: 3.13", 22 | "Topic :: Internet :: WWW/HTTP :: Site Management", 23 | ] 24 | dependencies = ["django"] 25 | description = "Simple SEO & meta tag management for Django" 26 | keywords = ["django", "seo", "meta"] 27 | license = { "file" = "LICENSE" } 28 | name = "django-snakeoil" 29 | requires-python = ">=3.9" 30 | readme = "README.rst" 31 | version = "2.0" 32 | 33 | [project.urls] 34 | Documentation = "https://django-snakeoil.readthedocs.io/en/latest/" 35 | Homepage = "https://github.com/knyghty/django-snakeoil" 36 | Repository = "https://github.com/knyghty/django-snakeoil" 37 | 38 | [tool.coverage.run] 39 | branch = true 40 | source = ["snakeoil"] 41 | 42 | [tool.mypy] 43 | files = ["snakeoil"] 44 | plugins = ["mypy_django_plugin.main"] 45 | strict = true 46 | 47 | [tool.django-stubs] 48 | django_settings_module = "tests.settings" 49 | 50 | [tool.ruff] 51 | target-version = "py39" 52 | 53 | [tool.ruff.lint] 54 | select = ["E", "F", "I", "UP"] 55 | 56 | [tool.ruff.lint.isort] 57 | force-single-line = true 58 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Development requirements. 2 | coverage 3 | django 4 | django-stubs 5 | pillow 6 | mypy 7 | pre-commit 8 | rstcheck 9 | sphinx 10 | sphinx-rtd-theme 11 | sphinxcontrib-spelling 12 | twine 13 | wheel 14 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | import django 6 | from django.test.runner import DiscoverRunner 7 | 8 | if __name__ == "__main__": 9 | os.environ["DJANGO_SETTINGS_MODULE"] = "tests.settings" 10 | django.setup() 11 | test_runner = DiscoverRunner(verbosity=3) 12 | failures = test_runner.run_tests([]) 13 | sys.exit(failures) 14 | -------------------------------------------------------------------------------- /snakeoil/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knyghty/django-snakeoil/a5284777d0c157f8d76ee36a46065166a052e4d9/snakeoil/__init__.py -------------------------------------------------------------------------------- /snakeoil/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import SEOPath 4 | 5 | admin.site.register(SEOPath) 6 | -------------------------------------------------------------------------------- /snakeoil/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class SnakeoilConfig(AppConfig): 5 | default_auto_field = "django.db.models.AutoField" 6 | name = "snakeoil" 7 | verbose_name = "Snakeoil" 8 | -------------------------------------------------------------------------------- /snakeoil/jinja2.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | import jinja2 4 | from django.db import models 5 | from django.template import Context 6 | 7 | from . import types 8 | from . import utils 9 | 10 | 11 | @jinja2.pass_context 12 | def get_meta_tags( 13 | context: jinja2.runtime.Context, obj: typing.Optional[models.Model] = None 14 | ) -> types.MetaTagList: 15 | # Jinja2's context doesn't have Django's `flatten()` method, 16 | # so we convert it to a Django context to get this. 17 | return utils.get_meta_tags(Context(context), obj)["meta_tags"] 18 | -------------------------------------------------------------------------------- /snakeoil/jinja2/snakeoil/seo.jinja2: -------------------------------------------------------------------------------- 1 | {% for tag in meta_tags %} 2 | 7 | {% endfor %} 8 | -------------------------------------------------------------------------------- /snakeoil/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | from django.db import models 3 | 4 | 5 | class Migration(migrations.Migration): 6 | initial = True 7 | 8 | dependencies: list[tuple[str, str]] = [] 9 | 10 | operations = [ 11 | migrations.CreateModel( 12 | name="SEOPath", 13 | fields=[ 14 | ("meta_tags", models.JSONField(default=dict, verbose_name="meta tags")), 15 | ( 16 | "path", 17 | models.CharField( 18 | max_length=255, 19 | primary_key=True, 20 | serialize=False, 21 | verbose_name="path", 22 | ), 23 | ), 24 | ], 25 | options={ 26 | "verbose_name": "SEO path", 27 | "verbose_name_plural": "SEO paths", 28 | }, 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /snakeoil/migrations/0002_auto_20200727_0951.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | from django.db import models 3 | 4 | 5 | class Migration(migrations.Migration): 6 | dependencies = [ 7 | ("snakeoil", "0001_initial"), 8 | ] 9 | 10 | operations = [ 11 | migrations.AlterField( 12 | model_name="seopath", 13 | name="meta_tags", 14 | field=models.JSONField(blank=True, default=dict, verbose_name="meta tags"), 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /snakeoil/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knyghty/django-snakeoil/a5284777d0c157f8d76ee36a46065166a052e4d9/snakeoil/migrations/__init__.py -------------------------------------------------------------------------------- /snakeoil/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class SEOModel(models.Model): 6 | meta_tags = models.JSONField(blank=True, default=dict, verbose_name=_("meta tags")) 7 | 8 | class Meta: 9 | abstract = True 10 | 11 | 12 | class SEOPath(SEOModel): 13 | path = models.CharField(primary_key=True, max_length=255, verbose_name=_("path")) 14 | 15 | class Meta: 16 | verbose_name = _("SEO path") 17 | verbose_name_plural = _("SEO paths") 18 | 19 | def __str__(self) -> str: 20 | return self.path 21 | -------------------------------------------------------------------------------- /snakeoil/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knyghty/django-snakeoil/a5284777d0c157f8d76ee36a46065166a052e4d9/snakeoil/py.typed -------------------------------------------------------------------------------- /snakeoil/templates/snakeoil/seo.html: -------------------------------------------------------------------------------- 1 | {% for tag in meta_tags %} 2 | 7 | {% endfor %} 8 | -------------------------------------------------------------------------------- /snakeoil/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knyghty/django-snakeoil/a5284777d0c157f8d76ee36a46065166a052e4d9/snakeoil/templatetags/__init__.py -------------------------------------------------------------------------------- /snakeoil/templatetags/snakeoil.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from django import template 4 | from django.db.models import Model 5 | 6 | from .. import types 7 | from .. import utils 8 | 9 | register = template.Library() 10 | 11 | 12 | @register.inclusion_tag("snakeoil/seo.html", takes_context=True) 13 | def meta( 14 | context: template.Context, obj: Optional[Model] = None 15 | ) -> types.MetaTagContext: 16 | return utils.get_meta_tags(context, obj) 17 | -------------------------------------------------------------------------------- /snakeoil/types.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | MetaTagKey = typing.Literal["attribute", "content", "name", "property", "static"] 4 | MetaTag = dict[MetaTagKey, str] 5 | MetaTagList = list[MetaTag] 6 | MetaTagLanguageList = dict[str, MetaTagList] 7 | MetaTagContext = dict[typing.Literal["meta_tags"], MetaTagList] 8 | -------------------------------------------------------------------------------- /snakeoil/utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Optional 3 | from urllib.parse import urljoin 4 | 5 | from django import template 6 | from django.conf import settings 7 | from django.db import models 8 | from django.db.models.fields.files import ImageFieldFile 9 | from django.http import HttpRequest 10 | from django.templatetags.static import static 11 | from django.utils.translation import get_language 12 | 13 | from . import types 14 | from .models import SEOPath 15 | 16 | logger = logging.getLogger(__name__) 17 | register = template.Library() 18 | 19 | 20 | def _get_meta_tags_from_context( 21 | context: template.Context, path: str 22 | ) -> tuple[Optional[models.Model], types.MetaTagLanguageList]: 23 | flat_context = context.flatten() 24 | for obj in flat_context.values(): 25 | if ( 26 | isinstance(obj, models.Model) 27 | and hasattr(obj, "get_absolute_url") 28 | and obj.get_absolute_url() == path 29 | ): 30 | return (obj, getattr(obj, "meta_tags", {})) 31 | return (None, {}) 32 | 33 | 34 | def _get_meta_tags_for_path(path: str) -> types.MetaTagLanguageList: 35 | return getattr(SEOPath.objects.filter(path=path).first(), "meta_tags", {}) 36 | 37 | 38 | def _override_tags( 39 | base_tags: types.MetaTagList, overriding_tags: types.MetaTagList 40 | ) -> types.MetaTagList: 41 | output = [] 42 | for base_tag in base_tags: 43 | for overriding_tag in overriding_tags: 44 | if base_tag.get("name") == overriding_tag.get("name") and base_tag.get( 45 | "property" 46 | ) == overriding_tag.get("property"): 47 | output.append(overriding_tag) 48 | overriding_tags.remove(overriding_tag) 49 | break 50 | else: 51 | output.append(base_tag) 52 | output.extend(overriding_tags) 53 | return output 54 | 55 | 56 | def _collate_meta_tags( 57 | meta_tags: types.MetaTagLanguageList, default_tags: types.MetaTagLanguageList 58 | ) -> types.MetaTagLanguageList: 59 | collated_tags = default_tags 60 | for language, tags in meta_tags.items(): 61 | # Simple case, if the language isn't in the tags, dump them all in. 62 | if language not in collated_tags: 63 | collated_tags[language] = tags 64 | continue 65 | collated_tags[language] = _override_tags( 66 | collated_tags[language], meta_tags[language] 67 | ) 68 | return collated_tags 69 | 70 | 71 | def _get_meta_tags_for_language( 72 | meta_tags: types.MetaTagLanguageList, 73 | ) -> types.MetaTagList: 74 | if not settings.USE_I18N: 75 | return meta_tags.get("default", []) 76 | 77 | language = get_language() 78 | if not language: 79 | return meta_tags.get("default", []) 80 | 81 | if "_" in language: 82 | language_tag = language[:2] 83 | specific_language_meta_tags = meta_tags.get(language, []) 84 | general_language_meta_tags = meta_tags.get(language_tag, []) 85 | all_language_meta_tags = _override_tags( 86 | general_language_meta_tags, specific_language_meta_tags 87 | ) 88 | else: 89 | all_language_meta_tags = meta_tags.get(language, []) 90 | return _override_tags(meta_tags.get("default", []), all_language_meta_tags) 91 | 92 | 93 | def _get_image_dimensions( 94 | obj: models.Model, field_file: ImageFieldFile 95 | ) -> tuple[str, str]: 96 | field = field_file.field 97 | if field.width_field: # type: ignore 98 | width = getattr(obj, field.width_field, field_file.width) # type: ignore 99 | else: 100 | width = field_file.width 101 | 102 | if field.height_field: # type: ignore 103 | height = getattr(obj, field.height_field, field_file.height) # type: ignore 104 | else: 105 | height = field_file.height 106 | return (str(width), str(height)) 107 | 108 | 109 | def _get_absolute_file_url(request: HttpRequest, path: str) -> str: 110 | # Both Open Graph and Twitter Cards require absolute URLs. 111 | # Some static / media storages will give us absolute URLs. 112 | # However, the ones in Django, whitenoise, etc. just give relative URLs. 113 | # `urljoin()` will leave alone already-absolute URLs, 114 | # but we prefix relative URLs with the current site root. 115 | # If the sites framework is installed it uses the current site, 116 | # otherwise it will use data from the request object. 117 | # This should work for almost all cases. 118 | return urljoin(request.build_absolute_uri(), path) 119 | 120 | 121 | def _parse_meta_tags( 122 | tags: types.MetaTagList, request: HttpRequest, obj: Optional[models.Model] 123 | ) -> types.MetaTagList: 124 | parsed_tags: types.MetaTagList = [] 125 | for tag in tags: 126 | if "content" in tag: 127 | parsed_tags.append(tag) 128 | elif "attribute" in tag: 129 | if not obj: 130 | logger.error( 131 | "Trying to use `attribute` without an object for tag with name: %s", 132 | tag.get("name", tag.get("property")), 133 | ) 134 | continue 135 | attr = getattr(obj, tag["attribute"]) 136 | if isinstance(attr, ImageFieldFile): 137 | field = attr 138 | tag["content"] = _get_absolute_file_url(request, field.url) 139 | parsed_tags.append(tag) 140 | if tag.get("property", "") in {"og:image", "og:image:url"}: 141 | width, height = _get_image_dimensions(obj, field) 142 | parsed_tags.append({"property": "og:image:width", "content": width}) 143 | parsed_tags.append( 144 | {"property": "og:image:height", "content": height} 145 | ) 146 | else: 147 | tag["content"] = attr 148 | parsed_tags.append(tag) 149 | elif "static" in tag: 150 | tag["content"] = _get_absolute_file_url(request, static(tag["static"])) 151 | parsed_tags.append(tag) 152 | else: 153 | logger.error( 154 | "Missing content field for tag with name: %s", 155 | tag.get("name", tag.get("property")), 156 | ) 157 | return parsed_tags 158 | 159 | 160 | def get_meta_tags( 161 | context: template.Context, obj: Optional[models.Model] = None 162 | ) -> types.MetaTagContext: 163 | """Fetch meta tags. 164 | 165 | 1. If an object is passed, use it. 166 | 2. If not, try to find the object in the context. 167 | 3. If there isn't one, check if there is an object for the current path. 168 | 4. Grab the defaults and merge in the tags from the model. 169 | 5. Merge in tags from the object. 170 | 6. Get tags based on the language. 171 | 7. Return the tags. 172 | 173 | The priority works like this: 174 | - More specific languages beat less specific ones, e.g. en_GB > en > default. 175 | - Tags from the object beat tags from the settings. 176 | """ 177 | try: 178 | if obj is not None: 179 | found_tags = obj.meta_tags # type: ignore 180 | else: 181 | request_path = context["request"].path 182 | obj, found_tags = _get_meta_tags_from_context(context, request_path) 183 | if not found_tags: 184 | found_tags = _get_meta_tags_for_path(request_path) 185 | 186 | default_tags = getattr(settings, "SNAKEOIL_DEFAULT_TAGS", {}) 187 | model_tags = getattr(obj, "snakeoil_metadata", None) or {} 188 | collated_tags = _collate_meta_tags(model_tags, default_tags) 189 | collated_tags = _collate_meta_tags(found_tags, collated_tags) 190 | language_tags = _get_meta_tags_for_language(collated_tags) 191 | meta_tags = _parse_meta_tags(language_tags, request=context["request"], obj=obj) 192 | return {"meta_tags": meta_tags} 193 | except Exception: 194 | logger.exception("Failed fetching meta tags") 195 | return {"meta_tags": []} 196 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knyghty/django-snakeoil/a5284777d0c157f8d76ee36a46065166a052e4d9/tests/__init__.py -------------------------------------------------------------------------------- /tests/data/kitties.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knyghty/django-snakeoil/a5284777d0c157f8d76ee36a46065166a052e4d9/tests/data/kitties.jpg -------------------------------------------------------------------------------- /tests/jinja2.py: -------------------------------------------------------------------------------- 1 | from jinja2 import Environment 2 | 3 | from snakeoil.jinja2 import get_meta_tags 4 | 5 | 6 | def environment(**options): 7 | env = Environment(**options) 8 | env.globals.update({"get_meta_tags": get_meta_tags}) 9 | return env 10 | -------------------------------------------------------------------------------- /tests/jinja2/base.jinja2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Test page 6 | 7 | {% block head %} 8 | {% with meta_tags=get_meta_tags() %} 9 | {% include "snakeoil/seo.jinja2" %} 10 | {% endwith %} 11 | {% endblock %} 12 | 13 | 14 | 15 | {% block content %}{% endblock %} 16 | 17 | 18 | -------------------------------------------------------------------------------- /tests/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.3 on 2023-10-13 06:53 2 | import django.db.models.deletion 3 | from django.conf import settings 4 | from django.db import migrations 5 | from django.db import models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | initial = True 10 | 11 | dependencies = [ 12 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name="Article", 18 | fields=[ 19 | ( 20 | "id", 21 | models.BigAutoField( 22 | auto_created=True, 23 | primary_key=True, 24 | serialize=False, 25 | verbose_name="ID", 26 | ), 27 | ), 28 | ( 29 | "meta_tags", 30 | models.JSONField( 31 | blank=True, default=dict, verbose_name="meta tags" 32 | ), 33 | ), 34 | ("slug", models.SlugField(unique=True)), 35 | ("title", models.CharField(max_length=500)), 36 | ( 37 | "main_image", 38 | models.ImageField( 39 | blank=True, 40 | height_field="main_image_height", 41 | null=True, 42 | upload_to="", 43 | width_field="main_image_width", 44 | ), 45 | ), 46 | ( 47 | "main_image_width", 48 | models.PositiveSmallIntegerField(blank=True, null=True), 49 | ), 50 | ( 51 | "main_image_height", 52 | models.PositiveSmallIntegerField(blank=True, null=True), 53 | ), 54 | ( 55 | "secondary_image", 56 | models.ImageField(blank=True, null=True, upload_to=""), 57 | ), 58 | ("content", models.TextField()), 59 | ( 60 | "author", 61 | models.ForeignKey( 62 | on_delete=django.db.models.deletion.CASCADE, 63 | related_name="articles", 64 | to=settings.AUTH_USER_MODEL, 65 | ), 66 | ), 67 | ], 68 | options={ 69 | "abstract": False, 70 | }, 71 | ), 72 | ] 73 | -------------------------------------------------------------------------------- /tests/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knyghty/django-snakeoil/a5284777d0c157f8d76ee36a46065166a052e4d9/tests/migrations/__init__.py -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.db import models 3 | from django.urls import reverse 4 | 5 | from snakeoil.models import SEOModel 6 | 7 | User = get_user_model() 8 | 9 | 10 | class Article(SEOModel): 11 | author = models.ForeignKey(User, on_delete=models.CASCADE, related_name="articles") 12 | slug = models.SlugField(unique=True) 13 | title = models.CharField(max_length=500) 14 | main_image = models.ImageField( 15 | null=True, 16 | blank=True, 17 | width_field="main_image_width", 18 | height_field="main_image_height", 19 | ) 20 | main_image_width = models.PositiveSmallIntegerField(null=True, blank=True) 21 | main_image_height = models.PositiveSmallIntegerField(null=True, blank=True) 22 | secondary_image = models.ImageField(null=True, blank=True) 23 | content = models.TextField() 24 | 25 | def get_absolute_url(self) -> str: 26 | return reverse("article_detail", args=[self.slug]) 27 | 28 | @property 29 | def author_name(self) -> str: 30 | return self.author.get_full_name() 31 | 32 | @property 33 | def snakeoil_metadata(self) -> dict[str, list[dict[str, str]]]: 34 | metadata = { 35 | "default": [ 36 | {"name": "author", "attribute": "author_name"}, 37 | {"property": "og:title", "attribute": "title"}, 38 | ], 39 | } 40 | if self.main_image: 41 | metadata["default"].append( 42 | {"property": "og:image", "attribute": "main_image"} 43 | ) 44 | return metadata 45 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | TESTS_DIR = Path(__file__).resolve(strict=True).parents[0] 5 | 6 | db = os.getenv("DB") 7 | if db == "postgres": 8 | DATABASES = { 9 | "default": { 10 | "ENGINE": "django.db.backends.postgresql", 11 | "HOST": "127.0.0.1", 12 | "PORT": int(os.getenv("DB_PORT", 5432)), 13 | "NAME": "snakeoil", 14 | "USER": "snakeoil", 15 | "PASSWORD": "snakeoil", 16 | }, 17 | } 18 | elif db in {"mysql", "mariadb"}: 19 | DATABASES = { 20 | "default": { 21 | "ENGINE": "django.db.backends.mysql", 22 | "HOST": "127.0.0.1", 23 | "PORT": int(os.getenv("DB_PORT", 3306)), 24 | "USER": "root", 25 | "PASSWORD": "snakeoil", 26 | "TEST": { 27 | "NAME": "default_test_snakeoil", 28 | "CHARSET": "utf8", 29 | "COLLATION": "utf8_general_ci", 30 | }, 31 | }, 32 | } 33 | else: 34 | DATABASES = { 35 | "default": { 36 | "ENGINE": "django.db.backends.sqlite3", 37 | "NAME": TESTS_DIR / "test.sqlite3", 38 | } 39 | } 40 | 41 | INSTALLED_APPS = [ 42 | "snakeoil", 43 | "tests", 44 | "django.contrib.admin", 45 | "django.contrib.auth", 46 | "django.contrib.contenttypes", 47 | "django.contrib.sessions", 48 | "django.contrib.messages", 49 | "django.contrib.staticfiles", 50 | ] 51 | 52 | MIDDLEWARE = [ 53 | "django.middleware.security.SecurityMiddleware", 54 | "django.contrib.sessions.middleware.SessionMiddleware", 55 | "django.middleware.common.CommonMiddleware", 56 | "django.middleware.csrf.CsrfViewMiddleware", 57 | "django.contrib.auth.middleware.AuthenticationMiddleware", 58 | "django.contrib.messages.middleware.MessageMiddleware", 59 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 60 | ] 61 | 62 | ROOT_URLCONF = "tests.urls" 63 | 64 | SECRET_KEY = "dummy" 65 | 66 | TEMPLATES = [ 67 | { 68 | "BACKEND": "django.template.backends.django.DjangoTemplates", 69 | "DIRS": [], 70 | "APP_DIRS": True, 71 | "OPTIONS": { 72 | "context_processors": [ 73 | "django.template.context_processors.debug", 74 | "django.template.context_processors.request", 75 | "django.contrib.auth.context_processors.auth", 76 | "django.contrib.messages.context_processors.messages", 77 | ], 78 | }, 79 | }, 80 | { 81 | "BACKEND": "django.template.backends.jinja2.Jinja2", 82 | "DIRS": [], 83 | "APP_DIRS": True, 84 | "OPTIONS": {"environment": "tests.jinja2.environment"}, 85 | }, 86 | ] 87 | 88 | LANGUAGE_CODE = "en" 89 | 90 | USE_I18N = True 91 | 92 | STATIC_URL = "/static/" 93 | 94 | MEDIA_ROOT = TESTS_DIR / "media" 95 | 96 | MEDIA_URL = "/media/" 97 | 98 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 99 | -------------------------------------------------------------------------------- /tests/templates/base.html: -------------------------------------------------------------------------------- 1 | {% load snakeoil %} 2 | 3 | 4 | 5 | 6 | Test page 7 | 8 | {% block head %} 9 | {% meta %} 10 | {% endblock %} 11 | 12 | 13 | 14 | {% block content %}{% endblock %} 15 | 16 | 17 | -------------------------------------------------------------------------------- /tests/templates/tests/article_detail.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load snakeoil %} 3 | 4 | {% block head %} 5 | {% meta article %} 6 | {% endblock %} 7 | 8 | {% block content %} 9 | {{ article.content }} 10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /tests/templates/tests/article_detail_without_obj.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load snakeoil %} 3 | 4 | {% block head %} 5 | {% meta %} 6 | {% endblock %} 7 | 8 | {% block content %} 9 | {{ article.content }} 10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /tests/test_jinja2.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from snakeoil.models import SEOPath 4 | 5 | 6 | class MetaTemplateTagTestCase(TestCase): 7 | def test_jinja2(self): 8 | SEOPath.objects.create( 9 | path="/jinja2/", 10 | meta_tags={ 11 | "en": [{"name": "description", "content": "jinja2 path description"}] 12 | }, 13 | ) 14 | response = self.client.get("/jinja2/") 15 | self.assertContains( 16 | response, 17 | '', 18 | html=True, 19 | ) 20 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.test import TestCase 3 | 4 | from snakeoil.models import SEOPath 5 | 6 | from .models import Article 7 | 8 | User = get_user_model() 9 | 10 | 11 | class SeoModelTestCase(TestCase): 12 | @classmethod 13 | def setUpTestData(cls): 14 | cls.user = User.objects.create_user(username="tom") 15 | cls.article = Article.objects.create( 16 | author=cls.user, slug="an-article", title="A test article" 17 | ) 18 | 19 | def test_add_meta_tags_to_model(self): 20 | self.article.meta_tags = { 21 | "en": [ 22 | { 23 | "name": "description", 24 | "property": "og:description", 25 | "content": "hello", 26 | } 27 | ] 28 | } 29 | self.article.save() 30 | self.article.refresh_from_db() 31 | meta_tags = self.article.meta_tags 32 | self.assertEqual(meta_tags["en"][0]["name"], "description") 33 | self.assertEqual(meta_tags["en"][0]["property"], "og:description") 34 | self.assertEqual(meta_tags["en"][0]["content"], "hello") 35 | 36 | def test_add_seo_path(self): 37 | seo_path = SEOPath.objects.create( 38 | path="/test-page/", 39 | meta_tags={"default": [{"name": "description", "content": "hello"}]}, 40 | ) 41 | self.assertEqual(str(seo_path), "/test-page/") 42 | -------------------------------------------------------------------------------- /tests/test_template_tags.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.conf import settings 4 | from django.contrib.auth import get_user_model 5 | from django.core.files.uploadedfile import SimpleUploadedFile 6 | from django.test import TestCase 7 | from django.test import override_settings 8 | 9 | from snakeoil.models import SEOPath 10 | 11 | from .models import Article 12 | 13 | User = get_user_model() 14 | 15 | 16 | class MetaTemplateTagTestCase(TestCase): 17 | def setUp(self): 18 | self.user = User.objects.create_user( 19 | username="tom", first_name="Tom", last_name="Carrick" 20 | ) 21 | self.article = Article.objects.create( 22 | author=self.user, 23 | slug="an-article", 24 | title="A test article", 25 | meta_tags={ 26 | "default": [{"name": "description", "content": "default description"}], 27 | "en": [ 28 | {"name": "description", "content": "hello"}, 29 | {"property": "og:description", "content": "opengraph hello"}, 30 | ], 31 | }, 32 | ) 33 | with open(settings.TESTS_DIR / "data" / "kitties.jpg", "rb") as f: 34 | self.article.main_image = SimpleUploadedFile( 35 | name="kitties.jpg", 36 | content=f.read(), 37 | content_type="image/jpeg", 38 | ) 39 | self.article.save() 40 | 41 | def test_meta_template_tag_with_seo_model(self): 42 | response = self.client.get(f"/articles/{self.article.slug}/") 43 | 44 | self.assertContains( 45 | response, '', html=True 46 | ) 47 | self.assertContains( 48 | response, 49 | '', 50 | html=True, 51 | ) 52 | 53 | def test_meta_template_tag_with_attr(self): 54 | response = self.client.get(f"/articles/{self.article.slug}/") 55 | 56 | self.assertTemplateUsed(response, "tests/article_detail.html") 57 | self.assertContains( 58 | response, '', html=True 59 | ) 60 | 61 | def test_attr_with_object_from_context(self): 62 | response = self.client.get( 63 | f"/articles/{self.article.slug}/", {"template_without_obj": True} 64 | ) 65 | 66 | self.assertTemplateUsed(response, "tests/article_detail_without_obj.html") 67 | self.assertContains( 68 | response, '', html=True 69 | ) 70 | 71 | def test_path(self): 72 | SEOPath.objects.create( 73 | path="/test-page/", 74 | meta_tags={"en": [{"name": "description", "content": "path description"}]}, 75 | ) 76 | 77 | response = self.client.get("/test-page/") 78 | 79 | self.assertContains( 80 | response, '', html=True 81 | ) 82 | 83 | def test_attr_not_allowed_for_path(self): 84 | SEOPath.objects.create( 85 | path="/test-page/", 86 | meta_tags={ 87 | "default": [{"name": "description", "attribute": "author_name"}] 88 | }, 89 | ) 90 | with self.assertLogs("snakeoil.utils", level=logging.ERROR): 91 | self.client.get("/test-page/") 92 | 93 | @override_settings(USE_I18N=False) 94 | def test_without_i18n(self): 95 | response = self.client.get(f"/articles/{self.article.slug}/") 96 | 97 | self.assertContains( 98 | response, 99 | '', 100 | html=True, 101 | ) 102 | 103 | @override_settings(LANGUAGE_CODE="en_GB") 104 | def test_more_specific_language_wins(self): 105 | self.article.meta_tags["en_GB"] = [ 106 | {"name": "description", "content": "yorrite m8"} 107 | ] 108 | self.article.save() 109 | 110 | response = self.client.get(f"/articles/{self.article.slug}/") 111 | 112 | self.assertContains( 113 | response, '', html=True 114 | ) 115 | 116 | @override_settings(LANGUAGE_CODE="en_GB") 117 | def test_fallback_to_generic_language(self): 118 | response = self.client.get(f"/articles/{self.article.slug}/") 119 | 120 | self.assertContains( 121 | response, '', html=True 122 | ) 123 | 124 | @override_settings(LANGUAGE_CODE="eo") 125 | def test_fallback_to_default(self): 126 | response = self.client.get(f"/articles/{self.article.slug}/") 127 | 128 | self.assertContains( 129 | response, 130 | '', 131 | html=True, 132 | ) 133 | 134 | def test_image_field_with_width_and_height_fields(self): 135 | with open(settings.TESTS_DIR / "data" / "kitties.jpg", "rb") as f: 136 | self.article.main_image = SimpleUploadedFile( 137 | name="kitties.jpg", 138 | content=f.read(), 139 | content_type="image/jpeg", 140 | ) 141 | self.article.save() 142 | 143 | response = self.client.get(f"/articles/{self.article.slug}/") 144 | 145 | self.assertContains( 146 | response, 147 | ( 148 | '' 150 | ), 151 | html=True, 152 | ) 153 | self.assertContains( 154 | response, '', html=True 155 | ) 156 | self.assertContains( 157 | response, '', html=True 158 | ) 159 | 160 | def test_image_field_without_width_and_height_fields(self): 161 | with open(settings.TESTS_DIR / "data" / "kitties.jpg", "rb") as f: 162 | self.article.secondary_image = SimpleUploadedFile( 163 | name="kitties.jpg", 164 | content=f.read(), 165 | content_type="image/jpeg", 166 | ) 167 | self.article.meta_tags["en"] = [ 168 | {"property": "og:image", "attribute": "secondary_image"} 169 | ] 170 | self.article.save() 171 | 172 | response = self.client.get(f"/articles/{self.article.slug}/") 173 | 174 | self.assertContains( 175 | response, 176 | ( 177 | '' 179 | ), 180 | html=True, 181 | ) 182 | self.assertContains( 183 | response, '', html=True 184 | ) 185 | self.assertContains( 186 | response, '', html=True 187 | ) 188 | 189 | def test_attr_manual_image(self): 190 | # The field here doesn't matter. It should just get passed through. 191 | # This is to handle URLFields, etc. 192 | self.article.meta_tags["en"] = [{"property": "og:image", "attribute": "slug"}] 193 | self.article.save() 194 | 195 | response = self.client.get(f"/articles/{self.article.slug}/") 196 | 197 | self.assertContains( 198 | response, '', html=True 199 | ) 200 | 201 | def test_static(self): 202 | # The default finder doesn't care if the file exists or not. 203 | self.article.meta_tags["en"] = [ 204 | {"property": "og:image", "static": "foo/dummy.png"} 205 | ] 206 | self.article.save() 207 | 208 | response = self.client.get(f"/articles/{self.article.slug}/") 209 | 210 | self.assertContains( 211 | response, 212 | ( 213 | '' 215 | ), 216 | html=True, 217 | ) 218 | 219 | @override_settings( 220 | SNAKEOIL_DEFAULT_TAGS={ 221 | "default": [{"property": "og:site_name", "content": "My Site"}] 222 | } 223 | ) 224 | def test_default_tags(self): 225 | response = self.client.get(f"/articles/{self.article.slug}/") 226 | 227 | self.assertNotIn("og:site_name", self.article.meta_tags.get("en", {})) 228 | self.assertNotIn("og:site_name", self.article.meta_tags.get("default", {})) 229 | self.assertContains( 230 | response, '', html=True 231 | ) 232 | self.assertContains( 233 | response, '', html=True 234 | ) 235 | self.assertContains( 236 | response, 237 | '', 238 | html=True, 239 | ) 240 | 241 | @override_settings( 242 | SNAKEOIL_DEFAULT_TAGS={ 243 | "default": [{"property": "og:site_name", "content": "My Site"}] 244 | } 245 | ) 246 | def test_model_beats_defaults(self): 247 | self.article.meta_tags["default"] = [ 248 | {"property": "og:site_name", "content": "Not really my site"} 249 | ] 250 | self.article.save() 251 | 252 | response = self.client.get(f"/articles/{self.article.slug}/") 253 | 254 | self.assertNotContains( 255 | response, '', html=True 256 | ) 257 | self.assertContains( 258 | response, 259 | '', 260 | html=True, 261 | ) 262 | 263 | @override_settings( 264 | LANGUAGE_CODE="eo", 265 | SNAKEOIL_DEFAULT_TAGS={ 266 | "eo": [{"property": "og:site_name", "content": "Mia Esperanta Retejo"}] 267 | }, 268 | ) 269 | def test_language_default_beats_model(self): 270 | self.article.meta_tags["default"].append( 271 | {"property": "og:site_name", "content": "My Site"} 272 | ) 273 | self.article.save() 274 | 275 | response = self.client.get(f"/articles/{self.article.slug}/") 276 | 277 | self.assertNotContains( 278 | response, '', html=True 279 | ) 280 | self.assertContains( 281 | response, 282 | '', 283 | html=True, 284 | ) 285 | 286 | def test_seo_path_root_url(self): 287 | SEOPath.objects.create( 288 | path="/", 289 | meta_tags={"default": [{"name": "description", "content": "home page"}]}, 290 | ) 291 | response = self.client.get("/") 292 | self.assertContains( 293 | response, '', html=True 294 | ) 295 | 296 | def test_model_metadata(self): 297 | response = self.client.get(f"/articles/{self.article.slug}/") 298 | 299 | self.assertContains( 300 | response, 301 | '', 302 | html=True, 303 | ) 304 | self.assertContains( 305 | response, 306 | f'', 307 | html=True, 308 | ) 309 | self.assertContains( 310 | response, 311 | ( 312 | '' 314 | ), 315 | html=True, 316 | ) 317 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views 4 | 5 | urlpatterns = [ 6 | path( 7 | "articles//", 8 | views.ArticleDetailView.as_view(), 9 | name="article_detail", 10 | ), 11 | path("test-page/", views.TestView.as_view()), 12 | path("jinja2/", views.Jinja2TestView.as_view()), 13 | path("", views.TestView.as_view()), 14 | ] 15 | -------------------------------------------------------------------------------- /tests/views.py: -------------------------------------------------------------------------------- 1 | from django.views import generic 2 | 3 | from .models import Article 4 | 5 | 6 | class ArticleDetailView(generic.DetailView): 7 | model = Article 8 | 9 | def get_template_names(self): 10 | if self.request.GET.get("template_without_obj"): 11 | return "tests/article_detail_without_obj.html" 12 | return super().get_template_names() 13 | 14 | 15 | class TestView(generic.TemplateView): 16 | template_name = "base.html" 17 | 18 | 19 | class Jinja2TestView(generic.TemplateView): 20 | template_name = "base.jinja2" 21 | --------------------------------------------------------------------------------