├── .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 |
--------------------------------------------------------------------------------