From e786035f69065d4aec5a49039464faeb30d7d58e Mon Sep 17 00:00:00 2001 From: Giannis Terzopoulos Date: Wed, 19 Jun 2024 19:44:31 +0200 Subject: [PATCH] =?UTF-8?q?Initial=20commit=20=F0=9F=94=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/dependabot.yml | 13 + .github/workflows/main.yml | 117 ++++ .gitignore | 5 + .pre-commit-config.yaml | 43 ++ CHANGELOG.rst | 8 + LICENSE | 21 + README.rst | 270 +++++++++ justfile | 10 + pyproject.toml | 117 ++++ src/django_xinclude/__init__.py | 13 + src/django_xinclude/apps.py | 11 + src/django_xinclude/cache.py | 75 +++ src/django_xinclude/checks.py | 26 + src/django_xinclude/conf.py | 23 + src/django_xinclude/exceptions.py | 5 + src/django_xinclude/logger.py | 6 + .../templates/django_xinclude/include.html | 4 + src/django_xinclude/templatetags/__init__.py | 0 src/django_xinclude/templatetags/xinclude.py | 181 ++++++ src/django_xinclude/urls.py | 11 + src/django_xinclude/views.py | 56 ++ tests/__init__.py | 0 tests/settings.py | 47 ++ tests/templates/tests/core.html | 2 + tests/templates/tests/for_loop.html | 4 + tests/templates/tests/partials/hello.html | 1 + tests/test_checks.py | 27 + tests/test_conf.py | 28 + tests/tests.py | 531 ++++++++++++++++++ tests/urls.py | 11 + tests/views.py | 11 + 31 files changed, 1677 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/main.yml create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 CHANGELOG.rst create mode 100644 LICENSE create mode 100644 README.rst create mode 100644 justfile create mode 100644 pyproject.toml create mode 100644 src/django_xinclude/__init__.py create mode 100644 src/django_xinclude/apps.py create mode 100644 src/django_xinclude/cache.py create mode 100644 src/django_xinclude/checks.py create mode 100644 src/django_xinclude/conf.py create mode 100644 src/django_xinclude/exceptions.py create mode 100644 src/django_xinclude/logger.py create mode 100644 src/django_xinclude/templates/django_xinclude/include.html create mode 100644 src/django_xinclude/templatetags/__init__.py create mode 100644 src/django_xinclude/templatetags/xinclude.py create mode 100644 src/django_xinclude/urls.py create mode 100644 src/django_xinclude/views.py create mode 100644 tests/__init__.py create mode 100644 tests/settings.py create mode 100644 tests/templates/tests/core.html create mode 100644 tests/templates/tests/for_loop.html create mode 100644 tests/templates/tests/partials/hello.html create mode 100644 tests/test_checks.py create mode 100644 tests/test_conf.py create mode 100644 tests/tests.py create mode 100644 tests/urls.py create mode 100644 tests/views.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..be006de --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +# Keep GitHub Actions up to date with GitHub's Dependabot... +# https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot +# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + groups: + github-actions: + patterns: + - "*" # Group all Actions updates into a single larger pull request + schedule: + interval: weekly diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..99002ac --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,117 @@ +name: CI + +on: + pull_request: + branches: + - main + +concurrency: + group: test-${{ github.head_ref }} + cancel-in-progress: true + +env: + PYTHONUNBUFFERED: "1" + FORCE_COLOR: "1" + +jobs: + test: + name: Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }} + runs-on: "ubuntu-latest" + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12"] + django-version: ["4.2", "5.0", "main"] + steps: + - uses: actions/checkout@v4 + + - uses: extractions/setup-just@v2 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: "pip" + allow-prereleases: true + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + if [[ "${{ matrix.django-version }}" == "main" ]]; then + python -m pip install https://github.com/django/django/archive/refs/heads/main.zip + else + python -m pip install django==${{ matrix.django-version }} + fi + python -m pip install '.[tests]' + + - name: Run tests + run: | + just test + +# tests: +# runs-on: ubuntu-latest +# needs: test +# if: always() +# steps: +# - name: OK +# if: ${{ !(contains(needs.*.result, 'failure')) }} +# run: exit 0 +# - name: Fail +# if: ${{ contains(needs.*.result, 'failure') }} +# run: exit 1 + + coverage: + runs-on: ubuntu-latest + env: + PYTHON_MIN_VERSION: "3.10" + DJANGO_MIN_VERSION: "4.2" + steps: + - uses: actions/checkout@v4 + + - uses: extractions/setup-just@v2 + + - name: Set up Python ${{ env.PYTHON_MIN_VERSION }} + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_MIN_VERSION }} + cache: "pip" + allow-prereleases: true + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install django==${{ env.DJANGO_MIN_VERSION }} + python -m pip install '.[tests, dev]' + + - name: Run coverage + run: | + just coverage + + ruff: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install '.[dev]' + + - name: Run ruff + run: | + ruff check . + ruff format --check + + mypy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install '.[dev]' + + - name: Run mypy + run: | + mypy . diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1624ed1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__ +dist +.idea +.venv +.coverage diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..f3a9f07 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,43 @@ +default_language_version: + python: python3.12 + +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: check-added-large-files + - id: check-case-conflict + - id: check-json + - id: check-merge-conflict + - id: check-symlinks + - id: check-toml + - id: end-of-file-fixer + exclude_types: [text] + - id: trailing-whitespace +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.4.7 + hooks: + - id: ruff + - id: ruff-format +- repo: https://github.com/rstcheck/rstcheck + rev: v6.2.1 + hooks: + - id: rstcheck + additional_dependencies: + - tomli==2.0.1 +- repo: https://github.com/asottile/pyupgrade + rev: v3.15.2 + hooks: + - id: pyupgrade + args: [--py310-plus] +- repo: https://github.com/adamchainz/django-upgrade + rev: 1.18.0 + hooks: + - id: django-upgrade + args: [--target-version, '4.2'] +- repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.10.0 + hooks: + - id: mypy + additional_dependencies: + - django-stubs==5.0.2 diff --git a/CHANGELOG.rst b/CHANGELOG.rst new file mode 100644 index 0000000..6a67627 --- /dev/null +++ b/CHANGELOG.rst @@ -0,0 +1,8 @@ +========= +CHANGELOG +========= + +0.1.0 (2024-06-19) +================== + +Initial release. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be3ff47 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2024 Giannis Terzopoulos + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..d14a86c --- /dev/null +++ b/README.rst @@ -0,0 +1,270 @@ +=================== +django-xinclude +=================== + +.. image:: https://img.shields.io/pypi/v/django-xinclude.svg + :target: https://pypi.org/project/django-xinclude/ + :alt: PyPI version + +Render a template using htmx with the current context. + +---- + +| ``hx-get`` is often used to delegate potentially computationally expensive template fragments to ``htmx``. + Achieving this sometimes requires the creation of multiple views, each of which needs to inherit from mixins that + provide access to the same context. +| ``django-xinclude`` provides a template tag that aims to make this easier by leveraging the cache, + removing the need for the extra views. + +---- + +Requirements +------------ +* Python 3.10 to 3.12 supported. +* Django 4.2 to 5.0 supported. +* `htmx `__ + +Setup +----- + +* Install from **pip**: + +.. code-block:: sh + + python -m pip install django-xinclude + +* Add it to your installed apps: + +.. code-block:: python + + INSTALLED_APPS = [ + ..., + "django_xinclude", + ..., + ] + +* Include the app URLs in your root URLconf: + +.. code-block:: python + + from django.urls import include, path + + urlpatterns = [ + ..., + path("__xinclude__/", include("django_xinclude.urls")), + ] + +You can use a different prefix if required. + + +Usage +----- + +Once installed, load the ``xinclude`` library and use the tag passing the template that you want to include: + +.. code-block:: html + + {% load xinclude %} + + {% xinclude "footer.html" %}{% endxinclude %} + +Every feature of the regular |include|__ tag is supported, including the use of ``with`` and ``only``. + +.. |include| replace:: ``include`` +__ https://docs.djangoproject.com/en/dev/ref/templates/builtins/#include + +You can use the following htmx-specific arguments: + +* ``hx-trigger``: corresponds to the |hx-trigger|__ htmx attribute. Defaults to ``load once``. + +.. |hx-trigger| replace:: ``hx-trigger`` +__ https://htmx.org/attributes/hx-trigger/ + +* ``swap-time``: corresponds to the ``swap`` timing of the |hx-swap|__ htmx attribute. + +.. |hx-swap| replace:: ``hx-swap`` +__ https://htmx.org/attributes/hx-swap/#timing-swap-settle + +* ``settle-time``: corresponds to the ``settle`` timing of the |hx-swap|__ htmx attribute. + +__ https://htmx.org/attributes/hx-swap/#timing-swap-settle + +"Primary nodes" may be passed along to render initial content prior to htmx swapping. For example: + +.. code-block:: html + + {% xinclude "footer.html" %} +
Loading...
+ {% endxinclude %} + +``django-xinclude`` plays well with the excellent `django-template-partials `__ +package, to select specific partials on the target template. + +Advanced usage +^^^^^^^^^^^^^^ +Below is a more complete example making use of the htmx `transition classes `__. +Note the ``intersect once`` trigger, which will fire the request once when the element intersects the viewport. + +.. code-block:: html + + + + {% xinclude "magic.html" with wand="🪄" hx-trigger="intersect once" swap-time="1s" settle-time="1s" %} +
+ Loading... +
+ {% endxinclude %} + +``magic.html``: + +.. code-block:: html + + + +
+ 🔮 {{ wand }} +
+ +---- + +You can preload the ``xinclude`` libary in every template by appending to your ``TEMPLATES`` ``builtins`` setting. +This way you don't need to repeat the ``{% load xinclude %}`` in every template that you need the tag: + +.. code-block:: python + + TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + ..., + "OPTIONS": { + "builtins": [ + "django_xinclude.templatetags.xinclude", + ], + }, + }, + ] + + +How It Works +------------ +``django-xinclude`` first checks if it needs to render the target template synchronously; +see the `Rendering synchronously <#rendering-synchronously>`__ section for cases where this might be useful. +If this is not the case, it stores the current context and the target template to the cache and constructs a url +with a ``fragment_id`` that targets an internal view. It then renders a parent ``div`` element containing all the +necessary htmx attributes. Once the htmx request fires, the view fetches the cache context and template that match +the passed ``fragment_id`` and uses that context to render the template. + +Cache +^^^^^ +``django-xinclude`` uses either the cache that corresponds to the ``XINCLUDE_CACHE_ALIAS`` setting, if specified, +or the ``CACHES["default"]``. +When setting a new cache key, it finds unpickable values and discards them. +If you want to see which keys get discarded, update your ``settings.LOGGERS`` to include ``"django_xinclude"`` +with ``"level": "DEBUG"``. + +| All official `Django cache backends `__ should work, + under one **important condition**: +| Your cache should be accessible from all your app instances. If you are using multi-processing for your Django application, + or multiple servers clusters, make sure that your ``django-xinclude`` cache is accessible from all the instances, + otherwise your requests will result in 404s. + +Authorization +^^^^^^^^^^^^^ +The request user is expected to be the one that initially accessed the original view (and added to cache), +or ``AnonymousUser`` in both cases; otherwise ``django-xinclude`` will return 404 for the htmx requests. +If ``request.user`` is not available, for instance when ``django.contrib.auth`` is not in the ``INSTALLED_APPS``, +then ``django-xinclude`` assumes that the end user can access the data. + +Rendering synchronously +^^^^^^^^^^^^^^^^^^^^^^^ +There are cases where you might want to conditionally render fragments synchronously (i.e. use the regular ``include``). +For example, you could render synchronously for SEO purposes, when robots are crawling your pages, but still make use +of the htmx functionality for regular users. ``django-xinclude`` supports this, it checks for a ``xinclude_sync`` +attribute on the request and renders synchronously if that evaluates to ``True``. +So you can add a custom middleware that sets the ``xinclude_sync`` attribute upon your individual conditions. + +See also `Configuration <#configuration>`__ below for the ``XINCLUDE_SYNC_REQUEST_ATTR`` setting. + +Configuration +------------- + +``XINCLUDE_CACHE_ALIAS: str`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +The cache alias that ``django-xinclude`` will use, it defaults to ``CACHES["default"]``. + +``XINCLUDE_CACHE_TIMEOUT: int`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +The number of seconds that contexts should be stored in the cache. If the setting is not present, Django will +use the default timeout argument of the appropriate backend in the ``CACHES`` setting. + +``XINCLUDE_SYNC_REQUEST_ATTR: str`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +The request attribute that ``django-xinclude`` will check on to determine if it needs to render synchronously. +It defaults to ``xinclude_sync``. + +Running the tests +----------------- + +Fork, then clone the repo: + +.. code-block:: sh + + git clone git@github.com:your-username/django-xinclude.git + +Set up a venv: + +.. code-block:: sh + + python -m venv .venv + source .venv/bin/activate + python -m pip install -e '.[tests,dev]' + +Set up the |pre-commit|__ hooks: + +.. |pre-commit| replace:: ``pre-commit`` +__ https://pre-commit.com/ + +.. code-block:: sh + + pre-commit install + +Then you can run the tests with the |just|__ command runner: + +.. |just| replace:: ``just`` +__ https://github.com/casey/just + +.. code-block:: sh + + just test + +Or with coverage: + +.. code-block:: sh + + just coverage + +If you don't have ``just`` installed, you can look in the ``justfile`` for the +commands that are run. + +| + +Complementary packages +---------------------- +* |django-htmx|__: Extensions for using Django with htmx. +* |django-template-partials|__: Reusable named inline partials for the Django Template Language. + +.. |django-htmx| replace:: ``django-htmx`` +__ https://github.com/adamchainz/django-htmx + +.. |django-template-partials| replace:: ``django-template-partials`` +__ https://github.com/carltongibson/django-template-partials/ diff --git a/justfile b/justfile new file mode 100644 index 0000000..1f1ee6e --- /dev/null +++ b/justfile @@ -0,0 +1,10 @@ +default: + just --list + +@test *options: + python -m pytest --ds=tests.settings {{ options }} + +@coverage: + coverage erase + coverage run -m django test --settings=tests.settings --pythonpath=. + coverage report diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a6b3494 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,117 @@ +[build-system] +requires = ["flit_core >=3.2,<4"] +build-backend = "flit_core.buildapi" + +[project] +name = "django-xinclude" +authors = [{ name = "Giannis Terzopoulos", email = "terzo.giannis@gmail.com" }] +readme = "README.rst" +keywords = [ + "django", + "templates", + "htmx", + "xhr", + "cache", +] +license = {file = "LICENSE"} +requires-python = ">=3.10" +classifiers = [ + "Development Status :: 1 - Planning", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Framework :: Django", + "Framework :: Django :: 4.2", + "Framework :: Django :: 5.0", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Typing :: Typed", +] +dynamic = ["version", "description"] +dependencies = ["Django>=4.2"] + +[project.urls] +Changelog = "https://github.com/g-nie/django-xinclude/blob/main/CHANGELOG.rst" +Repository = "https://github.com/g-nie/django-xinclude" + +[project.optional-dependencies] +tests = [ + "pytest", + "pytest-django", + "coverage", +] +dev = [ + "django-stubs[compatible-mypy]", + "ipython", + "ruff", +] + +# TODO: This is not working for some reason: +[tool.flit.sdist] +include = [ + "CHANGELOG.rst", + "LICENSE", + "README.rst", + "pyproject.toml" +] + +[tool.ruff.lint] +select = [ + "E", "F", "I", "W", "N", "B", "A", "C4", "T20", "DJ", "UP", + "COM818", # trailing-comma-on-bare-tuple + "RUF006", # asyncio-dangling-task + "RUF013", # Implicit Optional + "RUF015", # Unnecessary iterable allocation for first element + "RUF017", # Avoid quadratic list summation + "RUF019", # Unnecessary key check + "RUF100", # Unused noqa directive +] +ignore = ["DJ008"] # Model does not define __str__ method + +[tool.ruff.lint.isort] +required-imports = ["from __future__ import annotations"] +combine-as-imports = true +order-by-type = true +no-lines-before = ["local-folder"] + +[tool.mypy] +plugins = ["mypy_django_plugin.main"] +mypy_path = "src/" +namespace_packages = false +show_error_codes = true +strict = true +warn_unreachable = true + +[[tool.mypy.overrides]] +module = "tests.*" +allow_untyped_defs = true +allow_untyped_calls = true +disable_error_code = ["var-annotated", "has-type"] + +[tool.django-stubs] +django_settings_module = "tests.settings" + +[tool.pytest.ini_options] +python_files = ["tests.py", "test_*.py"] +django_find_project = false + +[tool.coverage.run] +branch = true +source = ["django_xinclude"] + +[tool.coverage.paths] +source = ["src"] + +[tool.coverage.report] +fail_under = 100 +show_missing = true +ignore_errors = true +exclude_also = [ + # Don't complain if tests don't hit defensive assertion code: + "raise AssertionError", + "raise NotImplementedError", + # Nor complain about type checking + "if TYPE_CHECKING:", +] diff --git a/src/django_xinclude/__init__.py b/src/django_xinclude/__init__.py new file mode 100644 index 0000000..e87ac64 --- /dev/null +++ b/src/django_xinclude/__init__.py @@ -0,0 +1,13 @@ +""" +django-xinclude + +Render a template using htmx with the current context. +""" + +from __future__ import annotations + +from django_xinclude.logger import logger + +__all__ = ["logger"] + +__version__ = "0.1.0" diff --git a/src/django_xinclude/apps.py b/src/django_xinclude/apps.py new file mode 100644 index 0000000..5c13007 --- /dev/null +++ b/src/django_xinclude/apps.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from django.apps import AppConfig + + +class XincludeAppConfig(AppConfig): + name = "django_xinclude" + verbose_name = "django-xinclude" + + def ready(self) -> None: + from django_xinclude import checks # noqa: F401 diff --git a/src/django_xinclude/cache.py b/src/django_xinclude/cache.py new file mode 100644 index 0000000..3491399 --- /dev/null +++ b/src/django_xinclude/cache.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import pickle +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from django.core.cache import caches + +from django_xinclude import logger +from django_xinclude.conf import conf +from django_xinclude.exceptions import FragmentNotFoundError + +if TYPE_CHECKING: + from django.contrib.auth.models import AbstractUser, AnonymousUser + from django.core.cache.backends.base import BaseCache + + +class ContextCache: + @property + def cache(self) -> BaseCache: + return caches[conf.XINCLUDE_CACHE_ALIAS] + + def get_pickled_context( + self, ctx: dict[str, Any], fragment_id: str + ) -> dict[str, Any]: + """Iterates over ``ctx`` to discard unpickable elements.""" + safe_ctx = {} + discarded = [] + for k, v in ctx.items(): + try: + pickle.dumps(v) + except (pickle.PicklingError, TypeError): + discarded.append(k) + else: + safe_ctx[k] = v + if discarded: + logger.debug( + "The values for the following keys could not get pickled " + f"and thus were not stored in cache (fragment_id: {fragment_id}): " + f"{discarded}" + ) + return safe_ctx + + def set(self, key: str, val: dict[str, Any]) -> None: + """ + Sets the cache ``key`` to ``val``. + + It uses the ``XINCLUDE_CACHE_TIMEOUT`` setting if specified, + otherwise Django will use the timeout argument of the appropriate backend + in the CACHES setting. + """ + if conf.XINCLUDE_CACHE_TIMEOUT is not None: + set_kwargs = {"timeout": conf.XINCLUDE_CACHE_TIMEOUT} + else: + set_kwargs = {} + self.cache.set(key, val, **set_kwargs) + + def get(self, key: str) -> FragmentData: + data = self.cache.get(key) + if data is None: + raise FragmentNotFoundError() + return FragmentData(meta=data["meta"], context=data["context"]) + + +@dataclass +class FragmentData: + meta: dict[str, Any] + context: dict[str, Any] + + def __post_init__(self) -> None: + self.user: AbstractUser | AnonymousUser | None = self.meta.get("user") + self.template_names: list[str] = self.meta["template_names"] + + +ctx_cache = ContextCache() diff --git a/src/django_xinclude/checks.py b/src/django_xinclude/checks.py new file mode 100644 index 0000000..c4761e6 --- /dev/null +++ b/src/django_xinclude/checks.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from django.apps import AppConfig +from django.conf import settings +from django.core.checks import Error, Tags, register + +from django_xinclude.conf import conf + +if TYPE_CHECKING: + from django.core.checks.messages import CheckMessage + + +@register(Tags.compatibility) # type: ignore[type-var] +def check_settings( + app_configs: list[AppConfig] | None, **kwargs: Any +) -> list[CheckMessage]: + checks: list[CheckMessage] = [] + if conf.XINCLUDE_CACHE_TIMEOUT == 0: + msg = "XINCLUDE_CACHE_TIMEOUT setting should be greater than 0." + checks.append(Error(msg, id="django_xinclude.E001")) + if (alias := conf.XINCLUDE_CACHE_ALIAS) not in settings.CACHES: + msg = f'Cache alias "{alias}" not found in CACHES.' + checks.append(Error(msg, id="django_xinclude.E002")) + return checks diff --git a/src/django_xinclude/conf.py b/src/django_xinclude/conf.py new file mode 100644 index 0000000..434f826 --- /dev/null +++ b/src/django_xinclude/conf.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from django.conf import settings + + +class Settings: + @property + def XINCLUDE_CACHE_ALIAS(self) -> str: # noqa: N802 + return getattr(settings, "XINCLUDE_CACHE_ALIAS", "default") + + @property + def XINCLUDE_CACHE_TIMEOUT(self) -> int | None: # noqa: N802 + # The number of seconds the value should be stored in the cache. + # If the setting is not present, Django will use the default timeout + # argument of the appropriate backend in the CACHES setting. + return getattr(settings, "XINCLUDE_CACHE_TIMEOUT", None) + + @property + def XINCLUDE_SYNC_REQUEST_ATTR(self) -> str: # noqa: N802 + return getattr(settings, "XINCLUDE_SYNC_REQUEST_ATTR", "xinclude_sync") + + +conf = Settings() diff --git a/src/django_xinclude/exceptions.py b/src/django_xinclude/exceptions.py new file mode 100644 index 0000000..ab268f8 --- /dev/null +++ b/src/django_xinclude/exceptions.py @@ -0,0 +1,5 @@ +from __future__ import annotations + + +class FragmentNotFoundError(Exception): + pass diff --git a/src/django_xinclude/logger.py b/src/django_xinclude/logger.py new file mode 100644 index 0000000..67d0c34 --- /dev/null +++ b/src/django_xinclude/logger.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +import logging + +# Global logger. +logger = logging.getLogger("django_xinclude") diff --git a/src/django_xinclude/templates/django_xinclude/include.html b/src/django_xinclude/templates/django_xinclude/include.html new file mode 100644 index 0000000..b9720b4 --- /dev/null +++ b/src/django_xinclude/templates/django_xinclude/include.html @@ -0,0 +1,4 @@ +
+ {% if primary_nodes %}{{ primary_nodes }}{% endif %} +
diff --git a/src/django_xinclude/templatetags/__init__.py b/src/django_xinclude/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/django_xinclude/templatetags/xinclude.py b/src/django_xinclude/templatetags/xinclude.py new file mode 100644 index 0000000..d567263 --- /dev/null +++ b/src/django_xinclude/templatetags/xinclude.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING, Any + +from django.template.base import FilterExpression, Parser, Token, Variable +from django.template.library import Library +from django.template.loader_tags import IncludeNode, do_include +from django.urls import reverse +from django.utils.safestring import mark_safe + +from django_xinclude.cache import ctx_cache +from django_xinclude.conf import conf + +if TYPE_CHECKING: + from collections.abc import KeysView + + from django.contrib.auth.models import AbstractUser, AnonymousUser + from django.template.base import NodeList + from django.template.context import RequestContext + from django.utils.safestring import SafeString + +register = Library() + + +class SpecialVariables: + _vars = { + "hx_trigger": "load once", + "swap_time": None, + "settle_time": None, + } + + @classmethod + def make_context_defaults(cls, ctx: dict[str, Any], parser: Parser) -> None: + for key, val in cls._vars.items(): + if val is not None: + ctx.setdefault(key, FilterExpression(f'"{val}"', parser)) + + @classmethod + def keys(cls) -> KeysView[str]: + return cls._vars.keys() + + @classmethod + def raw_list(cls) -> list[str]: + return [k.replace("_", "-") for k in cls._vars.keys()] + + +class HtmxIncludeNode(IncludeNode): + def __init__( + self, + template: FilterExpression, + target_template: FilterExpression, + primary_nodelist: NodeList, + *args: Any, + **kwargs: Any, + ): + super().__init__(template, *args, **kwargs) + self.target_template = target_template + self.primary_nodelist = primary_nodelist + + def save_context(self, context: RequestContext, fragment_id: str) -> None: + """ + Save the context data to the cache. + """ + values = { + name: var.resolve(context) + for name, var in self.extra_context.items() + if name not in SpecialVariables.keys() + } + meta: dict[str, Any] = {"template_names": self.get_template_names(context)} + if user := self.get_user(context): + meta["user"] = user + if self.isolated_context: # using "only" + ctx = values + else: + with context.push(**values): + ctx = context.flatten() # type: ignore[assignment] + ctx = ctx_cache.get_pickled_context(ctx, fragment_id) + ctx_cache.set(fragment_id, {"meta": meta, "context": ctx}) + + def get_user(self, context: RequestContext) -> AbstractUser | AnonymousUser | None: + try: + return context.get("user") or context["request"].user # type: ignore[no-any-return] + except (KeyError, AttributeError): + return None + + @property + def fragment_id(self) -> str: + return str(uuid.uuid4()) + + def sync_include(self, context: RequestContext) -> bool: + try: + return getattr(context.request, conf.XINCLUDE_SYNC_REQUEST_ATTR, False) + except AttributeError: + return False + + def get_template_names(self, context: RequestContext) -> list[str]: + tmpt = self.target_template.var + if isinstance(tmpt, Variable): + tmpt = tmpt.resolve(context) + if isinstance(tmpt, str): + return [tmpt] + # tmpt should be an Iterable then + return tmpt # type: ignore[no-any-return] + return [tmpt] + + def render(self, context: RequestContext) -> SafeString: # type: ignore[override] + if self.sync_include(context): + self.template = self.target_template + return super().render(context) + fragment_id = self.fragment_id + self.save_context(context, fragment_id) + context["url"] = ( + f"{reverse('django_xinclude:xinclude')}?fragment_id={fragment_id}" + ) + if self.primary_nodelist: + element = self.primary_nodelist.render(context) + context["primary_nodes"] = mark_safe(element.strip()) + return super().render(context) + + +@register.tag("xinclude") +def do_xinclude(parser: Parser, token: Token) -> HtmxIncludeNode: + """ + Render a template using htmx with the current context. + The context gets passed to the underlying view using the cache framework,. + Every feature of the regular ``include`` tag is supported. + You can use the following htmx-specfic arguments: + - ``hx-trigger``: corresponds to the "hx-trigger" htmx attribute. + Defaults to "load once". + - ``swap-time``: corresponds to the "swap" timing of the hx-swap htmx attribute. + - ``settle-time``: corresponds to the "settle" timing of the hx-swap + htmx attribute. + + Example:: + + {% xinclude "foo.html" %}{% endxinclude %} + {% xinclude "foo.html" with foo="bar" only %}{% endxinclude %} + + Use "primary nodes" to render initial content prior to htmx swapping. + + {% xinclude "foo" hx-trigger="intersect once" swap-time="1s" settle-time="1s" %} +
Loading...
+ {% endxinclude %} + """ + + bits = token.split_contents() + remaining_bits = [] + options = {} + while bits: + # Find the xinclude-specific arguments and add the rest to remaining_bits + option = bits.pop(0) + try: + key, value = option.split("=", 1) + except ValueError: + remaining_bits.append(option) + continue + if key in SpecialVariables.raw_list(): + options[key.replace("-", "_")] = FilterExpression(value, parser) + else: + remaining_bits.append(option) + + token = Token( + token.token_type, " ".join(remaining_bits), token.position, token.lineno + ) + node = do_include(parser, token) # the regular IncludeNode + template = FilterExpression('"django_xinclude/include.html"', parser) + context = {**node.extra_context, **options} + # add the variable defaults to context + SpecialVariables.make_context_defaults(context, parser) + # extract the primary element + primary_nodelist = parser.parse(["endxinclude"]) + parser.delete_first_token() + + return HtmxIncludeNode( + template, + target_template=node.template, + extra_context=context, + isolated_context=node.isolated_context, + primary_nodelist=primary_nodelist, + ) diff --git a/src/django_xinclude/urls.py b/src/django_xinclude/urls.py new file mode 100644 index 0000000..5398ee2 --- /dev/null +++ b/src/django_xinclude/urls.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from django.urls import path + +from django_xinclude.views import XincludeView + +app_name = "django_xinclude" + +urlpatterns = [ + path("", XincludeView.as_view(), name="xinclude"), +] diff --git a/src/django_xinclude/views.py b/src/django_xinclude/views.py new file mode 100644 index 0000000..cddb0d7 --- /dev/null +++ b/src/django_xinclude/views.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from django.http import Http404, HttpResponseServerError +from django.template.exceptions import TemplateDoesNotExist +from django.template.loader import select_template +from django.views.generic import TemplateView + +from django_xinclude import logger +from django_xinclude.cache import ctx_cache +from django_xinclude.exceptions import FragmentNotFoundError + +if TYPE_CHECKING: + from django.http.request import HttpRequest + from django.http.response import HttpResponseBase + + +class XincludeView(TemplateView): + def dispatch( + self, request: HttpRequest, *args: Any, **kwargs: Any + ) -> HttpResponseBase: + try: + fragment_id = request.GET["fragment_id"] + except KeyError: + raise Http404 # noqa: B904 + try: + # noinspection PyAttributeOutsideInit + self.fragment = ctx_cache.get(fragment_id) + except (KeyError, TypeError): + raise Http404 # noqa: B904 + except FragmentNotFoundError: + logger.debug(f"fragment_id: {fragment_id} not found in cache.") + return HttpResponseServerError() + self.authorize_user() + return super().dispatch(request, *args, **kwargs) + + def authorize_user(self) -> None: + # The request user should be the one that initially accessed the parent view + # (and added to cache), or AnonymousUser in both cases; + # otherwise raise 404. + try: + if self.request.user != self.fragment.user: + raise Http404 + except AttributeError: + pass + + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: + return self.fragment.context + + def get_template_names(self) -> list[str]: + try: + select_template(self.fragment.template_names) + except TemplateDoesNotExist: + raise Http404 # noqa: B904 + return self.fragment.template_names diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/settings.py b/tests/settings.py new file mode 100644 index 0000000..4594f5b --- /dev/null +++ b/tests/settings.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.append("src") +BASE_DIR = Path(__file__).resolve().parent + +ALLOWED_HOSTS: list[str] = [] + +INSTALLED_APPS = [ + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django_xinclude", + "tests", +] + +MIDDLEWARE = [ + "django.contrib.sessions.middleware.SessionMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", +] + +ROOT_URLCONF = "tests.urls" + +SECRET_KEY = "NOTASECRET" + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.contrib.auth.context_processors.auth", + ], + "debug": True, + }, + }, +] diff --git a/tests/templates/tests/core.html b/tests/templates/tests/core.html new file mode 100644 index 0000000..e62e6dd --- /dev/null +++ b/tests/templates/tests/core.html @@ -0,0 +1,2 @@ +{% load xinclude %} +{% xinclude "tests/partials/hello.html" %}{% endxinclude %} diff --git a/tests/templates/tests/for_loop.html b/tests/templates/tests/for_loop.html new file mode 100644 index 0000000..bbda7bb --- /dev/null +++ b/tests/templates/tests/for_loop.html @@ -0,0 +1,4 @@ +{% load xinclude %} +{% for i in "00" %} + {% xinclude "tests/partials/hello.html" %}{% endxinclude %} +{% endfor %} diff --git a/tests/templates/tests/partials/hello.html b/tests/templates/tests/partials/hello.html new file mode 100644 index 0000000..e965047 --- /dev/null +++ b/tests/templates/tests/partials/hello.html @@ -0,0 +1 @@ +Hello diff --git a/tests/test_checks.py b/tests/test_checks.py new file mode 100644 index 0000000..4619bc8 --- /dev/null +++ b/tests/test_checks.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from django.core.management import call_command +from django.core.management.base import SystemCheckError +from django.test import SimpleTestCase, override_settings + + +class CheckTests(SimpleTestCase): + @override_settings(XINCLUDE_CACHE_TIMEOUT=0) + def test_cache_timeout_0(self): + message = ( + "(django_xinclude.E001) XINCLUDE_CACHE_TIMEOUT setting " + "should be greater than 0." + ) + with self.assertRaisesMessage(SystemCheckError, message): + call_command("check") + + @override_settings( + CACHES={ + "default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}, + }, + XINCLUDE_CACHE_ALIAS="other", + ) + def test_invalid_cache_alias(self): + message = '(django_xinclude.E002) Cache alias "other" not found in CACHES.' + with self.assertRaisesMessage(SystemCheckError, message): + call_command("check") diff --git a/tests/test_conf.py b/tests/test_conf.py new file mode 100644 index 0000000..4b7e404 --- /dev/null +++ b/tests/test_conf.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from django.test import SimpleTestCase +from django.test.utils import override_settings +from django_xinclude.conf import conf + + +class ConfTests(SimpleTestCase): + def test_sync_include_request_attr_default(self): + self.assertEqual(conf.XINCLUDE_SYNC_REQUEST_ATTR, "xinclude_sync") + + @override_settings(XINCLUDE_SYNC_REQUEST_ATTR="foo") + def test_sync_include_request_attr_override(self): + self.assertEqual(conf.XINCLUDE_SYNC_REQUEST_ATTR, "foo") + + def test_cache_alias_default(self): + self.assertEqual(conf.XINCLUDE_CACHE_ALIAS, "default") + + @override_settings(XINCLUDE_CACHE_ALIAS="custom") + def test_cache_alias_override(self): + self.assertEqual(conf.XINCLUDE_CACHE_ALIAS, "custom") + + def test_cache_timeout_default(self): + self.assertEqual(conf.XINCLUDE_CACHE_TIMEOUT, None) + + @override_settings(XINCLUDE_CACHE_TIMEOUT=100) + def test_cache_timeout_override(self): + self.assertEqual(conf.XINCLUDE_CACHE_TIMEOUT, 100) diff --git a/tests/tests.py b/tests/tests.py new file mode 100644 index 0000000..9501133 --- /dev/null +++ b/tests/tests.py @@ -0,0 +1,531 @@ +from __future__ import annotations + +import pickle +import re +import uuid +from io import TextIOWrapper +from typing import Any +from unittest import mock + +from django.contrib.auth.models import AnonymousUser, User +from django.core.cache import caches +from django.core.cache.backends.redis import RedisCache +from django.http import HttpRequest +from django.template import engines +from django.test import ( + RequestFactory, + SimpleTestCase, + TestCase, + override_settings, +) +from django.urls import reverse +from django_xinclude.cache import ctx_cache + + +def get_include_html(fragment_id, primary_nodes=""): + return ( + f"""
\n """ + f"""{primary_nodes}\n
""" + ) + + +def request_mock(**kwargs: Any) -> mock.MagicMock: + return mock.MagicMock(spec=HttpRequest, **kwargs) + + +class CacheTests(SimpleTestCase): + def test_context_cache_alias_default(self): + self.assertIs(ctx_cache.cache, caches["default"]) + + @override_settings( + CACHES={ + "default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}, + "redis": {"BACKEND": "django.core.cache.backends.redis.RedisCache"}, + }, + XINCLUDE_CACHE_ALIAS="redis", + ) + def test_context_cache_alias_override(self): + self.assertTrue(isinstance(ctx_cache.cache, RedisCache)) + + @mock.patch("django_xinclude.cache.ContextCache.cache") + def test_context_cache_timeout_default(self, cache): + ctx_cache.set("foo", {}) + cache.set.assert_called_once_with("foo", {}) # no kwarg + + @override_settings(XINCLUDE_CACHE_TIMEOUT=100) + @mock.patch("django_xinclude.cache.ContextCache.cache") + def test_context_cache_timeout_set(self, cache): + ctx_cache.set("foo", {}) + cache.set.assert_called_once_with("foo", {}, timeout=100) + + +class TemplateTagTests(SimpleTestCase): + fragment_id: str + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.fragment_id = str(uuid.uuid4()) + + def setUp(self): + super().setUp() + node_klass = "django_xinclude.templatetags.xinclude.HtmxIncludeNode" + fragment_patcher = mock.patch( + f"{node_klass}.fragment_id", + new_callable=mock.PropertyMock, + return_value=self.fragment_id, + ) + self.fragment = fragment_patcher.start() + self.addCleanup(fragment_patcher.stop) + cache_patcher = mock.patch("django_xinclude.cache.ContextCache.cache") + self.cache = cache_patcher.start() + self.addCleanup(cache_patcher.stop) + + def test_xinclude(self): + template = """ + {% load xinclude %} + {% xinclude "tests/partials/hello.html" %}{% endxinclude %} + """ + t = engines["django"].from_string(template) + rendered = t.render({"request": request_mock()}).strip() + self.assertEqual(rendered, get_include_html(self.fragment_id)) + + def test_cache_context(self): + template = """ + {% load xinclude %} + {% with foo="bar" %} + {% xinclude "tests/partials/hello.html" with zoo="car" %}{% endxinclude %} + {% endwith %} + """ + t = engines["django"].from_string(template) + t.render({"request": request_mock()}) + self.cache.set.assert_called_once() + fragment_id, ctx = self.cache.set.mock_calls[0].args + self.assertEqual(fragment_id, self.fragment_id) + self.assertTrue({"foo": "bar", "zoo": "car"}.items() <= ctx["context"].items()) + self.assertEqual(ctx["meta"]["template_names"], ["tests/partials/hello.html"]) + + def test_skips_unpickable_objects(self): + template = """ + {% load xinclude %} + {% xinclude "tests/partials/hello.html" %}{% endxinclude %} + """ + t = engines["django"].from_string(template) + with mock.patch("django_xinclude.cache.logger") as logger: + t.render({"a": mock.MagicMock(), "b": mock.MagicMock()}) + ctx = self.cache.set.mock_calls[0].args[1]["context"] + self.assertNotIn("a", ctx) + self.assertNotIn("b", ctx) + logger.debug.assert_called_once_with( + "The values for the following keys could not get pickled and thus were not " + f"stored in cache (fragment_id: {self.fragment_id}): ['a', 'b']" + ) + + def test_pickling_typerror_is_tolerated(self): + unp = TextIOWrapper(mock.MagicMock()) + self.assertRaises(TypeError, lambda: pickle.dumps(unp)) + template = """ + {% load xinclude %} + {% xinclude "tests/partials/hello.html" %}{% endxinclude %} + """ + t = engines["django"].from_string(template) + req = RequestFactory().get("") + req.user = AnonymousUser() + t.render({"request": req, "zoo": unp}) + ctx = self.cache.set.mock_calls[0].args[1] + self.assertIn("user", ctx["meta"]) + self.assertNotIn("zoo", ctx) + + def test_context_only(self): + template = """ + {% load xinclude %} + {% with foo="bar" %} + {% xinclude "tests/partials/hello.html" with zoo="car" only %}{% endxinclude %} + {% endwith %} + """ + t = engines["django"].from_string(template) + t.render({"request": request_mock()}) + ctx = self.cache.set.mock_calls[0].args[1]["context"] + self.assertEqual(ctx["zoo"], "car") + self.assertNotIn("foo", ctx) + + def test_trigger_may_be_passed(self): + template = """ + {% load xinclude %} + {% xinclude "tests/partials/hello.html" hx-trigger="intersect once" %} + {% endxinclude %} + """ + t = engines["django"].from_string(template) + self.assertIn( + 'hx-trigger="intersect once"', t.render({"request": request_mock()}) + ) + + def test_hx_vars_not_passed_to_cache(self): + template = """ + {% load xinclude %} + {% xinclude "tests/partials/hello.html" hx-trigger="intersect once" %} + {% endxinclude %} + """ + t = engines["django"].from_string(template) + t.render({"request": request_mock()}) + ctx = self.cache.set.mock_calls[0].args[1] + self.assertNotIn("hx_trigger", ctx) + + def test_with_and_trigger(self): + template = ( + """ + {% load xinclude %} + {% xinclude "tests/partials/hello.html" """ + + """hx-trigger="intersect once" with foo="bar" %}{% endxinclude %} + """ + ) + t = engines["django"].from_string(template) + self.assertIn( + 'hx-trigger="intersect once"', t.render({"request": request_mock()}) + ) + self.cache.set.assert_called_once() + ctx = self.cache.set.mock_calls[0].args[1]["context"] + self.assertTrue({"foo": "bar"}.items() <= ctx.items()) + + def test_only_and_trigger(self): + template = ( + """ + {% load xinclude %} + {% with foo="bar" %} + {% xinclude "tests/partials/hello.html" """ + + """hx-trigger="intersect once" with zoo="car" only %}{% endxinclude %} + {% endwith %} + """ + ) + t = engines["django"].from_string(template) + rendered = t.render({"request": request_mock()}) + self.assertIn('hx-trigger="intersect once"', rendered) + self.cache.set.assert_called_once() + ctx = self.cache.set.mock_calls[0].args[1]["context"] + self.assertTrue({"zoo": "car"}.items() <= ctx.items()) + self.assertNotIn("foo", ctx.keys()) + + def test_timing(self): + template = """ + {% load xinclude %} + {% xinclude "tests/partials/hello.html" swap-time="1s" settle-time="2s" %} + {% endxinclude %} + """ + t = engines["django"].from_string(template) + self.assertIn( + 'hx-swap="outerHTML swap:1s settle:2s"', + t.render({"request": request_mock()}), + ) + + def test_sync_include(self): + template = """ + {% load xinclude %} + {% xinclude "tests/partials/hello.html" hx-trigger="intersect once" %} + {% endxinclude %} + """ + t = engines["django"].from_string(template) + rendered = t.render(request=mock.MagicMock(xinclude_sync=True)).strip() + self.assertEqual(rendered, "Hello") + + def test_xinclude_template_variable(self): + template = """ + {% load xinclude %} + {% xinclude template_name %}{% endxinclude %} + """ + t = engines["django"].from_string(template) + rendered = t.render( + {"template_name": "tests/partials/hello.html", "request": request_mock()} + ).strip() + self.assertEqual(rendered, get_include_html(self.fragment_id)) + + def test_xinclude_template_variable_iterable(self): + template = """ + {% load xinclude %} + {% xinclude template_name %}{% endxinclude %} + """ + t = engines["django"].from_string(template) + rendered = t.render( + {"template_name": ["tests/partials/hello.html"], "request": request_mock()} + ).strip() + self.assertEqual(rendered, get_include_html(self.fragment_id)) + ctx = self.cache.set.mock_calls[0].args[1] + self.assertEqual(ctx["meta"]["template_names"], ["tests/partials/hello.html"]) + + def test_for_loop_include(self): + template = """ + {% load xinclude %} + {% for i in "00" %} + {% xinclude "tests/partials/hello.html" %}{% endxinclude %} + {% endfor %} + """ + self.fragment.side_effect = ["fr1", "fr2"] + t = engines["django"].from_string(template) + rendered = t.render({"request": request_mock()}) + fragment_ids = re.findall(r'\?fragment_id=([\w-]+)"', rendered) + self.assertEqual(fragment_ids, ["fr1", "fr2"]) + + def test_primary_nodes(self): + template = """ + {% load xinclude %} + {% xinclude "tests/partials/hello.html" %} +
Processing
+ {% with foo="bar" %}
foo
{% endwith %} + {% endxinclude %} + """ + t = engines["django"].from_string(template) + rendered = t.render({}).strip() + pr_el = '
Processing
\n
foo
' + self.assertEqual(rendered, get_include_html(self.fragment_id, pr_el)) + + +class ViewTests(SimpleTestCase): + def setUp(self): + super().setUp() + self.anon = AnonymousUser() + cache_patcher = mock.patch("django_xinclude.cache.ContextCache.cache") + self.cache = cache_patcher.start() + self.addCleanup(cache_patcher.stop) + + def test_view_renders_passed_template(self): + self.cache.get.return_value = { + "meta": { + "user": self.anon, + "template_names": ["tests/partials/hello.html"], + }, + "context": {}, + } + url = "/__xinclude__/?fragment_id=abc123" + response = self.client.get(url) + self.assertTemplateUsed(response, "tests/partials/hello.html") + self.assertEqual(response.rendered_content, "Hello\n") # type: ignore[attr-defined] + + def test_view_context(self): + self.cache.get.return_value = { + "meta": { + "user": self.anon, + "template_names": ["tests/partials/hello.html"], + }, + "context": {"foo": "bar"}, + } + url = "/__xinclude__/?fragment_id=abc123" + response = self.client.get(url) + self.assertEqual(response.context_data, {"foo": "bar"}) # type: ignore[attr-defined] + + def test_missing_context(self): + self.cache.get.return_value = None + url = "/__xinclude__/?fragment_id=missing" + with mock.patch("django_xinclude.views.logger") as logger: + response = self.client.get(url) + self.assertEqual(response.status_code, 500) + logger.debug.assert_called_once_with("fragment_id: missing not found in cache.") + + def test_missing_meta(self): + self.cache.get.return_value = {} + url = "/__xinclude__/?fragment_id=abc123" + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + + def test_missing_meta_user(self): + self.cache.get.return_value = { + "meta": {"template_names": ["tests/partials/hello.html"]}, + "context": {}, + } + url = "/__xinclude__/?fragment_id=abc123" + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + + def test_missing_fragment_id(self): + url = "/__xinclude__/" + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + + def test_missing_template_404(self): + self.cache.get.return_value = {"meta": {"user": self.anon}, "context": {}} + url = "/__xinclude__/?fragment_id=abc123" + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + + def test_only_1_fragment_id_allowed(self): + self.cache.get.return_value = {"meta": {"user": self.anon}, "context": {}} + url = "/__xinclude__/?fragment_id=abc123&fragment_id=abc123" + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + + def test_inexistent_template_404(self): + self.cache.get.return_value = { + "meta": {"user": self.anon, "template_names": ["inexistent"]}, + "context": {}, + } + url = "/__xinclude__/?fragment_id=abc123" + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + + def test_missing_context_404(self): + self.cache.get.return_value = { + "meta": {"user": self.anon, "template_names": ["tests/partials/hello.html"]} + } + url = "/__xinclude__/?fragment_id=abc123" + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + + def test_multiple_templates_inexistent_first(self): + self.cache.get.return_value = { + "meta": { + "user": self.anon, + "template_names": ["inexistent", "tests/partials/hello.html"], + }, + "context": {}, + } + url = "/__xinclude__/?fragment_id=abc123" + response = self.client.get(url) + self.assertTemplateUsed(response, "tests/partials/hello.html") + self.assertEqual(response.rendered_content, "Hello\n") # type: ignore[attr-defined] + + def test_multiple_templates_inexistent_second(self): + self.cache.get.return_value = { + "meta": { + "user": self.anon, + "template_names": ["tests/partials/hello.html", "inexistent"], + }, + "context": {}, + } + url = "/__xinclude__/?fragment_id=abc123" + response = self.client.get(url) + self.assertTemplateUsed(response, "tests/partials/hello.html") + self.assertEqual(response.rendered_content, "Hello\n") # type: ignore[attr-defined] + + +def get_templates_settings(context_processors: list[str]) -> list[dict[str, Any]]: + return [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "APP_DIRS": True, + "OPTIONS": {"context_processors": context_processors}, + } + ] + + +class AuthTests(TestCase): + def setUp(self): + super().setUp() + cache_patcher = mock.patch("django_xinclude.cache.ContextCache.cache") + self.cache = cache_patcher.start() + self.addCleanup(cache_patcher.stop) + + def test_tag_stores_authed_user_in_cache(self): + user = User.objects.create() + self.client.force_login(user) + self.client.get(reverse("core")) + ctx = self.cache.set.mock_calls[0].args[1] + self.assertEqual(ctx["meta"]["user"], user) + + def test_tag_stores_anon_user_in_cache(self): + self.client.get(reverse("core")) + ctx = self.cache.set.mock_calls[0].args[1] + self.assertEqual(ctx["meta"]["user"], AnonymousUser()) + + @override_settings( + INSTALLED_APPS=["django.contrib.auth", "django_xinclude", "tests"], + TEMPLATES=get_templates_settings( + ["django.template.context_processors.request"] + ), + ) + def test_tag_store_user_minimal_processors_1(self): + self.client.get(reverse("core")) + ctx = self.cache.set.mock_calls[0].args[1] + self.assertEqual(ctx["meta"]["user"], AnonymousUser()) + + @override_settings( + INSTALLED_APPS=["django.contrib.auth", "django_xinclude", "tests"], + TEMPLATES=get_templates_settings( + ["django.contrib.auth.context_processors.auth"] + ), + ) + def test_tag_store_user_minimal_processors_2(self): + self.client.get(reverse("core")) + ctx = self.cache.set.mock_calls[0].args[1] + self.assertEqual(ctx["meta"]["user"], AnonymousUser()) + + @override_settings( + INSTALLED_APPS=["django_xinclude", "tests"], + TEMPLATES=get_templates_settings( + ["django.contrib.auth.context_processors.auth"] + ), + MIDDLEWARE=[], + ) + def test_tag_store_user_minimal_processors_3(self): + self.client.get(reverse("core")) + ctx = self.cache.set.mock_calls[0].args[1] + self.assertEqual(ctx["meta"]["user"], AnonymousUser()) + + @override_settings( + INSTALLED_APPS=["django_xinclude", "tests"], + TEMPLATES=get_templates_settings([]), + MIDDLEWARE=[], + ) + def test_tag_may_not_store_user_1(self): + self.client.get(reverse("core")) + ctx = self.cache.set.mock_calls[0].args[1] + self.assertNotIn("user", ctx["meta"]) + + @override_settings( + INSTALLED_APPS=["django_xinclude", "tests"], + TEMPLATES=get_templates_settings( + ["django.template.context_processors.request"] + ), + MIDDLEWARE=[], + ) + def test_tag_may_not_store_user_2(self): + self.client.get(reverse("core")) + ctx = self.cache.set.mock_calls[0].args[1] + self.assertNotIn("user", ctx["meta"]) + + def test_view_404_for_different_user(self): + self.cache.get.return_value = { + "meta": { + "user": mock.MagicMock(pk=10), + "template_names": ["tests/partials/hello.html"], + }, + "context": {}, + } + user = User.objects.create(id=99) + self.client.force_login(user) + url = "/__xinclude__/?fragment_id=abc123" + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + + def test_view_200_for_same_user(self): + user = User.objects.create(id=99) + self.cache.get.return_value = { + "meta": {"user": user, "template_names": ["tests/partials/hello.html"]}, + "context": {}, + } + self.client.force_login(user) + url = "/__xinclude__/?fragment_id=abc123" + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + @override_settings(INSTALLED_APPS=["django_xinclude", "tests"], MIDDLEWARE=[]) + def test_view_cache_no_user(self): + # If auth is disabled altogether, then we assume that + # the user can access the view. + self.cache.get.return_value = { + "meta": {"template_names": ["tests/partials/hello.html"]}, + "context": {}, + } + url = "/__xinclude__/?fragment_id=abc123" + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + def test_cache_no_user_and_view_user(self): + # Not sure if this can even happen, but we should raise 404 if it does. + self.cache.get.return_value = { + "meta": {"template_names": ["tests/partials/hello.html"]}, + "context": {}, + } + user = User.objects.create(id=99) + self.client.force_login(user) + url = "/__xinclude__/?fragment_id=abc123" + response = self.client.get(url) + self.assertEqual(response.status_code, 404) diff --git a/tests/urls.py b/tests/urls.py new file mode 100644 index 0000000..dde0a49 --- /dev/null +++ b/tests/urls.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from django.urls import include, path + +from tests import views + +urlpatterns = [ + path("__xinclude__/", include("django_xinclude.urls")), + path("core/", views.CoreView.as_view(), name="core"), + path("for_loop/", views.ForLoopView.as_view(), name="for_loop"), +] diff --git a/tests/views.py b/tests/views.py new file mode 100644 index 0000000..706da1a --- /dev/null +++ b/tests/views.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from django.views.generic import TemplateView + + +class CoreView(TemplateView): + template_name = "tests/core.html" + + +class ForLoopView(TemplateView): + template_name = "tests/for_loop.html"